From 1d1a7355732f10f7f29f7256f58b1d955b6666d6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 11 Oct 2022 16:06:05 -0700 Subject: [PATCH] fix(generator): generate nice locators for arbitrary selectors --- .../src/server/injected/selectorGenerator.ts | 4 +- .../server/isomorphic/locatorGenerators.ts | 87 +++++------ tests/library/inspector/cli-codegen-3.spec.ts | 4 +- tests/library/locator-generator.spec.ts | 138 ++++++++++++++++++ 4 files changed, 188 insertions(+), 45 deletions(-) create mode 100644 tests/library/locator-generator.spec.ts diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 3de7a8d95f157..fd6b63b1feb8c 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -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 }); diff --git a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts index 250f05a7d4d0a..beed3873d91be 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts @@ -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, hasText?: string, exact?: boolean }): string; + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: { attrs?: Record, 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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') ? ', re.IGNORECASE' : ''; + 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, hasText?: string, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, 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 = { java: new JavaLocatorFactory(), csharp: new CSharpLocatorFactory(), }; + +export function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp; +} diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index 37ff8ed1cb907..b90e77ba79c75 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -267,7 +267,7 @@ test.describe('cli codegen', () => { await recorder.setContentAndWait(``); 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(``); 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'), diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts new file mode 100644 index 0000000000000..676b911b3ad0f --- /dev/null +++ b/tests/library/locator-generator.spec.ts @@ -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", re.IGNORECASE))', + 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", re.IGNORECASE))', + 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", re.IGNORECASE))', + 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", re.IGNORECASE))', + javascript: 'getByTitle(/wor/i)', + python: 'get_by_title(re.compile(r"wor", re.IGNORECASE))', + }); + +});