chore: add explore locator parser (#18429)

This commit is contained in:
Pavel Feldman 2022-10-31 12:55:35 -07:00 committed by GitHub
parent 2d07c10888
commit 2c3fa1b1ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 240 additions and 29 deletions

View File

@ -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);
}

View File

@ -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)})`;
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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'}`;
}

View File

@ -146,7 +146,7 @@ export const Recorder: React.FC<RecorderProps> = ({
window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' } }).catch(() => { });
}}>Explore</ToolbarButton>
<input ref={selectorInputRef} className='selector-input' placeholder='Playwright Selector' spellCheck='false' value={locator} disabled={mode !== 'none'} onChange={event => {
setLocator(asLocator(source.language, event.target.value));
setLocator(event.target.value);
window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } });
}} />
<ToolbarButton icon='files' title='Copy' onClick={() => {

View File

@ -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\' })',
},
]);

View File

@ -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<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);
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 }) => {

View File

@ -50,7 +50,7 @@ it.describe('selector generator', () => {
it('should generate text for <input type=button>', async ({ page }) => {
await page.setContent(`<input type=button value="Click me">`);
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(`<div>Text</div><div>Text</div><div data-testid=a>Text</div><div>Text</div>`);
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', () => {
<div data-testid=a>
Text
</div>`);
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', () => {
<div data-testid=a mark=1>
Text
</div>`);
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(`<button><span></span></button><button></button>`);
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(`<div><span></span></div>`);
@ -343,7 +343,7 @@ it.describe('selector generator', () => {
it('should accept valid aria-label for candidate consideration', async ({ page }) => {
await page.setContent(`<button aria-label="ariaLabel" id="buttonId"></button>`);
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 }) => {