From 304a4ee8ec81ec5b80e28ffefa74f60453a02959 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 18 Oct 2022 16:09:54 -0400 Subject: [PATCH] chore: migrate to the internal:text selector (#18135) --- .../playwright-core/src/client/locator.ts | 4 +- packages/playwright-core/src/server/dom.ts | 2 + .../src/server/injected/consoleApi.ts | 2 +- .../src/server/injected/injectedScript.ts | 43 ++++++--- .../src/server/injected/selectorGenerator.ts | 29 +++--- .../server/isomorphic/locatorGenerators.ts | 74 ++++++++++----- .../playwright-core/src/server/selectors.ts | 2 +- .../src/utils/isomorphic/stringUtils.ts | 12 +-- tests/library/inspector/cli-codegen-1.spec.ts | 6 +- tests/library/inspector/cli-codegen-3.spec.ts | 16 ++-- tests/library/locator-generator.spec.ts | 94 ++++++++++++++++++- tests/library/selector-generator.spec.ts | 34 +++---- tests/page/page-strict.spec.ts | 12 +-- 13 files changed, 233 insertions(+), 97 deletions(-) diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index b40e2b6677..a86c6157f2 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -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 { diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 8db0f76331..db33b20114 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -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')}] diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index d0d2752ab8..a173ed7086 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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) diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 4a48b26e32..f689510f3f 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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 = (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) diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index fd6b63b1fe..50e64ea3bd 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -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): 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; } diff --git a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts index 9a6583c758..5f43bbc9cf 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts @@ -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, hasText?: string, exact?: boolean }): string; + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: { attrs?: Record, 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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, '\"'); } diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index 9765f0a378..a05b88fd8f 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -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', diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index f642e5cc8c..996c64f48e 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -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 { diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index a9e32ec203..3fb613a17d 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -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(`link`); 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(`link`); 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(), diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index d9847b787f..c2ae7c467b 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -325,7 +325,7 @@ test.describe('cli codegen', () => { await recorder.setContentAndWait(``); 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(``); 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();`); }); }); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index e6ab2a2ecc..5d8e24460f 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -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 { + 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(` +
Hello world
+ Hello world + Goodbye world + `); + 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(` +
Hello world
+ Hello world + `); + 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(` +
Hello world
+
Goodbye world
+ `); + 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")', + }); + }); }); diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index c1226669ff..e243d793ca 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -40,7 +40,7 @@ it.describe('selector generator', () => { it('should generate text and normalize whitespace', async ({ page }) => { await page.setContent(`
Text some\n\n\n more \t text
`); - 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(`
Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789
`); - 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(`
text>>text
`); - 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(`
text"text
`); - 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(`
/text
`); - 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(`
Text
Text
Text
Text
`); - 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(`
Hello world
Hello world Goodbye world `); - 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', () => {
Hello world
Hello world `); - 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', () => {
Hello world
Goodbye world
`); - 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', () => {
Text
`); - 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', () => {
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
`); - 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(``); - expect(await generate(page, 'input')).toBe('internal:label=Country'); + expect(await generate(page, 'input')).toBe('internal:label="Country"i'); await page.setContent(``); - expect(await generate(page, 'input')).toBe('internal:label=/Coun"try/'); + expect(await generate(page, 'input')).toBe('internal:label="Coun\\\"try"i'); }); }); diff --git a/tests/page/page-strict.spec.ts b/tests/page/page-strict.spec.ts index 99c3b08674..19cca03f50 100644 --- a/tests/page/page-strict.spec.ts +++ b/tests/page/page-strict.spec.ts @@ -20,8 +20,8 @@ it('should fail page.textContent in strict mode', async ({ page }) => { await page.setContent(`span1
target
`); const error = await page.textContent('span', { strict: true }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain('1) span1 aka playwright.$("text=span1")'); - expect(error.message).toContain('2) target aka playwright.$("text=target")'); + expect(error.message).toContain(`1) span1 aka page.getByText('span1')`); + expect(error.message).toContain(`2) target 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(`
`); const error = await page.fill('input', 'text', { strict: true }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain('1) aka playwright.$("input >> nth=0")'); - expect(error.message).toContain('2) aka playwright.$("div input")'); + expect(error.message).toContain(`1) aka page.locator('input').first()`); + expect(error.message).toContain(`2) 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(`
`); const error = await page.dispatchEvent('span', 'click', {}, { strict: true }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain('1) aka playwright.$("span >> nth=0")'); - expect(error.message).toContain('2) aka playwright.$("div span")'); + expect(error.message).toContain(`1) aka page.locator('span').first()`); + expect(error.message).toContain(`2) aka page.locator('div span')`); }); it('should properly format :nth-child() in strict mode message', async ({ page }) => {