cherry-pick(#18010): fix(generator): generate nice locators for arbitrary selectors

This commit is contained in:
Pavel Feldman 2022-10-11 16:50:41 -08:00
parent 3367ebd968
commit 04b3b7190c
5 changed files with 190 additions and 47 deletions

View file

@ -158,7 +158,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, acces
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
const input = element as HTMLInputElement | HTMLTextAreaElement;
if (input.placeholder)
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: 3 });
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, false)}]`, score: 3 });
const label = input.labels?.[0];
if (label) {
const labelText = elementText(injectedScript._evaluator._cacheText, label).full.trim();
@ -176,7 +176,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, acces
}
if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName))
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, true)}]`, score: 10 });
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: 10 });
if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'FRAME', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName))
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteAttributeValue(element.getAttribute('name')!)}]`, score: 50 });

View file

@ -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, 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>, hasText?: string, exact?: boolean }): string;
}
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
@ -74,13 +74,14 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
if (part.name === 'internal:attr') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { name, value } = attrSelector.attributes[0];
const { name, value, caseSensitive } = attrSelector.attributes[0];
if (name === 'data-testid') {
tokens.push(factory.generateLocator(base, 'test-id', value));
continue;
}
const { exact, text } = detectExact(value);
const text = value as string | RegExp;
const exact = !!caseSensitive;
if (name === 'placeholder') {
tokens.push(factory.generateLocator(base, 'placeholder', text, { exact }));
continue;
@ -104,8 +105,11 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
return tokens.join('.');
}
function detectExact(text: string): { exact: boolean, text: string } {
function detectExact(text: string): { exact?: boolean, text: string | RegExp } {
let exact = false;
const match = text.match(/^\/(.*)\/([igm]*)$/);
if (match)
return { text: new RegExp(match[1], match[2]) };
if (text.startsWith('"') && text.endsWith('"')) {
text = JSON.parse(text);
exact = true;
@ -114,10 +118,10 @@ function detectExact(text: string): { exact: boolean, text: string } {
}
export class JavaScriptLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, 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>, hasText?: string, exact?: boolean } = {}): string {
switch (kind) {
case 'default':
return `locator(${this.quote(body)})`;
return `locator(${this.quote(body as string)})`;
case 'nth':
return `nth(${body})`;
case 'first':
@ -129,11 +133,11 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`${name}: ${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
return `getByRole(${this.quote(body)}${attrString})`;
return `getByRole(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `locator(${this.quote(body)}, { hasText: ${this.quote(options.hasText!)} })`;
return `locator(${this.quote(body as string)}, { hasText: ${this.quote(options.hasText!)} })`;
case 'test-id':
return `getByTestId(${this.quote(body)})`;
return `getByTestId(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact('getByText', body, !!options.exact);
case 'alt':
@ -149,8 +153,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
}
}
private toCallWithExact(method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i')))
private toCallWithExact(method: string, body: string | RegExp, exact?: boolean) {
if (isRegExp(body))
return `${method}(${body})`;
return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`;
}
@ -161,10 +165,10 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
}
export class PythonLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, 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>, hasText?: string, exact?: boolean } = {}): string {
switch (kind) {
case 'default':
return `locator(${this.quote(body)})`;
return `locator(${this.quote(body as string)})`;
case 'nth':
return `nth(${body})`;
case 'first':
@ -176,11 +180,11 @@ export class PythonLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`${toSnakeCase(name)}=${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
return `get_by_role(${this.quote(body)}${attrString})`;
return `get_by_role(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `locator(${this.quote(body)}, has_text=${this.quote(options.hasText!)})`;
return `locator(${this.quote(body as string)}, has_text=${this.quote(options.hasText!)})`;
case 'test-id':
return `get_by_test_id(${this.quote(body)})`;
return `get_by_test_id(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact('get_by_text', body, !!options.exact);
case 'alt':
@ -196,11 +200,10 @@ export class PythonLocatorFactory implements LocatorFactory {
}
}
private toCallWithExact(method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
const regex = body.substring(1, body.lastIndexOf('/'));
const suffix = body.endsWith('i') ? ', re.IGNORECASE' : '';
return `${method}(re.compile(r${this.quote(regex)}${suffix}))`;
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}))`;
}
if (exact)
return `${method}(${this.quote(body)}, exact=true)`;
@ -213,7 +216,7 @@ export class PythonLocatorFactory implements LocatorFactory {
}
export class JavaLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, 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>, hasText?: string, exact?: boolean } = {}): string {
let clazz: string;
switch (base) {
case 'page': clazz = 'Page'; break;
@ -222,7 +225,7 @@ export class JavaLocatorFactory implements LocatorFactory {
}
switch (kind) {
case 'default':
return `locator(${this.quote(body)})`;
return `locator(${this.quote(body as string)})`;
case 'nth':
return `nth(${body})`;
case 'first':
@ -234,11 +237,11 @@ export class JavaLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})`);
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
return `getByRole(AriaRole.${toSnakeCase(body).toUpperCase()}${attrString})`;
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
case 'has-text':
return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasText(${this.quote(options.hasText!)}))`;
return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.quote(options.hasText!)}))`;
case 'test-id':
return `getByTestId(${this.quote(body)})`;
return `getByTestId(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact(clazz, 'getByText', body, !!options.exact);
case 'alt':
@ -254,11 +257,10 @@ export class JavaLocatorFactory implements LocatorFactory {
}
}
private toCallWithExact(clazz: string, method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
const regex = body.substring(1, body.lastIndexOf('/'));
const suffix = body.endsWith('i') ? ', Pattern.CASE_INSENSITIVE' : '';
return `${method}(Pattern.compile(${this.quote(regex)}${suffix}))`;
private toCallWithExact(clazz: string, method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body)) {
const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : '';
return `${method}(Pattern.compile(${this.quote(body.source)}${suffix}))`;
}
if (exact)
return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(exact))`;
@ -271,10 +273,10 @@ export class JavaLocatorFactory implements LocatorFactory {
}
export class CSharpLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, 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>, hasText?: string, exact?: boolean } = {}): string {
switch (kind) {
case 'default':
return `Locator(${this.quote(body)})`;
return `Locator(${this.quote(body as string)})`;
case 'nth':
return `Nth(${body})`;
case 'first':
@ -286,11 +288,11 @@ export class CSharpLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, new () { ${attrs.join(', ')} }` : '';
return `GetByRole(AriaRole.${toTitleCase(body)}${attrString})`;
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
case 'has-text':
return `Locator(${this.quote(body)}, new () { HasTextString: ${this.quote(options.hasText!)} })`;
return `Locator(${this.quote(body as string)}, new () { HasTextString: ${this.quote(options.hasText!)} })`;
case 'test-id':
return `GetByTestId(${this.quote(body)})`;
return `GetByTestId(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact('GetByText', body, !!options.exact);
case 'alt':
@ -306,11 +308,10 @@ export class CSharpLocatorFactory implements LocatorFactory {
}
}
private toCallWithExact(method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
const regex = body.substring(1, body.lastIndexOf('/'));
const suffix = body.endsWith('i') ? ', RegexOptions.IgnoreCase' : '';
return `${method}(new Regex(${this.quote(regex)}${suffix}))`;
private toCallWithExact(method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body)) {
const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : '';
return `${method}(new Regex(${this.quote(body.source)}${suffix}))`;
}
if (exact)
return `${method}(${this.quote(body)}, new () { Exact: true })`;
@ -328,3 +329,7 @@ const generators: Record<Language, LocatorFactory> = {
java: new JavaLocatorFactory(),
csharp: new CSharpLocatorFactory(),
};
export function isRegExp(obj: any): obj is RegExp {
return obj instanceof RegExp;
}

View file

@ -267,7 +267,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input placeholder="Country"></input>`);
const selector = await recorder.hoverOverElement('input');
expect(selector).toBe('internal:attr=[placeholder="Country"]');
expect(selector).toBe('internal:attr=[placeholder="Country"i]');
const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'click'),
@ -296,7 +296,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input alt="Country"></input>`);
const selector = await recorder.hoverOverElement('input');
expect(selector).toBe('internal:attr=[alt="Country"]');
expect(selector).toBe('internal:attr=[alt="Country"i]');
const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'click'),

View file

@ -0,0 +1,138 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*
* 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 { contextTest as it, expect } from '../config/browserTest';
import { asLocator } from '../../packages/playwright-core/lib/server/isomorphic/locatorGenerators';
import type { Locator } from 'playwright-core';
function generate(locator: Locator) {
const result: any = {};
for (const lang of ['javascript', 'python', 'java', 'csharp'])
result[lang] = asLocator(lang, (locator as any)._selector, false);
return result;
}
it('reverse engineer locators', async ({ page }) => {
expect.soft(generate(page.getByTestId('Hello'))).toEqual({
javascript: "getByTestId('Hello')",
python: 'get_by_test_id("Hello")',
java: 'getByTestId("Hello")',
csharp: 'GetByTestId("Hello")'
});
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")'
});
expect.soft(generate(page.getByText('Hello', { exact: true }))).toEqual({
csharp: 'GetByText("Hello", new () { Exact: true })',
java: 'getByText("Hello", new Page.GetByTextOptions().setExact(exact))',
javascript: 'getByText(\'Hello\', { exact: true })',
python: 'get_by_text("Hello", exact=true)',
});
expect.soft(generate(page.getByText('Hello'))).toEqual({
csharp: 'GetByText("Hello")',
java: 'getByText("Hello")',
javascript: 'getByText(\'Hello\')',
python: 'get_by_text("Hello")',
});
expect.soft(generate(page.getByText(/Hello/))).toEqual({
csharp: 'GetByText(new Regex("Hello"))',
java: 'getByText(Pattern.compile("Hello"))',
javascript: 'getByText(/Hello/)',
python: 'get_by_text(re.compile(r"Hello"))',
});
expect.soft(generate(page.getByLabel('Name'))).toEqual({
csharp: 'GetByLabel("Name")',
java: 'getByLabel("Name")',
javascript: 'getByLabel(\'Name\')',
python: 'get_by_label("Name")',
});
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))',
javascript: 'getByLabel(\'Last Name\', { exact: true })',
python: 'get_by_label("Last Name", exact=true)',
});
expect.soft(generate(page.getByLabel(/Last\s+name/i))).toEqual({
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))',
});
expect.soft(generate(page.getByPlaceholder('hello'))).toEqual({
csharp: 'GetByPlaceholder("hello")',
java: 'getByPlaceholder("hello")',
javascript: 'getByPlaceholder(\'hello\')',
python: 'get_by_placeholder("hello")',
});
expect.soft(generate(page.getByPlaceholder('Hello', { exact: true }))).toEqual({
csharp: 'GetByPlaceholder("Hello", new () { Exact: true })',
java: 'getByPlaceholder("Hello", new Page.GetByPlaceholderOptions().setExact(exact))',
javascript: 'getByPlaceholder(\'Hello\', { exact: true })',
python: 'get_by_placeholder("Hello", exact=true)',
});
expect.soft(generate(page.getByPlaceholder(/wor/i))).toEqual({
csharp: 'GetByPlaceholder(new Regex("wor", RegexOptions.IgnoreCase))',
java: 'getByPlaceholder(Pattern.compile("wor", Pattern.CASE_INSENSITIVE))',
javascript: 'getByPlaceholder(/wor/i)',
python: 'get_by_placeholder(re.compile(r"wor", re.IGNORECASE))',
});
expect.soft(generate(page.getByAltText('hello'))).toEqual({
csharp: 'GetByAltText("hello")',
java: 'getByAltText("hello")',
javascript: 'getByAltText(\'hello\')',
python: 'get_by_alt_text("hello")',
});
expect.soft(generate(page.getByAltText('Hello', { exact: true }))).toEqual({
csharp: 'GetByAltText("Hello", new () { Exact: true })',
java: 'getByAltText("Hello", new Page.GetByAltTextOptions().setExact(exact))',
javascript: 'getByAltText(\'Hello\', { exact: true })',
python: 'get_by_alt_text("Hello", exact=true)',
});
expect.soft(generate(page.getByAltText(/wor/i))).toEqual({
csharp: 'GetByAltText(new Regex("wor", RegexOptions.IgnoreCase))',
java: 'getByAltText(Pattern.compile("wor", Pattern.CASE_INSENSITIVE))',
javascript: 'getByAltText(/wor/i)',
python: 'get_by_alt_text(re.compile(r"wor", re.IGNORECASE))',
});
expect.soft(generate(page.getByTitle('hello'))).toEqual({
csharp: 'GetByTitle("hello")',
java: 'getByTitle("hello")',
javascript: 'getByTitle(\'hello\')',
python: 'get_by_title("hello")',
});
expect.soft(generate(page.getByTitle('Hello', { exact: true }))).toEqual({
csharp: 'GetByTitle("Hello", new () { Exact: true })',
java: 'getByTitle("Hello", new Page.GetByTitleOptions().setExact(exact))',
javascript: 'getByTitle(\'Hello\', { exact: true })',
python: 'get_by_title("Hello", exact=true)',
});
expect.soft(generate(page.getByTitle(/wor/i))).toEqual({
csharp: 'GetByTitle(new Regex("wor", RegexOptions.IgnoreCase))',
java: 'getByTitle(Pattern.compile("wor", Pattern.CASE_INSENSITIVE))',
javascript: 'getByTitle(/wor/i)',
python: 'get_by_title(re.compile(r"wor", re.IGNORECASE))',
});
});

View file

@ -45,7 +45,7 @@ it.describe('selector generator', () => {
it('should not escape spaces inside named attr selectors', async ({ page }) => {
await page.setContent(`<input placeholder="Foo b ar"/>`);
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"Foo b ar\"]');
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"Foo b ar\"i]');
});
it('should generate text for <input type=button>', async ({ page }) => {
@ -232,7 +232,7 @@ it.describe('selector generator', () => {
});
it('placeholder', async ({ page }) => {
await page.setContent(`<input placeholder="foobar" type="text"/>`);
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"]');
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"i]');
});
it('type', async ({ page }) => {
await page.setContent(`<input type="text"/>`);