diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 7f2aa33830..383a199818 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -25,6 +25,7 @@ import { Recorder } from './recorder'; import { EmptyRecorderApp } from './recorder/recorderApp'; import { asLocator } from './isomorphic/locatorGenerators'; import type { Language } from './isomorphic/locatorGenerators'; +import { locatorOrSelectorAsSelector } from './isomorphic/locatorParser'; const internalMetadata = serverSideCallMetadata(); @@ -138,6 +139,7 @@ export class DebugController extends SdkObject { } async highlight(selector: string) { + selector = locatorOrSelectorAsSelector(selector); for (const recorder of await this._allRecorders()) recorder.setHighlightedSelector(selector); } diff --git a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts index 75d3eb3092..1fb7f9c8a2 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts @@ -206,7 +206,7 @@ export class PythonLocatorFactory implements LocatorFactory { private toCallWithExact(method: string, body: string | RegExp, exact: boolean) { if (isRegExp(body)) { const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : ''; - return `${method}(re.compile(r${this.quote(body.source)}${suffix}))`; + return `${method}(re.compile(r"${body.source.replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix}))`; } if (exact) return `${method}(${this.quote(body)}, exact=true)`; @@ -216,7 +216,7 @@ export class PythonLocatorFactory implements LocatorFactory { 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 `re.compile(r"${body.source.replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`; } return `${this.quote(body)}`; } @@ -274,7 +274,7 @@ export class JavaLocatorFactory implements LocatorFactory { return `${method}(Pattern.compile(${this.quote(body.source)}${suffix}))`; } if (exact) - return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(exact))`; + return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`; return `${method}(${this.quote(body)})`; } diff --git a/packages/playwright-core/src/server/isomorphic/locatorParser.ts b/packages/playwright-core/src/server/isomorphic/locatorParser.ts new file mode 100644 index 0000000000..98995d0491 --- /dev/null +++ b/packages/playwright-core/src/server/isomorphic/locatorParser.ts @@ -0,0 +1,135 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { escapeForAttributeSelector, escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; +import { parseSelector } from './selectorParser'; + +export function parseLocator(locator: string): string { + locator = locator + .replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase()) + .replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`); + const params: { quote: string, text: string }[] = []; + let template = ''; + for (let i = 0; i < locator.length; ++i) { + const quote = locator[i]; + if (quote !== '"' && quote !== '\'' && quote !== '`' && quote !== '/') { + template += quote; + continue; + } + const isRegexEscaping = locator[i - 1] === 'r' || locator[i] === '/'; + ++i; + let text = ''; + while (i < locator.length) { + if (locator[i] === '\\') { + if (isRegexEscaping) { + if (locator[i + 1] !== quote) + text += locator[i]; + ++i; + text += locator[i]; + } else { + ++i; + if (locator[i] === 'n') + text += '\n'; + else if (locator[i] === 'r') + text += '\r'; + else if (locator[i] === 't') + text += '\t'; + else + text += locator[i]; + } + ++i; + continue; + } + if (locator[i] !== quote) { + text += locator[i++]; + continue; + } + break; + } + params.push({ quote, text }); + template += (quote === '/' ? 'r' : '') + '$' + params.length; + } + + // Equalize languages. + template = template.toLowerCase() + .replace(/get_by_alt_text/g, 'getbyalttext') + .replace(/get_by_test_id/g, 'getbytestid') + .replace(/get_by_([\w]+)/g, 'getby$1') + .replace(/has_text/g, 'hastext') + .replace(/[{}\s]/g, '') + .replace(/new\(\)/g, '') + .replace(/new[\w]+\.[\w]+options\(\)/g, '') + .replace(/\.set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) + .replace(/:/g, '=') + .replace(/,re\.ignorecase/g, 'i') + .replace(/,pattern.case_insensitive/g, 'i') + .replace(/,regexoptions.ignorecase/g, 'i') + .replace(/re.compile\(([^)]+)\)/g, '$1') // Python has regex strings as r"foo" + .replace(/pattern.compile\(([^)]+)\)/g, 'r$1') + .replace(/newregex\(([^)]+)\)/g, 'r$1') + .replace(/string=/g, '=') + .replace(/regex=/g, '=') + .replace(/,,/g, ','); + + // Transform. + template = template + .replace(/locator\(([^)]+)\)/g, '$1') + .replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1') + .replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1') + .replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1') + .replace(/getbytestid\(([^)]+)\)/g, 'internal:attr=[data-testid=$1s]') + .replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 'internal:attr=[$1=$2]') + .replace(/first(\(\))?/g, 'nth=0') + .replace(/last(\(\))?/g, 'nth=-1') + .replace(/nth\(([^)]+)\)/g, 'nth=$1') + .replace(/filter\(.*hastext=([^)]+)\)/g, 'internal:has-text=$1') + .replace(/,exact=false/g, '') + .replace(/,exact=true/g, 's') + .replace(/\,/g, ']['); + + return template.split('.').map(t => { + if (!t.startsWith('internal:')) + return t.replace(/\$(\d+)/g, (_, ordinal) => { const param = params[+ordinal - 1]; return param.text; }); + t = t.includes('[') ? t.replace(/\]/, '') + ']' : t; + t = t + .replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => { + const param = params[+ordinal - 1]; + if (t.startsWith('internal:attr') || t.startsWith('internal:role')) + return new RegExp(param.text) + (suffix || ''); + return escapeForTextSelector(new RegExp(param.text, suffix), false); + }) + .replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => { + const param = params[+ordinal - 1]; + if (t.startsWith('internal:attr') || t.startsWith('internal:role')) + return escapeForAttributeSelector(param.text, suffix === 's'); + return escapeForTextSelector(param.text, suffix === 's'); + }); + return t; + }).join(' >> '); +} + +export function locatorOrSelectorAsSelector(locator: string): string { + try { + parseSelector(locator); + return locator; + } catch (e) { + } + try { + return parseLocator(locator); + } catch (e) { + } + return locator; +} diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 0c67bc2f88..5865132069 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -41,6 +41,7 @@ import { Debugger } from './debugger'; import { EventEmitter } from 'events'; import { raceAgainstTimeout } from '../utils/timeoutRunner'; import type { Language, LanguageGenerator } from './recorder/language'; +import { locatorOrSelectorAsSelector } from './isomorphic/locatorParser'; type BindingSource = { frame: Frame, page: Page }; @@ -210,7 +211,7 @@ export class Recorder implements InstrumentationListener { } setHighlightedSelector(selector: string) { - this._highlightedSelector = selector; + this._highlightedSelector = locatorOrSelectorAsSelector(selector); this._refreshOverlay(); } diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index 996c64f48e..1504fa29e2 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -61,7 +61,7 @@ function cssEscapeOne(s: string, i: number): string { export function escapeForTextSelector(text: string | RegExp, exact: boolean): string { if (typeof text !== 'string') return String(text); - return `${JSON.stringify(text)}${exact ? '' : 'i'}`; + return `${JSON.stringify(text)}${exact ? 's' : 'i'}`; } export function escapeForAttributeSelector(value: string, exact: boolean): string { @@ -69,5 +69,5 @@ export function escapeForAttributeSelector(value: string, exact: boolean): strin // cssEscape(value).replace(/\\ /g, ' ') // However, our attribute selectors do not conform to CSS parsing spec, // so we escape them differently. - return `"${value.replace(/["]/g, '\\"')}"${exact ? '' : 'i'}`; + return `"${value.replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`; } diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 941bde7829..a9078d3199 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -146,7 +146,7 @@ export const Recorder: React.FC = ({ window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' } }).catch(() => { }); }}>Explore { - setLocator(asLocator(source.language, event.target.value)); + setLocator(event.target.value); window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } }); }} /> { diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 71a72f16e4..5243039e0e 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -70,10 +70,10 @@ test('should pick element', async ({ backend, connectedBrowser }) => { expect(events).toEqual([ { - selector: 'internal:role=button[name=\"Submit\"]', + selector: 'internal:role=button[name=\"Submit\"s]', locator: 'getByRole(\'button\', { name: \'Submit\' })', }, { - selector: 'internal:role=button[name=\"Submit\"]', + selector: 'internal:role=button[name=\"Submit\"s]', locator: 'getByRole(\'button\', { name: \'Submit\' })', }, ]); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 65e701b395..454bed9417 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -16,20 +16,31 @@ import { contextTest as it, expect } from '../config/browserTest'; import { asLocator } from '../../packages/playwright-core/lib/server/isomorphic/locatorGenerators'; +import { parseLocator } from '../../packages/playwright-core/lib/server/isomorphic/locatorParser'; import type { Page, Frame, Locator } from 'playwright-core'; function generate(locator: Locator) { + return generateForSelector((locator as any)._selector); +} + +function generateForSelector(selector: string) { const result: any = {}; - for (const lang of ['javascript', 'python', 'java', 'csharp']) - result[lang] = asLocator(lang, (locator as any)._selector, false); + for (const lang of ['javascript', 'python', 'java', 'csharp']) { + const locatorString = asLocator(lang, selector, false); + expect.soft(parseLocator(locatorString), lang + ' mismatch').toBe(selector); + result[lang] = locatorString; + } 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); + for (const lang of ['javascript', 'python', 'java', 'csharp']) { + const locatorString = asLocator(lang, selector, false); + expect.soft(parseLocator(locatorString)).toBe(selector); + result[lang] = locatorString; + } return result; } @@ -43,14 +54,14 @@ it('reverse engineer locators', async ({ page }) => { expect.soft(generate(page.getByTestId('He"llo'))).toEqual({ javascript: 'getByTestId(\'He"llo\')', - python: 'get_by_test_id("He\\\"llo")', - java: 'getByTestId("He\\\"llo")', - csharp: 'GetByTestId("He\\\"llo")' + python: 'get_by_test_id("He\\"llo")', + java: 'getByTestId("He\\"llo")', + csharp: 'GetByTestId("He\\"llo")' }); expect.soft(generate(page.getByText('Hello', { exact: true }))).toEqual({ csharp: 'GetByText("Hello", new() { Exact: true })', - java: 'getByText("Hello", new Page.GetByTextOptions().setExact(exact))', + java: 'getByText("Hello", new Page.GetByTextOptions().setExact(true))', javascript: 'getByText(\'Hello\', { exact: true })', python: 'get_by_text("Hello", exact=true)', }); @@ -75,7 +86,7 @@ it('reverse engineer locators', async ({ page }) => { }); expect.soft(generate(page.getByLabel('Last Name', { exact: true }))).toEqual({ csharp: 'GetByLabel("Last Name", new() { Exact: true })', - java: 'getByLabel("Last Name", new Page.GetByLabelOptions().setExact(exact))', + java: 'getByLabel("Last Name", new Page.GetByLabelOptions().setExact(true))', javascript: 'getByLabel(\'Last Name\', { exact: true })', python: 'get_by_label("Last Name", exact=true)', }); @@ -83,7 +94,7 @@ it('reverse engineer locators', async ({ page }) => { csharp: 'GetByLabel(new Regex("Last\\\\s+name", RegexOptions.IgnoreCase))', java: 'getByLabel(Pattern.compile("Last\\\\s+name", Pattern.CASE_INSENSITIVE))', javascript: 'getByLabel(/Last\\s+name/i)', - python: 'get_by_label(re.compile(r"Last\\\\s+name", re.IGNORECASE))', + python: 'get_by_label(re.compile(r"Last\\s+name", re.IGNORECASE))', }); expect.soft(generate(page.getByPlaceholder('hello'))).toEqual({ @@ -94,7 +105,7 @@ it('reverse engineer locators', async ({ page }) => { }); expect.soft(generate(page.getByPlaceholder('Hello', { exact: true }))).toEqual({ csharp: 'GetByPlaceholder("Hello", new() { Exact: true })', - java: 'getByPlaceholder("Hello", new Page.GetByPlaceholderOptions().setExact(exact))', + java: 'getByPlaceholder("Hello", new Page.GetByPlaceholderOptions().setExact(true))', javascript: 'getByPlaceholder(\'Hello\', { exact: true })', python: 'get_by_placeholder("Hello", exact=true)', }); @@ -113,7 +124,7 @@ it('reverse engineer locators', async ({ page }) => { }); expect.soft(generate(page.getByAltText('Hello', { exact: true }))).toEqual({ csharp: 'GetByAltText("Hello", new() { Exact: true })', - java: 'getByAltText("Hello", new Page.GetByAltTextOptions().setExact(exact))', + java: 'getByAltText("Hello", new Page.GetByAltTextOptions().setExact(true))', javascript: 'getByAltText(\'Hello\', { exact: true })', python: 'get_by_alt_text("Hello", exact=true)', }); @@ -132,7 +143,7 @@ it('reverse engineer locators', async ({ page }) => { }); expect.soft(generate(page.getByTitle('Hello', { exact: true }))).toEqual({ csharp: 'GetByTitle("Hello", new() { Exact: true })', - java: 'getByTitle("Hello", new Page.GetByTitleOptions().setExact(exact))', + java: 'getByTitle("Hello", new Page.GetByTitleOptions().setExact(true))', javascript: 'getByTitle(\'Hello\', { exact: true })', python: 'get_by_title("Hello", exact=true)', }); @@ -183,7 +194,69 @@ it('reverse engineer ignore-case locators', async ({ page }) => { }); }); -it.describe('selector generator', () => { +it('reverse engineer ordered locators', async ({ page }) => { + expect.soft(generate(page.locator('div').nth(3).first().last())).toEqual({ + csharp: `Locator(\"div\").Nth(3).First.Last`, + java: `locator(\"div\").nth(3).first().last()`, + javascript: `locator('div').nth(3).first().last()`, + python: `locator(\"div\").nth(3).first.last`, + }); +}); + +it('reverse engineer locators with regex', async ({ page }) => { + expect.soft(generate(page.getByText(/he\/\sl\nlo/))).toEqual({ + csharp: `GetByText(new Regex(\"he\\\\/\\\\sl\\\\nlo\"))`, + java: `getByText(Pattern.compile(\"he\\\\/\\\\sl\\\\nlo\"))`, + javascript: `getByText(/he\\/\\sl\\nlo/)`, + python: `get_by_text(re.compile(r"he/\\sl\\nlo"))`, + }); + + expect.soft(generate(page.getByPlaceholder(/he\/\sl\nlo/))).toEqual({ + csharp: `GetByPlaceholder(new Regex(\"he\\\\/\\\\sl\\\\nlo\"))`, + java: `getByPlaceholder(Pattern.compile(\"he\\\\/\\\\sl\\\\nlo\"))`, + javascript: `getByPlaceholder(/he\\/\\sl\\nlo/)`, + python: `get_by_placeholder(re.compile(r"he/\\sl\\nlo"))`, + }); + + expect.soft(generate(page.getByText(/hel"lo/))).toEqual({ + csharp: `GetByText(new Regex("hel\\"lo"))`, + java: `getByText(Pattern.compile("hel\\"lo"))`, + javascript: `getByText(/hel\"lo/)`, + python: `get_by_text(re.compile(r"hel\\"lo"))`, + }); + + expect.soft(generate(page.getByPlaceholder(/hel"lo/))).toEqual({ + csharp: `GetByPlaceholder(new Regex("hel\\"lo"))`, + java: `getByPlaceholder(Pattern.compile("hel\\"lo"))`, + javascript: `getByPlaceholder(/hel"lo/)`, + python: `get_by_placeholder(re.compile(r"hel\\"lo"))`, + }); +}); + +it('reverse engineer hasText', async ({ page }) => { + expect.soft(generate(page.getByText('Hello').filter({ hasText: 'wo"rld\n' }))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { HasTextString: "wo\\"rld\\n" })`, + java: `getByText("Hello").filter(new Locator.LocatorOptions().setHasText("wo\\"rld\\n"))`, + javascript: `getByText('Hello').filter({ hasText: 'wo"rld\\n' })`, + python: `get_by_text("Hello").filter(has_text="wo\\"rld\\n")`, + }); + + expect.soft(generate(page.getByText('Hello').filter({ hasText: /wo\/\srld\n/ }))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { HasTextString: new Regex("wo\\\\/\\\\srld\\\\n") })`, + java: `getByText("Hello").filter(new Locator.LocatorOptions().setHasText(Pattern.compile("wo\\\\/\\\\srld\\\\n")))`, + javascript: `getByText('Hello').filter({ hasText: /wo\\/\\srld\\n/ })`, + python: `get_by_text("Hello").filter(has_text=re.compile(r"wo/\\srld\\n"))`, + }); + + expect.soft(generate(page.getByText('Hello').filter({ hasText: /wor"ld/ }))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { HasTextString: new Regex("wor\\"ld") })`, + java: `getByText("Hello").filter(new Locator.LocatorOptions().setHasText(Pattern.compile("wor\\"ld")))`, + javascript: `getByText('Hello').filter({ hasText: /wor"ld/ })`, + python: `get_by_text("Hello").filter(has_text=re.compile(r"wor\\"ld"))`, + }); +}); + +it.describe(() => { it.skip(({ mode }) => mode !== 'default'); it.beforeEach(async ({ context }) => { diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index efc2bceec0..e3eb310da2 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -50,7 +50,7 @@ it.describe('selector generator', () => { it('should generate text for ', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'input')).toBe('internal:role=button[name=\"Click me\"]'); + expect(await generate(page, 'input')).toBe('internal:role=button[name=\"Click me\"s]'); }); it('should trim text', async ({ page }) => { @@ -88,7 +88,7 @@ it.describe('selector generator', () => { it('should prefer data-testid', async ({ page }) => { await page.setContent(`
Text
Text
Text
Text
`); - expect(await generate(page, '[data-testid="a"]')).toBe('internal:attr=[data-testid=\"a\"]'); + expect(await generate(page, '[data-testid="a"]')).toBe('internal:attr=[data-testid=\"a\"s]'); }); it('should handle first non-unique data-testid', async ({ page }) => { @@ -99,7 +99,7 @@ it.describe('selector generator', () => {
Text
`); - expect(await generate(page, 'div[mark="1"]')).toBe('internal:attr=[data-testid=\"a\"] >> nth=0'); + expect(await generate(page, 'div[mark="1"]')).toBe('internal:attr=[data-testid=\"a\"s] >> nth=0'); }); it('should handle second non-unique data-testid', async ({ page }) => { @@ -110,7 +110,7 @@ it.describe('selector generator', () => {
Text
`); - expect(await generate(page, 'div[mark="1"]')).toBe(`internal:attr=[data-testid=\"a\"] >> nth=1`); + expect(await generate(page, 'div[mark="1"]')).toBe(`internal:attr=[data-testid=\"a\"s] >> nth=1`); }); it('should use readable id', async ({ page }) => { @@ -319,7 +319,7 @@ it.describe('selector generator', () => { await page.setContent(``); await page.$eval('button', button => button.setAttribute('aria-label', `!#'!?:`)); - expect(await generate(page, 'button')).toBe(`internal:role=button[name="!#'!?:"]`); + expect(await generate(page, 'button')).toBe(`internal:role=button[name="!#'!?:"s]`); expect(await page.$(`role=button[name="!#'!?:"]`)).toBeTruthy(); await page.setContent(`
`); @@ -343,7 +343,7 @@ it.describe('selector generator', () => { it('should accept valid aria-label for candidate consideration', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'button')).toBe('internal:role=button[name=\"ariaLabel\"]'); + expect(await generate(page, 'button')).toBe('internal:role=button[name=\"ariaLabel\"s]'); }); it('should ignore empty role for candidate consideration', async ({ page }) => {