chore: migrate to the internal:text selector (#18135)
This commit is contained in:
Родитель
098de5009e
Коммит
304a4ee8ec
|
@ -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)}`;
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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')}]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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));
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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`);
|
||||
|
@ -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[] = [];
|
||||
|
@ -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] === "'") {
|
||||
selector = unquote(selector);
|
||||
strict = true;
|
||||
}
|
||||
if (strict)
|
||||
|
|
|
@ -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);
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
*/
|
||||
|
||||
import { escapeWithQuotes, toSnakeCase, toTitleCase } from '../../utils/isomorphic/stringUtils';
|
||||
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
|
||||
import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
||||
import type { NestedSelectorBody } from '../isomorphic/selectorParser';
|
||||
import type { ParsedSelector } from '../isomorphic/selectorParser';
|
||||
|
||||
export type Language = 'javascript' | 'python' | 'java' | 'csharp';
|
||||
|
@ -24,7 +24,7 @@ export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder'
|
|||
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
|
||||
|
||||
export interface LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean }): string;
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: { attrs?: Record<string, string | boolean>, exact?: boolean }): string;
|
||||
}
|
||||
|
||||
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
|
||||
|
@ -45,7 +45,7 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
|
|||
tokens.push(factory.generateLocator(base, 'nth', part.body as string));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'text') {
|
||||
if (part.name === 'internal:text') {
|
||||
const { exact, text } = detectExact(part.body as string);
|
||||
tokens.push(factory.generateLocator(base, 'text', text, { exact }));
|
||||
continue;
|
||||
|
@ -63,11 +63,11 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
|
|||
tokens.push(factory.generateLocator(base, 'role', attrSelector.name, { attrs }));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'css') {
|
||||
const parsed = part.body as CSSComplexSelectorList;
|
||||
if (parsed[0].simples.length === 1 && parsed[0].simples[0].selector.functions.length === 1 && parsed[0].simples[0].selector.functions[0].name === 'hasText') {
|
||||
const hasText = parsed[0].simples[0].selector.functions[0].args[0] as string;
|
||||
tokens.push(factory.generateLocator(base, 'has-text', parsed[0].simples[0].selector.css!, { hasText }));
|
||||
if (part.name === 'internal:has') {
|
||||
const nested = (part.body as NestedSelectorBody).parsed;
|
||||
if (nested?.parts?.[0]?.name === 'internal:text') {
|
||||
const result = detectExact(nested.parts[0].body as string);
|
||||
tokens.push(factory.generateLocator(base, 'has-text', result.text, { exact: result.exact }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
@ -94,10 +94,6 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
|
|||
tokens.push(factory.generateLocator(base, 'title', text, { exact }));
|
||||
continue;
|
||||
}
|
||||
if (name === 'label') {
|
||||
tokens.push(factory.generateLocator(base, 'label', text, { exact }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const p: ParsedSelector = { parts: [part] };
|
||||
tokens.push(factory.generateLocator(base, 'default', stringifySelector(p)));
|
||||
|
@ -110,15 +106,21 @@ function detectExact(text: string): { exact?: boolean, text: string | RegExp } {
|
|||
const match = text.match(/^\/(.*)\/([igm]*)$/);
|
||||
if (match)
|
||||
return { text: new RegExp(match[1], match[2]) };
|
||||
if (text.startsWith('"') && text.endsWith('"')) {
|
||||
if (text.endsWith('"')) {
|
||||
text = JSON.parse(text);
|
||||
exact = true;
|
||||
} else if (text.endsWith('"s')) {
|
||||
text = JSON.parse(text.substring(0, text.length - 1));
|
||||
exact = true;
|
||||
} else if (text.endsWith('"i')) {
|
||||
text = JSON.parse(text.substring(0, text.length - 1));
|
||||
exact = false;
|
||||
}
|
||||
return { exact, text };
|
||||
}
|
||||
|
||||
export class JavaScriptLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, exact?: boolean } = {}): string {
|
||||
switch (kind) {
|
||||
case 'default':
|
||||
return `locator(${this.quote(body as string)})`;
|
||||
|
@ -135,7 +137,7 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
|
|||
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
|
||||
return `getByRole(${this.quote(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `locator(${this.quote(body as string)}, { hasText: ${this.quote(options.hasText!)} })`;
|
||||
return `filter({ hasText: ${this.toHasText(body as string)} })`;
|
||||
case 'test-id':
|
||||
return `getByTestId(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
|
@ -159,13 +161,19 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
|
|||
return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body))
|
||||
return String(body);
|
||||
return this.quote(body);
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, '\'');
|
||||
}
|
||||
}
|
||||
|
||||
export class PythonLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, exact?: boolean } = {}): string {
|
||||
switch (kind) {
|
||||
case 'default':
|
||||
return `locator(${this.quote(body as string)})`;
|
||||
|
@ -182,7 +190,7 @@ export class PythonLocatorFactory implements LocatorFactory {
|
|||
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
|
||||
return `get_by_role(${this.quote(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `locator(${this.quote(body as string)}, has_text=${this.quote(options.hasText!)})`;
|
||||
return `filter(has_text=${this.toHasText(body as string)})`;
|
||||
case 'test-id':
|
||||
return `get_by_test_id(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
|
@ -210,13 +218,21 @@ export class PythonLocatorFactory implements LocatorFactory {
|
|||
return `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body)) {
|
||||
const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : '';
|
||||
return `re.compile(r${this.quote(body.source)}${suffix})`;
|
||||
}
|
||||
return `${this.quote(body)}`;
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, '\"');
|
||||
}
|
||||
}
|
||||
|
||||
export class JavaLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, exact?: boolean } = {}): string {
|
||||
let clazz: string;
|
||||
switch (base) {
|
||||
case 'page': clazz = 'Page'; break;
|
||||
|
@ -239,7 +255,7 @@ export class JavaLocatorFactory implements LocatorFactory {
|
|||
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
|
||||
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
|
||||
case 'has-text':
|
||||
return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.quote(options.hasText!)}))`;
|
||||
return `filter(new ${clazz}.LocatorOptions().setHasText(${this.toHasText(body)}))`;
|
||||
case 'test-id':
|
||||
return `getByTestId(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
|
@ -267,13 +283,21 @@ export class JavaLocatorFactory implements LocatorFactory {
|
|||
return `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body)) {
|
||||
const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : '';
|
||||
return `Pattern.compile(${this.quote(body.source)}${suffix})`;
|
||||
}
|
||||
return this.quote(body);
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, '\"');
|
||||
}
|
||||
}
|
||||
|
||||
export class CSharpLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, exact?: boolean } = {}): string {
|
||||
switch (kind) {
|
||||
case 'default':
|
||||
return `Locator(${this.quote(body as string)})`;
|
||||
|
@ -292,7 +316,7 @@ export class CSharpLocatorFactory implements LocatorFactory {
|
|||
const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : '';
|
||||
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `Locator(${this.quote(body as string)}, new() { HasTextString: ${this.quote(options.hasText!)} })`;
|
||||
return `Filter(new() { HasTextString: ${this.toHasText(body)} })`;
|
||||
case 'test-id':
|
||||
return `GetByTestId(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
|
@ -320,6 +344,14 @@ export class CSharpLocatorFactory implements LocatorFactory {
|
|||
return `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body)) {
|
||||
const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : '';
|
||||
return `new Regex(${this.quote(body.source)}${suffix})`;
|
||||
}
|
||||
return this.quote(body);
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, '\"');
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ export class Selectors {
|
|||
'data-test-id', 'data-test-id:light',
|
||||
'data-test', 'data-test:light',
|
||||
'nth', 'visible', 'internal:control', 'internal:has',
|
||||
'role', 'internal:attr', 'internal:label'
|
||||
'role', 'internal:attr', 'internal:label', 'internal:text'
|
||||
]);
|
||||
this._builtinEnginesInMainWorld = new Set([
|
||||
'_react', '_vue',
|
||||
|
|
|
@ -58,18 +58,10 @@ function cssEscapeOne(s: string, i: number): string {
|
|||
return '\\' + s.charAt(i);
|
||||
}
|
||||
|
||||
function escapeForRegex(text: string): string {
|
||||
return text.replace(/[.*+?^>${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function escapeForTextSelector(text: string | RegExp, exact: boolean, caseSensitive = false): string {
|
||||
export function escapeForTextSelector(text: string | RegExp, exact: boolean): string {
|
||||
if (typeof text !== 'string')
|
||||
return String(text);
|
||||
if (exact)
|
||||
return '"' + text.replace(/["]/g, '\\"') + '"';
|
||||
if (text.includes('"') || text.includes('>>') || text[0] === '/')
|
||||
return `/${escapeForRegex(text).replace(/\s+/g, '\\s+')}/` + (caseSensitive ? '' : 'i');
|
||||
return text;
|
||||
return `${JSON.stringify(text)}${exact ? '' : 'i'}`;
|
||||
}
|
||||
|
||||
export function escapeForAttributeSelector(value: string, exact: boolean): string {
|
||||
|
|
|
@ -192,7 +192,7 @@ test.describe('cli codegen', () => {
|
|||
});
|
||||
|
||||
const selector = await recorder.hoverOverElement('div');
|
||||
expect(selector).toBe('text=Some long text here');
|
||||
expect(selector).toBe('internal:text="Some long text here"i');
|
||||
|
||||
// Sanity check that selector does not match our highlight.
|
||||
const divContents = await page.$eval(selector, div => div.outerHTML);
|
||||
|
@ -584,7 +584,7 @@ test.describe('cli codegen', () => {
|
|||
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
|
||||
|
||||
const selector = await recorder.hoverOverElement('a');
|
||||
expect(selector).toBe('text=link');
|
||||
expect(selector).toBe('internal:text="link"i');
|
||||
const [, sources] = await Promise.all([
|
||||
page.waitForNavigation(),
|
||||
recorder.waitForOutput('JavaScript', 'waitForURL'),
|
||||
|
@ -629,7 +629,7 @@ test.describe('cli codegen', () => {
|
|||
await recorder.setContentAndWait(`<a onclick="setTimeout(() => window.location.href='about:blank#foo', 1000)">link</a>`);
|
||||
|
||||
const selector = await recorder.hoverOverElement('a');
|
||||
expect(selector).toBe('text=link');
|
||||
expect(selector).toBe('internal:text="link"i');
|
||||
|
||||
const [, sources] = await Promise.all([
|
||||
page.waitForNavigation(),
|
||||
|
|
|
@ -325,7 +325,7 @@ test.describe('cli codegen', () => {
|
|||
await recorder.setContentAndWait(`<label for=target>Country</label><input id=target>`);
|
||||
|
||||
const selector = await recorder.hoverOverElement('input');
|
||||
expect(selector).toBe('internal:label=Country');
|
||||
expect(selector).toBe('internal:label="Country"i');
|
||||
|
||||
const [sources] = await Promise.all([
|
||||
recorder.waitForOutput('JavaScript', 'click'),
|
||||
|
@ -348,13 +348,13 @@ test.describe('cli codegen', () => {
|
|||
await page.GetByLabel("Country").ClickAsync();`);
|
||||
});
|
||||
|
||||
test('should generate getByLabel with regex', async ({ page, openRecorder }) => {
|
||||
test('should generate getByLabel without regex', async ({ page, openRecorder }) => {
|
||||
const recorder = await openRecorder();
|
||||
|
||||
await recorder.setContentAndWait(`<label for=target>Coun"try</label><input id=target>`);
|
||||
|
||||
const selector = await recorder.hoverOverElement('input');
|
||||
expect(selector).toBe('internal:label=/Coun"try/');
|
||||
expect(selector).toBe('internal:label="Coun\\\"try"i');
|
||||
|
||||
const [sources] = await Promise.all([
|
||||
recorder.waitForOutput('JavaScript', 'click'),
|
||||
|
@ -362,18 +362,18 @@ test.describe('cli codegen', () => {
|
|||
]);
|
||||
|
||||
expect.soft(sources.get('JavaScript').text).toContain(`
|
||||
await page.getByLabel(/Coun"try/).click();`);
|
||||
await page.getByLabel('Coun\"try').click();`);
|
||||
|
||||
expect.soft(sources.get('Python').text).toContain(`
|
||||
page.get_by_label(re.compile(r"Coun\\\"try")).click()`);
|
||||
page.get_by_label("Coun\\"try").click()`);
|
||||
|
||||
expect.soft(sources.get('Python Async').text).toContain(`
|
||||
await page.get_by_label(re.compile(r"Coun\\\"try")).click()`);
|
||||
await page.get_by_label("Coun\\"try").click()`);
|
||||
|
||||
expect.soft(sources.get('Java').text).toContain(`
|
||||
page.getByLabel(Pattern.compile("Coun\\\"try")).click()`);
|
||||
page.getByLabel("Coun\\"try").click()`);
|
||||
|
||||
expect.soft(sources.get('C#').text).toContain(`
|
||||
await page.GetByLabel(new Regex("Coun\\\"try")).ClickAsync();`);
|
||||
await page.GetByLabel("Coun\\"try").ClickAsync();`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import { contextTest as it, expect } from '../config/browserTest';
|
||||
import { asLocator } from '../../packages/playwright-core/lib/server/isomorphic/locatorGenerators';
|
||||
import type { Locator } from 'playwright-core';
|
||||
import type { Page, Frame, Locator } from 'playwright-core';
|
||||
|
||||
function generate(locator: Locator) {
|
||||
const result: any = {};
|
||||
|
@ -25,6 +25,14 @@ function generate(locator: Locator) {
|
|||
return result;
|
||||
}
|
||||
|
||||
async function generateForNode(pageOrFrame: Page | Frame, target: string): Promise<string> {
|
||||
const selector = await pageOrFrame.locator(target).evaluate(e => (window as any).playwright.selector(e));
|
||||
const result: any = {};
|
||||
for (const lang of ['javascript', 'python', 'java', 'csharp'])
|
||||
result[lang] = asLocator(lang, selector, false);
|
||||
return result;
|
||||
}
|
||||
|
||||
it('reverse engineer locators', async ({ page }) => {
|
||||
expect.soft(generate(page.getByTestId('Hello'))).toEqual({
|
||||
javascript: "getByTestId('Hello')",
|
||||
|
@ -134,5 +142,87 @@ it('reverse engineer locators', async ({ page }) => {
|
|||
javascript: 'getByTitle(/wor/i)',
|
||||
python: 'get_by_title(re.compile(r"wor", re.IGNORECASE))',
|
||||
});
|
||||
|
||||
expect.soft(generate(page.getByPlaceholder('hello my\nwo"rld'))).toEqual({
|
||||
csharp: 'GetByPlaceholder("hello my\\nwo\\"rld")',
|
||||
java: 'getByPlaceholder("hello my\\nwo\\"rld")',
|
||||
javascript: 'getByPlaceholder(\'hello my\\nwo"rld\')',
|
||||
python: 'get_by_placeholder("hello my\\nwo\\"rld")',
|
||||
});
|
||||
expect.soft(generate(page.getByAltText('hello my\nwo"rld'))).toEqual({
|
||||
csharp: 'GetByAltText("hello my\\nwo\\"rld")',
|
||||
java: 'getByAltText("hello my\\nwo\\"rld")',
|
||||
javascript: 'getByAltText(\'hello my\\nwo"rld\')',
|
||||
python: 'get_by_alt_text("hello my\\nwo\\"rld")',
|
||||
});
|
||||
expect.soft(generate(page.getByTitle('hello my\nwo"rld'))).toEqual({
|
||||
csharp: 'GetByTitle("hello my\\nwo\\"rld")',
|
||||
java: 'getByTitle("hello my\\nwo\\"rld")',
|
||||
javascript: 'getByTitle(\'hello my\\nwo"rld\')',
|
||||
python: 'get_by_title("hello my\\nwo\\"rld")',
|
||||
});
|
||||
});
|
||||
|
||||
it('reverse engineer ignore-case locators', async ({ page }) => {
|
||||
expect.soft(generate(page.getByText('hello my\nwo"rld'))).toEqual({
|
||||
csharp: 'GetByText("hello my\\nwo\\"rld")',
|
||||
java: 'getByText("hello my\\nwo\\"rld")',
|
||||
javascript: 'getByText(\'hello my\\nwo"rld\')',
|
||||
python: 'get_by_text("hello my\\nwo\\"rld")',
|
||||
});
|
||||
expect.soft(generate(page.getByText('hello my wo"rld'))).toEqual({
|
||||
csharp: 'GetByText("hello my wo\\"rld")',
|
||||
java: 'getByText("hello my wo\\"rld")',
|
||||
javascript: 'getByText(\'hello my wo"rld\')',
|
||||
python: 'get_by_text("hello my wo\\"rld")',
|
||||
});
|
||||
expect.soft(generate(page.getByLabel('hello my\nwo"rld'))).toEqual({
|
||||
csharp: 'GetByLabel("hello my\\nwo\\"rld")',
|
||||
java: 'getByLabel("hello my\\nwo\\"rld")',
|
||||
javascript: 'getByLabel(\'hello my\\nwo"rld\')',
|
||||
python: 'get_by_label("hello my\\nwo\\"rld")',
|
||||
});
|
||||
});
|
||||
|
||||
it.describe('selector generator', () => {
|
||||
it.skip(({ mode }) => mode !== 'default');
|
||||
|
||||
it.beforeEach(async ({ context }) => {
|
||||
await (context as any)._enableRecorder({ language: 'javascript' });
|
||||
});
|
||||
|
||||
it('reverse engineer internal:has locators', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>Hello world</div>
|
||||
<a>Hello <span>world</span></a>
|
||||
<a>Goodbye <span>world</span></a>
|
||||
`);
|
||||
expect.soft(await generateForNode(page, 'a:has-text("Hello")')).toEqual({
|
||||
csharp: 'Locator("a").Filter(new() { HasTextString: "Hello world" })',
|
||||
java: 'locator("a").filter(new Locator.LocatorOptions().setHasText("Hello world"))',
|
||||
javascript: `locator('a').filter({ hasText: 'Hello world' })`,
|
||||
python: 'locator("a").filter(has_text="Hello world")',
|
||||
});
|
||||
|
||||
await page.setContent(`
|
||||
<div>Hello <span>world</span></div>
|
||||
<b>Hello <span mark=1>world</span></b>
|
||||
`);
|
||||
expect.soft(await generateForNode(page, '[mark="1"]')).toEqual({
|
||||
csharp: 'Locator("b").Filter(new() { HasTextString: "Hello world" }).Locator("span")',
|
||||
java: 'locator("b").filter(new Locator.LocatorOptions().setHasText("Hello world")).locator("span")',
|
||||
javascript: `locator('b').filter({ hasText: 'Hello world' }).locator('span')`,
|
||||
python: 'locator("b").filter(has_text="Hello world").locator("span")',
|
||||
});
|
||||
|
||||
await page.setContent(`
|
||||
<div>Hello <span>world</span></div>
|
||||
<div>Goodbye <span mark=1>world</span></div>
|
||||
`);
|
||||
expect.soft(await generateForNode(page, '[mark="1"]')).toEqual({
|
||||
csharp: 'Locator("div").Filter(new() { HasTextString: "Goodbye world" }).Locator("span")',
|
||||
java: 'locator("div").filter(new Locator.LocatorOptions().setHasText("Goodbye world")).locator("span")',
|
||||
javascript: `locator('div').filter({ hasText: 'Goodbye world' }).locator('span')`,
|
||||
python: 'locator("div").filter(has_text="Goodbye world").locator("span")',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -40,7 +40,7 @@ it.describe('selector generator', () => {
|
|||
|
||||
it('should generate text and normalize whitespace', async ({ page }) => {
|
||||
await page.setContent(`<div>Text some\n\n\n more \t text </div>`);
|
||||
expect(await generate(page, 'div')).toBe('text=Text some more text');
|
||||
expect(await generate(page, 'div')).toBe('internal:text="Text some more text"i');
|
||||
});
|
||||
|
||||
it('should not escape spaces inside named attr selectors', async ({ page }) => {
|
||||
|
@ -55,22 +55,22 @@ it.describe('selector generator', () => {
|
|||
|
||||
it('should trim text', async ({ page }) => {
|
||||
await page.setContent(`<div>Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789</div>`);
|
||||
expect(await generate(page, 'div')).toBe('text=Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text012345');
|
||||
expect(await generate(page, 'div')).toBe('internal:text="Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text012345"i');
|
||||
});
|
||||
|
||||
it('should escape text with >>', async ({ page }) => {
|
||||
it('should not escape text with >>', async ({ page }) => {
|
||||
await page.setContent(`<div>text>>text</div>`);
|
||||
expect(await generate(page, 'div')).toBe('text=/text\\>\\>text/');
|
||||
expect(await generate(page, 'div')).toBe('internal:text="text>>text"i');
|
||||
});
|
||||
|
||||
it('should escape text with quote', async ({ page }) => {
|
||||
await page.setContent(`<div>text"text</div>`);
|
||||
expect(await generate(page, 'div')).toBe('text=/text"text/');
|
||||
expect(await generate(page, 'div')).toBe('internal:text="text\\\"text"i');
|
||||
});
|
||||
|
||||
it('should escape text with slash', async ({ page }) => {
|
||||
await page.setContent(`<div>/text</div>`);
|
||||
expect(await generate(page, 'div')).toBe('text=/\/text/');
|
||||
expect(await generate(page, 'div')).toBe('internal:text="\/text"i');
|
||||
});
|
||||
|
||||
it('should not use text for select', async ({ page }) => {
|
||||
|
@ -83,7 +83,7 @@ it.describe('selector generator', () => {
|
|||
|
||||
it('should use ordinal for identical nodes', async ({ page }) => {
|
||||
await page.setContent(`<div>Text</div><div>Text</div><div mark=1>Text</div><div>Text</div>`);
|
||||
expect(await generate(page, 'div[mark="1"]')).toBe(`text=Text >> nth=2`);
|
||||
expect(await generate(page, 'div[mark="1"]')).toBe(`internal:text="Text"i >> nth=2`);
|
||||
});
|
||||
|
||||
it('should prefer data-testid', async ({ page }) => {
|
||||
|
@ -129,13 +129,13 @@ it.describe('selector generator', () => {
|
|||
expect(await generate(page, 'div[mark="1"]')).toBe(`div >> nth=1`);
|
||||
});
|
||||
|
||||
it('should use has-text', async ({ page }) => {
|
||||
it('should use internal:has', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>Hello world</div>
|
||||
<a>Hello <span>world</span></a>
|
||||
<a>Goodbye <span>world</span></a>
|
||||
`);
|
||||
expect(await generate(page, 'a:has-text("Hello")')).toBe(`a:has-text("Hello world")`);
|
||||
expect(await generate(page, 'a:has-text("Hello")')).toBe(`a >> internal:has=\"internal:text=\\\"Hello world\\\"i\"`);
|
||||
});
|
||||
|
||||
it('should chain text after parent', async ({ page }) => {
|
||||
|
@ -143,7 +143,7 @@ it.describe('selector generator', () => {
|
|||
<div>Hello <span>world</span></div>
|
||||
<b>Hello <span mark=1>world</span></b>
|
||||
`);
|
||||
expect(await generate(page, '[mark="1"]')).toBe(`b:has-text(\"Hello world\") span`);
|
||||
expect(await generate(page, '[mark="1"]')).toBe(`b >> internal:has=\"internal:text=\\\"Hello world\\\"i\" >> span`);
|
||||
});
|
||||
|
||||
it('should use parent text', async ({ page }) => {
|
||||
|
@ -151,7 +151,7 @@ it.describe('selector generator', () => {
|
|||
<div>Hello <span>world</span></div>
|
||||
<div>Goodbye <span mark=1>world</span></div>
|
||||
`);
|
||||
expect(await generate(page, '[mark="1"]')).toBe(`div:has-text(\"Goodbye world\") span`);
|
||||
expect(await generate(page, '[mark="1"]')).toBe(`div >> internal:has=\"internal:text=\\\"Goodbye world\\\"i\" >> span`);
|
||||
});
|
||||
|
||||
it('should separate selectors by >>', async ({ page }) => {
|
||||
|
@ -163,7 +163,7 @@ it.describe('selector generator', () => {
|
|||
<div>Text</div>
|
||||
</div>
|
||||
`);
|
||||
expect(await generate(page, '#id > div')).toBe('#id >> text=Text');
|
||||
expect(await generate(page, '#id > div')).toBe('#id >> internal:text="Text"i');
|
||||
});
|
||||
|
||||
it('should trim long text', async ({ page }) => {
|
||||
|
@ -175,7 +175,7 @@ it.describe('selector generator', () => {
|
|||
<div>Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on</div>
|
||||
</div>
|
||||
`);
|
||||
expect(await generate(page, '#id > div')).toBe(`#id >> text=Text that goes on and on and on and on and on and on and on and on and on and on`);
|
||||
expect(await generate(page, '#id > div')).toBe(`#id >> internal:text="Text that goes on and on and on and on and on and on and on and on and on and on"i`);
|
||||
});
|
||||
|
||||
it('should use nested ordinals', async ({ page }) => {
|
||||
|
@ -248,7 +248,7 @@ it.describe('selector generator', () => {
|
|||
span.textContent = 'Target';
|
||||
shadowRoot.appendChild(span);
|
||||
});
|
||||
expect(await generate(page, 'span')).toBe('text=Target');
|
||||
expect(await generate(page, 'span')).toBe('internal:text="Target"i');
|
||||
});
|
||||
|
||||
it('should match in shadow dom', async ({ page }) => {
|
||||
|
@ -294,7 +294,7 @@ it.describe('selector generator', () => {
|
|||
});
|
||||
}),
|
||||
]);
|
||||
expect(await generate(frame, 'div')).toBe('text=Target');
|
||||
expect(await generate(frame, 'div')).toBe('internal:text="Target"i');
|
||||
});
|
||||
|
||||
it('should use the name attributes for elements that can have it', async ({ page }) => {
|
||||
|
@ -368,9 +368,9 @@ it.describe('selector generator', () => {
|
|||
|
||||
it('should generate label selector', async ({ page }) => {
|
||||
await page.setContent(`<label for=target>Country</label><input id=target>`);
|
||||
expect(await generate(page, 'input')).toBe('internal:label=Country');
|
||||
expect(await generate(page, 'input')).toBe('internal:label="Country"i');
|
||||
|
||||
await page.setContent(`<label for=target>Coun"try</label><input id=target>`);
|
||||
expect(await generate(page, 'input')).toBe('internal:label=/Coun"try/');
|
||||
expect(await generate(page, 'input')).toBe('internal:label="Coun\\\"try"i');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,8 +20,8 @@ it('should fail page.textContent in strict mode', async ({ page }) => {
|
|||
await page.setContent(`<span>span1</span><div><span>target</span></div>`);
|
||||
const error = await page.textContent('span', { strict: true }).catch(e => e);
|
||||
expect(error.message).toContain('strict mode violation');
|
||||
expect(error.message).toContain('1) <span>span1</span> aka playwright.$("text=span1")');
|
||||
expect(error.message).toContain('2) <span>target</span> aka playwright.$("text=target")');
|
||||
expect(error.message).toContain(`1) <span>span1</span> aka page.getByText('span1')`);
|
||||
expect(error.message).toContain(`2) <span>target</span> aka page.getByText('target')`);
|
||||
});
|
||||
|
||||
it('should fail page.getAttribute in strict mode', async ({ page }) => {
|
||||
|
@ -34,8 +34,8 @@ it('should fail page.fill in strict mode', async ({ page }) => {
|
|||
await page.setContent(`<input></input><div><input></input></div>`);
|
||||
const error = await page.fill('input', 'text', { strict: true }).catch(e => e);
|
||||
expect(error.message).toContain('strict mode violation');
|
||||
expect(error.message).toContain('1) <input/> aka playwright.$("input >> nth=0")');
|
||||
expect(error.message).toContain('2) <input/> aka playwright.$("div input")');
|
||||
expect(error.message).toContain(`1) <input/> aka page.locator('input').first()`);
|
||||
expect(error.message).toContain(`2) <input/> aka page.locator('div input')`);
|
||||
});
|
||||
|
||||
it('should fail page.$ in strict mode', async ({ page }) => {
|
||||
|
@ -54,8 +54,8 @@ it('should fail page.dispatchEvent in strict mode', async ({ page }) => {
|
|||
await page.setContent(`<span></span><div><span></span></div>`);
|
||||
const error = await page.dispatchEvent('span', 'click', {}, { strict: true }).catch(e => e);
|
||||
expect(error.message).toContain('strict mode violation');
|
||||
expect(error.message).toContain('1) <span></span> aka playwright.$("span >> nth=0")');
|
||||
expect(error.message).toContain('2) <span></span> aka playwright.$("div span")');
|
||||
expect(error.message).toContain(`1) <span></span> aka page.locator('span').first()`);
|
||||
expect(error.message).toContain(`2) <span></span> aka page.locator('div span')`);
|
||||
});
|
||||
|
||||
it('should properly format :nth-child() in strict mode message', async ({ page }) => {
|
||||
|
|
Загрузка…
Ссылка в новой задаче