chore: migrate to the internal:text selector (#18135)
This commit is contained in:
parent
098de5009e
commit
304a4ee8ec
|
|
@ -51,7 +51,7 @@ export class Locator implements api.Locator {
|
||||||
this._selector = selector;
|
this._selector = selector;
|
||||||
|
|
||||||
if (options?.hasText) {
|
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)}`;
|
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 {
|
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 {
|
export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string {
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,14 @@ export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
const custom: string[] = [];
|
const custom: string[] = [];
|
||||||
for (const [name, { source }] of this.frame._page.selectors._engines)
|
for (const [name, { source }] of this.frame._page.selectors._engines)
|
||||||
custom.push(`{ name: '${name}', engine: (${source}) }`);
|
custom.push(`{ name: '${name}', engine: (${source}) }`);
|
||||||
|
const sdkLanguage = this.frame._page.context()._browser.options.sdkLanguage;
|
||||||
const source = `
|
const source = `
|
||||||
(() => {
|
(() => {
|
||||||
const module = {};
|
const module = {};
|
||||||
${injectedScriptSource.source}
|
${injectedScriptSource.source}
|
||||||
return new module.exports(
|
return new module.exports(
|
||||||
${isUnderTest()},
|
${isUnderTest()},
|
||||||
|
"${sdkLanguage}",
|
||||||
${this.frame._page._delegate.rafCountForStablePosition()},
|
${this.frame._page._delegate.rafCountForStablePosition()},
|
||||||
"${this.frame._page._browserContext._browser.options.name}",
|
"${this.frame._page._browserContext._browser.options.name}",
|
||||||
[${custom.join(',\n')}]
|
[${custom.join(',\n')}]
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function createLocator(injectedScript: InjectedScript, initial: string, options?
|
||||||
constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
|
constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
|
||||||
this.selector = selector;
|
this.selector = selector;
|
||||||
if (options?.hasText) {
|
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)}`;
|
this.selector += ` >> internal:has=${JSON.stringify(textSelector)}`;
|
||||||
}
|
}
|
||||||
if (options?.has)
|
if (options?.has)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import type * as channels from '@protocol/channels';
|
||||||
import { Highlight } from './highlight';
|
import { Highlight } from './highlight';
|
||||||
import { getAriaDisabled, getAriaRole, getElementAccessibleName } from './roleUtils';
|
import { getAriaDisabled, getAriaRole, getElementAccessibleName } from './roleUtils';
|
||||||
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
|
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;
|
type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;
|
||||||
|
|
||||||
|
|
@ -79,9 +81,11 @@ export class InjectedScript {
|
||||||
private _hitTargetInterceptor: undefined | ((event: MouseEvent | PointerEvent | TouchEvent) => void);
|
private _hitTargetInterceptor: undefined | ((event: MouseEvent | PointerEvent | TouchEvent) => void);
|
||||||
private _highlight: Highlight | undefined;
|
private _highlight: Highlight | undefined;
|
||||||
readonly isUnderTest: boolean;
|
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.isUnderTest = isUnderTest;
|
||||||
|
this._sdkLanguage = sdkLanguage;
|
||||||
this._evaluator = new SelectorEvaluatorImpl(new Map());
|
this._evaluator = new SelectorEvaluatorImpl(new Map());
|
||||||
|
|
||||||
this._engines = new Map();
|
this._engines = new Map();
|
||||||
|
|
@ -90,8 +94,8 @@ export class InjectedScript {
|
||||||
this._engines.set('_react', ReactEngine);
|
this._engines.set('_react', ReactEngine);
|
||||||
this._engines.set('_vue', VueEngine);
|
this._engines.set('_vue', VueEngine);
|
||||||
this._engines.set('role', RoleEngine);
|
this._engines.set('role', RoleEngine);
|
||||||
this._engines.set('text', this._createTextEngine(true));
|
this._engines.set('text', this._createTextEngine(true, false));
|
||||||
this._engines.set('text:light', this._createTextEngine(false));
|
this._engines.set('text:light', this._createTextEngine(false, false));
|
||||||
this._engines.set('id', this._createAttributeEngine('id', true));
|
this._engines.set('id', this._createAttributeEngine('id', true));
|
||||||
this._engines.set('id:light', this._createAttributeEngine('id', false));
|
this._engines.set('id:light', this._createAttributeEngine('id', false));
|
||||||
this._engines.set('data-testid', this._createAttributeEngine('data-testid', true));
|
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('visible', this._createVisibleEngine());
|
||||||
this._engines.set('internal:control', this._createControlEngine());
|
this._engines.set('internal:control', this._createControlEngine());
|
||||||
this._engines.set('internal:has', this._createHasEngine());
|
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());
|
this._engines.set('internal:attr', this._createNamedAttributeEngine());
|
||||||
|
|
||||||
for (const { name, engine } of customEngines)
|
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 queryList = (root: SelectorRoot, selector: string): Element[] => {
|
||||||
const { matcher, kind } = createTextMatcher(selector, false);
|
const { matcher, kind } = createTextMatcher(selector, false, internal);
|
||||||
const result: Element[] = [];
|
const result: Element[] = [];
|
||||||
let lastDidNotMatchSelf: Element | null = null;
|
let lastDidNotMatchSelf: Element | null = null;
|
||||||
|
|
||||||
|
|
@ -274,11 +279,11 @@ export class InjectedScript {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createLabelEngine(): SelectorEngine {
|
private _createInternalLabelEngine(): SelectorEngine {
|
||||||
const evaluator = this._evaluator;
|
const evaluator = this._evaluator;
|
||||||
return {
|
return {
|
||||||
queryAll: (root: SelectorRoot, selector: string): Element[] => {
|
queryAll: (root: SelectorRoot, selector: string): Element[] => {
|
||||||
const { matcher } = createTextMatcher(selector, true);
|
const { matcher } = createTextMatcher(selector, true, true);
|
||||||
const result: Element[] = [];
|
const result: Element[] = [];
|
||||||
const labels = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, 'label') as HTMLLabelElement[];
|
const labels = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, 'label') as HTMLLabelElement[];
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
|
|
@ -993,7 +998,7 @@ export class InjectedScript {
|
||||||
preview: this.previewNode(m),
|
preview: this.previewNode(m),
|
||||||
selector: this.generateSelector(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)
|
if (infos.length < matches.length)
|
||||||
lines.push('\n ...');
|
lines.push('\n ...');
|
||||||
return this.createStacklessError(`strict mode violation: "${stringifySelector(selector)}" resolved to ${matches.length} elements:${lines.join('')}\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 kMouseHitTargetInterceptorEvents = new Set(['mousedown', 'mouseup', 'pointerdown', 'pointerup', 'click', 'auxclick', 'dblclick', 'contextmenu']);
|
||||||
const kAllHitTargetInterceptorEvents = new Set([...kHoverHitTargetInterceptorEvents, ...kTapHitTargetInterceptorEvents, ...kMouseHitTargetInterceptorEvents]);
|
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('\\'))
|
if (!s.includes('\\'))
|
||||||
return s;
|
return s;
|
||||||
const r: string[] = [];
|
const r: string[] = [];
|
||||||
|
|
@ -1294,19 +1301,25 @@ function unescape(s: string): string {
|
||||||
return r.join('');
|
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) {
|
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
|
||||||
const lastSlash = selector.lastIndexOf('/');
|
const lastSlash = selector.lastIndexOf('/');
|
||||||
const matcher: TextMatcher = createRegexTextMatcher(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
|
const matcher: TextMatcher = createRegexTextMatcher(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
|
||||||
return { matcher, kind: 'regex' };
|
return { matcher, kind: 'regex' };
|
||||||
}
|
}
|
||||||
|
const unquote = internal ? JSON.parse.bind(JSON) : cssUnquote;
|
||||||
let strict = false;
|
let strict = false;
|
||||||
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
|
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
|
||||||
selector = unescape(selector.substring(1, selector.length - 1));
|
selector = unquote(selector);
|
||||||
strict = true;
|
strict = true;
|
||||||
}
|
} else if (internal && selector.length > 1 && selector[0] === '"' && selector[selector.length - 2] === '"' && selector[selector.length - 1] === 'i') {
|
||||||
if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
|
selector = unquote(selector.substring(0, selector.length - 1));
|
||||||
selector = unescape(selector.substring(1, 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;
|
strict = true;
|
||||||
}
|
}
|
||||||
if (strict)
|
if (strict)
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
|
||||||
const calculate = (element: Element, allowText: boolean): SelectorToken[] | null => {
|
const calculate = (element: Element, allowText: boolean): SelectorToken[] | null => {
|
||||||
const allowNthMatch = element === targetElement;
|
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) {
|
if (element !== targetElement) {
|
||||||
// Do not use regex for parent elements (for performance).
|
// Do not use regex for parent elements (for performance).
|
||||||
textCandidates = filterRegexTokens(textCandidates);
|
textCandidates = filterRegexTokens(textCandidates);
|
||||||
|
|
@ -162,7 +162,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, acces
|
||||||
const label = input.labels?.[0];
|
const label = input.labels?.[0];
|
||||||
if (label) {
|
if (label) {
|
||||||
const labelText = elementText(injectedScript._evaluator._cacheText, label).full.trim();
|
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;
|
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')
|
if (element.nodeName === 'SELECT')
|
||||||
return [];
|
return [];
|
||||||
const text = elementText(injectedScript._evaluator._cacheText, element).full.trim().replace(/\s+/g, ' ').substring(0, 80);
|
const text = elementText(injectedScript._evaluator._cacheText, element).full.trim().replace(/\s+/g, ' ').substring(0, 80);
|
||||||
if (!text)
|
if (!text)
|
||||||
return [];
|
return [];
|
||||||
const candidates: SelectorToken[] = [];
|
const candidates: SelectorToken[][] = [];
|
||||||
|
|
||||||
const escaped = escapeForTextSelector(text, false, true);
|
const escaped = escapeForTextSelector(text, false);
|
||||||
|
|
||||||
if (isTargetNode)
|
if (isTargetNode)
|
||||||
candidates.push({ engine: 'text', selector: escaped, score: 10 });
|
candidates.push([{ engine: 'internal:text', selector: escaped, score: 10 }]);
|
||||||
|
|
||||||
if (escaped === text) {
|
const ariaRole = getAriaRole(element);
|
||||||
let prefix = element.nodeName.toLowerCase();
|
const candidate: SelectorToken[] = [];
|
||||||
if (element.hasAttribute('role'))
|
if (ariaRole) {
|
||||||
prefix += `[role=${quoteAttributeValue(element.getAttribute('role')!)}]`;
|
const ariaName = getElementAccessibleName(element, false, accessibleNameCache);
|
||||||
candidates.push({ engine: 'css', selector: `${prefix}:has-text("${text}")`, score: 10 });
|
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;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { escapeWithQuotes, toSnakeCase, toTitleCase } from '../../utils/isomorphic/stringUtils';
|
import { escapeWithQuotes, toSnakeCase, toTitleCase } from '../../utils/isomorphic/stringUtils';
|
||||||
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
|
|
||||||
import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
||||||
|
import type { NestedSelectorBody } from '../isomorphic/selectorParser';
|
||||||
import type { ParsedSelector } from '../isomorphic/selectorParser';
|
import type { ParsedSelector } from '../isomorphic/selectorParser';
|
||||||
|
|
||||||
export type Language = 'javascript' | 'python' | 'java' | 'csharp';
|
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 type LocatorBase = 'page' | 'locator' | 'frame-locator';
|
||||||
|
|
||||||
export interface LocatorFactory {
|
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 {
|
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));
|
tokens.push(factory.generateLocator(base, 'nth', part.body as string));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (part.name === 'text') {
|
if (part.name === 'internal:text') {
|
||||||
const { exact, text } = detectExact(part.body as string);
|
const { exact, text } = detectExact(part.body as string);
|
||||||
tokens.push(factory.generateLocator(base, 'text', text, { exact }));
|
tokens.push(factory.generateLocator(base, 'text', text, { exact }));
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -63,11 +63,11 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
|
||||||
tokens.push(factory.generateLocator(base, 'role', attrSelector.name, { attrs }));
|
tokens.push(factory.generateLocator(base, 'role', attrSelector.name, { attrs }));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (part.name === 'css') {
|
if (part.name === 'internal:has') {
|
||||||
const parsed = part.body as CSSComplexSelectorList;
|
const nested = (part.body as NestedSelectorBody).parsed;
|
||||||
if (parsed[0].simples.length === 1 && parsed[0].simples[0].selector.functions.length === 1 && parsed[0].simples[0].selector.functions[0].name === 'hasText') {
|
if (nested?.parts?.[0]?.name === 'internal:text') {
|
||||||
const hasText = parsed[0].simples[0].selector.functions[0].args[0] as string;
|
const result = detectExact(nested.parts[0].body as string);
|
||||||
tokens.push(factory.generateLocator(base, 'has-text', parsed[0].simples[0].selector.css!, { hasText }));
|
tokens.push(factory.generateLocator(base, 'has-text', result.text, { exact: result.exact }));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,10 +94,6 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
|
||||||
tokens.push(factory.generateLocator(base, 'title', text, { exact }));
|
tokens.push(factory.generateLocator(base, 'title', text, { exact }));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (name === 'label') {
|
|
||||||
tokens.push(factory.generateLocator(base, 'label', text, { exact }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const p: ParsedSelector = { parts: [part] };
|
const p: ParsedSelector = { parts: [part] };
|
||||||
tokens.push(factory.generateLocator(base, 'default', stringifySelector(p)));
|
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]*)$/);
|
const match = text.match(/^\/(.*)\/([igm]*)$/);
|
||||||
if (match)
|
if (match)
|
||||||
return { text: new RegExp(match[1], match[2]) };
|
return { text: new RegExp(match[1], match[2]) };
|
||||||
if (text.startsWith('"') && text.endsWith('"')) {
|
if (text.endsWith('"')) {
|
||||||
text = JSON.parse(text);
|
text = JSON.parse(text);
|
||||||
exact = true;
|
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 };
|
return { exact, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class JavaScriptLocatorFactory implements LocatorFactory {
|
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) {
|
switch (kind) {
|
||||||
case 'default':
|
case 'default':
|
||||||
return `locator(${this.quote(body as string)})`;
|
return `locator(${this.quote(body as string)})`;
|
||||||
|
|
@ -135,7 +137,7 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
|
||||||
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
|
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
|
||||||
return `getByRole(${this.quote(body as string)}${attrString})`;
|
return `getByRole(${this.quote(body as string)}${attrString})`;
|
||||||
case 'has-text':
|
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':
|
case 'test-id':
|
||||||
return `getByTestId(${this.quote(body as string)})`;
|
return `getByTestId(${this.quote(body as string)})`;
|
||||||
case 'text':
|
case 'text':
|
||||||
|
|
@ -159,13 +161,19 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
|
||||||
return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`;
|
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) {
|
private quote(text: string) {
|
||||||
return escapeWithQuotes(text, '\'');
|
return escapeWithQuotes(text, '\'');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PythonLocatorFactory implements LocatorFactory {
|
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) {
|
switch (kind) {
|
||||||
case 'default':
|
case 'default':
|
||||||
return `locator(${this.quote(body as string)})`;
|
return `locator(${this.quote(body as string)})`;
|
||||||
|
|
@ -182,7 +190,7 @@ export class PythonLocatorFactory implements LocatorFactory {
|
||||||
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
|
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
|
||||||
return `get_by_role(${this.quote(body as string)}${attrString})`;
|
return `get_by_role(${this.quote(body as string)}${attrString})`;
|
||||||
case 'has-text':
|
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':
|
case 'test-id':
|
||||||
return `get_by_test_id(${this.quote(body as string)})`;
|
return `get_by_test_id(${this.quote(body as string)})`;
|
||||||
case 'text':
|
case 'text':
|
||||||
|
|
@ -210,13 +218,21 @@ export class PythonLocatorFactory implements LocatorFactory {
|
||||||
return `${method}(${this.quote(body)})`;
|
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) {
|
private quote(text: string) {
|
||||||
return escapeWithQuotes(text, '\"');
|
return escapeWithQuotes(text, '\"');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class JavaLocatorFactory implements LocatorFactory {
|
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;
|
let clazz: string;
|
||||||
switch (base) {
|
switch (base) {
|
||||||
case 'page': clazz = 'Page'; break;
|
case 'page': clazz = 'Page'; break;
|
||||||
|
|
@ -239,7 +255,7 @@ export class JavaLocatorFactory implements LocatorFactory {
|
||||||
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
|
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
|
||||||
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
|
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
|
||||||
case 'has-text':
|
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':
|
case 'test-id':
|
||||||
return `getByTestId(${this.quote(body as string)})`;
|
return `getByTestId(${this.quote(body as string)})`;
|
||||||
case 'text':
|
case 'text':
|
||||||
|
|
@ -267,13 +283,21 @@ export class JavaLocatorFactory implements LocatorFactory {
|
||||||
return `${method}(${this.quote(body)})`;
|
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) {
|
private quote(text: string) {
|
||||||
return escapeWithQuotes(text, '\"');
|
return escapeWithQuotes(text, '\"');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CSharpLocatorFactory implements LocatorFactory {
|
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) {
|
switch (kind) {
|
||||||
case 'default':
|
case 'default':
|
||||||
return `Locator(${this.quote(body as string)})`;
|
return `Locator(${this.quote(body as string)})`;
|
||||||
|
|
@ -292,7 +316,7 @@ export class CSharpLocatorFactory implements LocatorFactory {
|
||||||
const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : '';
|
const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : '';
|
||||||
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
|
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
|
||||||
case 'has-text':
|
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':
|
case 'test-id':
|
||||||
return `GetByTestId(${this.quote(body as string)})`;
|
return `GetByTestId(${this.quote(body as string)})`;
|
||||||
case 'text':
|
case 'text':
|
||||||
|
|
@ -320,6 +344,14 @@ export class CSharpLocatorFactory implements LocatorFactory {
|
||||||
return `${method}(${this.quote(body)})`;
|
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) {
|
private quote(text: string) {
|
||||||
return escapeWithQuotes(text, '\"');
|
return escapeWithQuotes(text, '\"');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export class Selectors {
|
||||||
'data-test-id', 'data-test-id:light',
|
'data-test-id', 'data-test-id:light',
|
||||||
'data-test', 'data-test:light',
|
'data-test', 'data-test:light',
|
||||||
'nth', 'visible', 'internal:control', 'internal:has',
|
'nth', 'visible', 'internal:control', 'internal:has',
|
||||||
'role', 'internal:attr', 'internal:label'
|
'role', 'internal:attr', 'internal:label', 'internal:text'
|
||||||
]);
|
]);
|
||||||
this._builtinEnginesInMainWorld = new Set([
|
this._builtinEnginesInMainWorld = new Set([
|
||||||
'_react', '_vue',
|
'_react', '_vue',
|
||||||
|
|
|
||||||
|
|
@ -58,18 +58,10 @@ function cssEscapeOne(s: string, i: number): string {
|
||||||
return '\\' + s.charAt(i);
|
return '\\' + s.charAt(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeForRegex(text: string): string {
|
export function escapeForTextSelector(text: string | RegExp, exact: boolean): string {
|
||||||
return text.replace(/[.*+?^>${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeForTextSelector(text: string | RegExp, exact: boolean, caseSensitive = false): string {
|
|
||||||
if (typeof text !== 'string')
|
if (typeof text !== 'string')
|
||||||
return String(text);
|
return String(text);
|
||||||
if (exact)
|
return `${JSON.stringify(text)}${exact ? '' : 'i'}`;
|
||||||
return '"' + text.replace(/["]/g, '\\"') + '"';
|
|
||||||
if (text.includes('"') || text.includes('>>') || text[0] === '/')
|
|
||||||
return `/${escapeForRegex(text).replace(/\s+/g, '\\s+')}/` + (caseSensitive ? '' : 'i');
|
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeForAttributeSelector(value: string, exact: boolean): string {
|
export function escapeForAttributeSelector(value: string, exact: boolean): string {
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ test.describe('cli codegen', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('div');
|
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.
|
// Sanity check that selector does not match our highlight.
|
||||||
const divContents = await page.$eval(selector, div => div.outerHTML);
|
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>`);
|
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('a');
|
const selector = await recorder.hoverOverElement('a');
|
||||||
expect(selector).toBe('text=link');
|
expect(selector).toBe('internal:text="link"i');
|
||||||
const [, sources] = await Promise.all([
|
const [, sources] = await Promise.all([
|
||||||
page.waitForNavigation(),
|
page.waitForNavigation(),
|
||||||
recorder.waitForOutput('JavaScript', 'waitForURL'),
|
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>`);
|
await recorder.setContentAndWait(`<a onclick="setTimeout(() => window.location.href='about:blank#foo', 1000)">link</a>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('a');
|
const selector = await recorder.hoverOverElement('a');
|
||||||
expect(selector).toBe('text=link');
|
expect(selector).toBe('internal:text="link"i');
|
||||||
|
|
||||||
const [, sources] = await Promise.all([
|
const [, sources] = await Promise.all([
|
||||||
page.waitForNavigation(),
|
page.waitForNavigation(),
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,7 @@ test.describe('cli codegen', () => {
|
||||||
await recorder.setContentAndWait(`<label for=target>Country</label><input id=target>`);
|
await recorder.setContentAndWait(`<label for=target>Country</label><input id=target>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('input');
|
const selector = await recorder.hoverOverElement('input');
|
||||||
expect(selector).toBe('internal:label=Country');
|
expect(selector).toBe('internal:label="Country"i');
|
||||||
|
|
||||||
const [sources] = await Promise.all([
|
const [sources] = await Promise.all([
|
||||||
recorder.waitForOutput('JavaScript', 'click'),
|
recorder.waitForOutput('JavaScript', 'click'),
|
||||||
|
|
@ -348,13 +348,13 @@ test.describe('cli codegen', () => {
|
||||||
await page.GetByLabel("Country").ClickAsync();`);
|
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();
|
const recorder = await openRecorder();
|
||||||
|
|
||||||
await recorder.setContentAndWait(`<label for=target>Coun"try</label><input id=target>`);
|
await recorder.setContentAndWait(`<label for=target>Coun"try</label><input id=target>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('input');
|
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([
|
const [sources] = await Promise.all([
|
||||||
recorder.waitForOutput('JavaScript', 'click'),
|
recorder.waitForOutput('JavaScript', 'click'),
|
||||||
|
|
@ -362,18 +362,18 @@ test.describe('cli codegen', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect.soft(sources.get('JavaScript').text).toContain(`
|
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(`
|
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(`
|
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(`
|
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(`
|
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 { contextTest as it, expect } from '../config/browserTest';
|
||||||
import { asLocator } from '../../packages/playwright-core/lib/server/isomorphic/locatorGenerators';
|
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) {
|
function generate(locator: Locator) {
|
||||||
const result: any = {};
|
const result: any = {};
|
||||||
|
|
@ -25,6 +25,14 @@ function generate(locator: Locator) {
|
||||||
return result;
|
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 }) => {
|
it('reverse engineer locators', async ({ page }) => {
|
||||||
expect.soft(generate(page.getByTestId('Hello'))).toEqual({
|
expect.soft(generate(page.getByTestId('Hello'))).toEqual({
|
||||||
javascript: "getByTestId('Hello')",
|
javascript: "getByTestId('Hello')",
|
||||||
|
|
@ -134,5 +142,87 @@ it('reverse engineer locators', async ({ page }) => {
|
||||||
javascript: 'getByTitle(/wor/i)',
|
javascript: 'getByTitle(/wor/i)',
|
||||||
python: 'get_by_title(re.compile(r"wor", re.IGNORECASE))',
|
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 }) => {
|
it('should generate text and normalize whitespace', async ({ page }) => {
|
||||||
await page.setContent(`<div>Text some\n\n\n more \t text </div>`);
|
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 }) => {
|
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 }) => {
|
it('should trim text', async ({ page }) => {
|
||||||
await page.setContent(`<div>Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789</div>`);
|
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>`);
|
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 }) => {
|
it('should escape text with quote', async ({ page }) => {
|
||||||
await page.setContent(`<div>text"text</div>`);
|
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 }) => {
|
it('should escape text with slash', async ({ page }) => {
|
||||||
await page.setContent(`<div>/text</div>`);
|
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 }) => {
|
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 }) => {
|
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>`);
|
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 }) => {
|
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`);
|
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(`
|
await page.setContent(`
|
||||||
<div>Hello world</div>
|
<div>Hello world</div>
|
||||||
<a>Hello <span>world</span></a>
|
<a>Hello <span>world</span></a>
|
||||||
<a>Goodbye <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 }) => {
|
it('should chain text after parent', async ({ page }) => {
|
||||||
|
|
@ -143,7 +143,7 @@ it.describe('selector generator', () => {
|
||||||
<div>Hello <span>world</span></div>
|
<div>Hello <span>world</span></div>
|
||||||
<b>Hello <span mark=1>world</span></b>
|
<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 }) => {
|
it('should use parent text', async ({ page }) => {
|
||||||
|
|
@ -151,7 +151,7 @@ it.describe('selector generator', () => {
|
||||||
<div>Hello <span>world</span></div>
|
<div>Hello <span>world</span></div>
|
||||||
<div>Goodbye <span mark=1>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 }) => {
|
it('should separate selectors by >>', async ({ page }) => {
|
||||||
|
|
@ -163,7 +163,7 @@ it.describe('selector generator', () => {
|
||||||
<div>Text</div>
|
<div>Text</div>
|
||||||
</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 }) => {
|
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>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>
|
</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 }) => {
|
it('should use nested ordinals', async ({ page }) => {
|
||||||
|
|
@ -248,7 +248,7 @@ it.describe('selector generator', () => {
|
||||||
span.textContent = 'Target';
|
span.textContent = 'Target';
|
||||||
shadowRoot.appendChild(span);
|
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 }) => {
|
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 }) => {
|
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 }) => {
|
it('should generate label selector', async ({ page }) => {
|
||||||
await page.setContent(`<label for=target>Country</label><input id=target>`);
|
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>`);
|
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>`);
|
await page.setContent(`<span>span1</span><div><span>target</span></div>`);
|
||||||
const error = await page.textContent('span', { strict: true }).catch(e => e);
|
const error = await page.textContent('span', { strict: true }).catch(e => e);
|
||||||
expect(error.message).toContain('strict mode violation');
|
expect(error.message).toContain('strict mode violation');
|
||||||
expect(error.message).toContain('1) <span>span1</span> aka playwright.$("text=span1")');
|
expect(error.message).toContain(`1) <span>span1</span> aka page.getByText('span1')`);
|
||||||
expect(error.message).toContain('2) <span>target</span> aka playwright.$("text=target")');
|
expect(error.message).toContain(`2) <span>target</span> aka page.getByText('target')`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail page.getAttribute in strict mode', async ({ page }) => {
|
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>`);
|
await page.setContent(`<input></input><div><input></input></div>`);
|
||||||
const error = await page.fill('input', 'text', { strict: true }).catch(e => e);
|
const error = await page.fill('input', 'text', { strict: true }).catch(e => e);
|
||||||
expect(error.message).toContain('strict mode violation');
|
expect(error.message).toContain('strict mode violation');
|
||||||
expect(error.message).toContain('1) <input/> aka playwright.$("input >> nth=0")');
|
expect(error.message).toContain(`1) <input/> aka page.locator('input').first()`);
|
||||||
expect(error.message).toContain('2) <input/> aka playwright.$("div input")');
|
expect(error.message).toContain(`2) <input/> aka page.locator('div input')`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail page.$ in strict mode', async ({ page }) => {
|
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>`);
|
await page.setContent(`<span></span><div><span></span></div>`);
|
||||||
const error = await page.dispatchEvent('span', 'click', {}, { strict: true }).catch(e => e);
|
const error = await page.dispatchEvent('span', 'click', {}, { strict: true }).catch(e => e);
|
||||||
expect(error.message).toContain('strict mode violation');
|
expect(error.message).toContain('strict mode violation');
|
||||||
expect(error.message).toContain('1) <span></span> aka playwright.$("span >> nth=0")');
|
expect(error.message).toContain(`1) <span></span> aka page.locator('span').first()`);
|
||||||
expect(error.message).toContain('2) <span></span> aka playwright.$("div span")');
|
expect(error.message).toContain(`2) <span></span> aka page.locator('div span')`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should properly format :nth-child() in strict mode message', async ({ page }) => {
|
it('should properly format :nth-child() in strict mode message', async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue