Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class Locator implements api.Locator {
this._selector = selector;

if (options?.hasText) {
const textSelector = 'text=' + escapeForTextSelector(options.hasText, false);
const textSelector = 'internal:text=' + escapeForTextSelector(options.hasText, false);
this._selector += ` >> internal:has=${JSON.stringify(textSelector)}`;
}

Expand Down Expand Up @@ -421,7 +421,7 @@ export function getByPlaceholderSelector(text: string | RegExp, options?: { exac
}

export function getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string {
return 'text=' + escapeForTextSelector(text, !!options?.exact);
return 'internal:text=' + escapeForTextSelector(text, !!options?.exact);
}

export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string {
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,14 @@ export class FrameExecutionContext extends js.ExecutionContext {
const custom: string[] = [];
for (const [name, { source }] of this.frame._page.selectors._engines)
custom.push(`{ name: '${name}', engine: (${source}) }`);
const sdkLanguage = this.frame._page.context()._browser.options.sdkLanguage;
const source = `
(() => {
const module = {};
${injectedScriptSource.source}
return new module.exports(
${isUnderTest()},
"${sdkLanguage}",
${this.frame._page._delegate.rafCountForStablePosition()},
"${this.frame._page._browserContext._browser.options.name}",
[${custom.join(',\n')}]
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/injected/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function createLocator(injectedScript: InjectedScript, initial: string, options?
constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
this.selector = selector;
if (options?.hasText) {
const textSelector = 'text=' + escapeForTextSelector(options.hasText, false);
const textSelector = 'internal:text=' + escapeForTextSelector(options.hasText, false);
this.selector += ` >> internal:has=${JSON.stringify(textSelector)}`;
}
if (options?.has)
Expand Down
43 changes: 28 additions & 15 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import type * as channels from '@protocol/channels';
import { Highlight } from './highlight';
import { getAriaDisabled, getAriaRole, getElementAccessibleName } from './roleUtils';
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../isomorphic/locatorGenerators';
import type { Language } from '../isomorphic/locatorGenerators';

type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;

Expand Down Expand Up @@ -79,9 +81,11 @@ export class InjectedScript {
private _hitTargetInterceptor: undefined | ((event: MouseEvent | PointerEvent | TouchEvent) => void);
private _highlight: Highlight | undefined;
readonly isUnderTest: boolean;
private _sdkLanguage: Language;

constructor(isUnderTest: boolean, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) {
constructor(isUnderTest: boolean, sdkLanguage: Language, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) {
this.isUnderTest = isUnderTest;
this._sdkLanguage = sdkLanguage;
this._evaluator = new SelectorEvaluatorImpl(new Map());

this._engines = new Map();
Expand All @@ -90,8 +94,8 @@ export class InjectedScript {
this._engines.set('_react', ReactEngine);
this._engines.set('_vue', VueEngine);
this._engines.set('role', RoleEngine);
this._engines.set('text', this._createTextEngine(true));
this._engines.set('text:light', this._createTextEngine(false));
this._engines.set('text', this._createTextEngine(true, false));
this._engines.set('text:light', this._createTextEngine(false, false));
this._engines.set('id', this._createAttributeEngine('id', true));
this._engines.set('id:light', this._createAttributeEngine('id', false));
this._engines.set('data-testid', this._createAttributeEngine('data-testid', true));
Expand All @@ -105,7 +109,8 @@ export class InjectedScript {
this._engines.set('visible', this._createVisibleEngine());
this._engines.set('internal:control', this._createControlEngine());
this._engines.set('internal:has', this._createHasEngine());
this._engines.set('internal:label', this._createLabelEngine());
this._engines.set('internal:label', this._createInternalLabelEngine());
this._engines.set('internal:text', this._createTextEngine(true, true));
this._engines.set('internal:attr', this._createNamedAttributeEngine());

for (const { name, engine } of customEngines)
Expand Down Expand Up @@ -242,9 +247,9 @@ export class InjectedScript {
};
}

private _createTextEngine(shadow: boolean): SelectorEngine {
private _createTextEngine(shadow: boolean, internal: boolean): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string): Element[] => {
const { matcher, kind } = createTextMatcher(selector, false);
const { matcher, kind } = createTextMatcher(selector, false, internal);
const result: Element[] = [];
let lastDidNotMatchSelf: Element | null = null;

Expand Down Expand Up @@ -274,11 +279,11 @@ export class InjectedScript {
};
}

private _createLabelEngine(): SelectorEngine {
private _createInternalLabelEngine(): SelectorEngine {
const evaluator = this._evaluator;
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
const { matcher } = createTextMatcher(selector, true);
const { matcher } = createTextMatcher(selector, true, true);
const result: Element[] = [];
const labels = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, 'label') as HTMLLabelElement[];
for (const label of labels) {
Expand Down Expand Up @@ -993,7 +998,7 @@ export class InjectedScript {
preview: this.previewNode(m),
selector: this.generateSelector(m),
}));
const lines = infos.map((info, i) => `\n ${i + 1}) ${info.preview} aka playwright.$("${info.selector}")`);
const lines = infos.map((info, i) => `\n ${i + 1}) ${info.preview} aka page.${asLocator(this._sdkLanguage, info.selector)}`);
if (infos.length < matches.length)
lines.push('\n ...');
return this.createStacklessError(`strict mode violation: "${stringifySelector(selector)}" resolved to ${matches.length} elements:${lines.join('')}\n`);
Expand Down Expand Up @@ -1281,7 +1286,9 @@ const kTapHitTargetInterceptorEvents = new Set(['pointerdown', 'pointerup', 'tou
const kMouseHitTargetInterceptorEvents = new Set(['mousedown', 'mouseup', 'pointerdown', 'pointerup', 'click', 'auxclick', 'dblclick', 'contextmenu']);
const kAllHitTargetInterceptorEvents = new Set([...kHoverHitTargetInterceptorEvents, ...kTapHitTargetInterceptorEvents, ...kMouseHitTargetInterceptorEvents]);

function unescape(s: string): string {
function cssUnquote(s: string): string {
// Trim quotes.
s = s.substring(1, s.length - 1);
if (!s.includes('\\'))
return s;
const r: string[] = [];
Expand All @@ -1294,19 +1301,25 @@ function unescape(s: string): string {
return r.join('');
}

function createTextMatcher(selector: string, strictMatchesFullText: boolean): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } {
function createTextMatcher(selector: string, strictMatchesFullText: boolean, internal: boolean): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } {
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
const lastSlash = selector.lastIndexOf('/');
const matcher: TextMatcher = createRegexTextMatcher(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
return { matcher, kind: 'regex' };
}
const unquote = internal ? JSON.parse.bind(JSON) : cssUnquote;
let strict = false;
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
selector = unescape(selector.substring(1, selector.length - 1));
selector = unquote(selector);
strict = true;
}
if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
selector = unescape(selector.substring(1, selector.length - 1));
} else if (internal && selector.length > 1 && selector[0] === '"' && selector[selector.length - 2] === '"' && selector[selector.length - 1] === 'i') {
selector = unquote(selector.substring(0, selector.length - 1));
strict = false;
} else if (internal && selector.length > 1 && selector[0] === '"' && selector[selector.length - 2] === '"' && selector[selector.length - 1] === 's') {
selector = unquote(selector.substring(0, selector.length - 1));
strict = true;
} else if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
Comment thread
pavelfeldman marked this conversation as resolved.
selector = unquote(selector);
strict = true;
}
if (strict)
Expand Down
29 changes: 18 additions & 11 deletions packages/playwright-core/src/server/injected/selectorGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
const calculate = (element: Element, allowText: boolean): SelectorToken[] | null => {
const allowNthMatch = element === targetElement;

let textCandidates = allowText ? buildTextCandidates(injectedScript, element, element === targetElement).map(token => [token]) : [];
let textCandidates = allowText ? buildTextCandidates(injectedScript, element, element === targetElement, accessibleNameCache) : [];
if (element !== targetElement) {
// Do not use regex for parent elements (for performance).
textCandidates = filterRegexTokens(textCandidates);
Expand Down Expand Up @@ -162,7 +162,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, acces
const label = input.labels?.[0];
if (label) {
const labelText = elementText(injectedScript._evaluator._cacheText, label).full.trim();
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, false, true), score: 3 });
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, false), score: 3 });
}
}

Expand Down Expand Up @@ -197,25 +197,32 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, acces
return candidates;
}

function buildTextCandidates(injectedScript: InjectedScript, element: Element, isTargetNode: boolean): SelectorToken[] {
function buildTextCandidates(injectedScript: InjectedScript, element: Element, isTargetNode: boolean, accessibleNameCache: Map<Element, boolean>): SelectorToken[][] {
if (element.nodeName === 'SELECT')
return [];
const text = elementText(injectedScript._evaluator._cacheText, element).full.trim().replace(/\s+/g, ' ').substring(0, 80);
if (!text)
return [];
const candidates: SelectorToken[] = [];
const candidates: SelectorToken[][] = [];

const escaped = escapeForTextSelector(text, false, true);
const escaped = escapeForTextSelector(text, false);

if (isTargetNode)
candidates.push({ engine: 'text', selector: escaped, score: 10 });
candidates.push([{ engine: 'internal:text', selector: escaped, score: 10 }]);

if (escaped === text) {
let prefix = element.nodeName.toLowerCase();
if (element.hasAttribute('role'))
prefix += `[role=${quoteAttributeValue(element.getAttribute('role')!)}]`;
candidates.push({ engine: 'css', selector: `${prefix}:has-text("${text}")`, score: 10 });
const ariaRole = getAriaRole(element);
const candidate: SelectorToken[] = [];
if (ariaRole) {
const ariaName = getElementAccessibleName(element, false, accessibleNameCache);
if (ariaName)
candidate.push({ engine: 'role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: 10 });
else
candidate.push({ engine: 'role', selector: ariaRole, score: 10 });
} else {
candidate.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: 10 });
}
candidate.push({ engine: 'internal:has', selector: JSON.stringify('internal:text=' + escaped), score: 0 });
candidates.push(candidate);
return candidates;
}

Expand Down
Loading