From 3ecaa36e25ffacbc548e6adf921e66779674b6d6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 5 Oct 2022 12:13:22 -0800 Subject: [PATCH] chore: make locators generator isomorphic (#17850) --- .../src/server/isomorphic/DEPS.list | 1 + .../server/isomorphic/locatorGenerators.ts | 330 ++++++++++++++++++ .../src/server/recorder/csharp.ts | 54 +-- .../src/server/recorder/java.ts | 60 +--- .../src/server/recorder/javascript.ts | 47 +-- .../src/server/recorder/language.ts | 86 ----- .../src/server/recorder/python.ts | 59 +--- .../src/utils/isomorphic/stringUtils.ts | 5 + 8 files changed, 351 insertions(+), 291 deletions(-) create mode 100644 packages/playwright-core/src/server/isomorphic/locatorGenerators.ts diff --git a/packages/playwright-core/src/server/isomorphic/DEPS.list b/packages/playwright-core/src/server/isomorphic/DEPS.list index c962277084..813c7f12f0 100644 --- a/packages/playwright-core/src/server/isomorphic/DEPS.list +++ b/packages/playwright-core/src/server/isomorphic/DEPS.list @@ -1,2 +1,3 @@ # Files in this folder are used both in Node.js and injected environments, they are isomorphic and can't have dependencies. [*] +../../utils/isomorphic \ No newline at end of file diff --git a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts new file mode 100644 index 0000000000..d040c545ac --- /dev/null +++ b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts @@ -0,0 +1,330 @@ +/** + * 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 { escapeWithQuotes, toSnakeCase, toTitleCase } from '../../utils/isomorphic/stringUtils'; +import type { CSSComplexSelectorList } from '../isomorphic/cssParser'; +import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; +import type { ParsedSelector } from '../isomorphic/selectorParser'; + +type Language = 'javascript' | 'python' | 'java' | 'csharp'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text'; +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; +} + +export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string { + return innerAsLocator(generators[lang], selector, isFrameLocator); +} + +function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocator: boolean = false): string { + const parsed = parseSelector(selector); + const tokens: string[] = []; + for (const part of parsed.parts) { + const base = part === parsed.parts[0] ? (isFrameLocator ? 'frame-locator' : 'page') : 'locator'; + if (part.name === 'nth') { + if (part.body === '0') + tokens.push(factory.generateLocator(base, 'first', '')); + else if (part.body === '-1') + tokens.push(factory.generateLocator(base, 'last', '')); + else + tokens.push(factory.generateLocator(base, 'nth', part.body as string)); + continue; + } + if (part.name === 'text') { + const { exact, text } = detectExact(part.body as string); + tokens.push(factory.generateLocator(base, 'text', text, { exact })); + continue; + } + if (part.name === 'internal:label') { + const { exact, text } = detectExact(part.body as string); + tokens.push(factory.generateLocator(base, 'label', text, { exact })); + continue; + } + if (part.name === 'role') { + const attrSelector = parseAttributeSelector(part.body as string, true); + const attrs: Record = {}; + for (const attr of attrSelector.attributes!) + attrs[attr.name === 'include-hidden' ? 'includeHidden' : attr.name] = attr.value; + 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 })); + continue; + } + } + + if (part.name === 'internal:attr') { + const attrSelector = parseAttributeSelector(part.body as string, true); + const { name, value } = attrSelector.attributes[0]; + if (name === 'data-testid') { + tokens.push(factory.generateLocator(base, 'test-id', value)); + continue; + } + + const { exact, text } = detectExact(value); + if (name === 'placeholder') { + tokens.push(factory.generateLocator(base, 'placeholder', text, { exact })); + continue; + } + if (name === 'alt') { + tokens.push(factory.generateLocator(base, 'alt', text, { exact })); + continue; + } + if (name === 'title') { + 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))); + } + return tokens.join('.'); +} + +function detectExact(text: string): { exact: boolean, text: string } { + let exact = false; + if (text.startsWith('"') && text.endsWith('"')) { + text = JSON.parse(text); + exact = true; + } + return { exact, text }; +} + +export class JavaScriptLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { + switch (kind) { + case 'default': + return `locator(${this.quote(body)})`; + case 'nth': + return `nth(${body})`; + case 'first': + return `first()`; + case 'last': + return `last()`; + case 'role': + const attrs: string[] = []; + 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})`; + case 'has-text': + return `locator(${this.quote(body)}, { hasText: ${this.quote(options.hasText!)} })`; + case 'test-id': + return `getByTestId(${this.quote(body)})`; + case 'text': + return this.toCallWithExact('getByText', body, !!options.exact); + case 'alt': + return this.toCallWithExact('getByAltText', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact('getByPlaceholder', body, !!options.exact); + case 'label': + return this.toCallWithExact('getByLabel', body, !!options.exact); + case 'title': + return this.toCallWithExact('getByTitle', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + private toCallWithExact(method: string, body: string, exact: boolean) { + if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) + return `${method}(${body})`; + return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`; + } + + private quote(text: string) { + return escapeWithQuotes(text, '\''); + } +} + +export class PythonLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { + switch (kind) { + case 'default': + return `locator(${this.quote(body)})`; + case 'nth': + return `nth(${body})`; + case 'first': + return `first`; + case 'last': + return `last`; + case 'role': + const attrs: string[] = []; + 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})`; + case 'has-text': + return `locator(${this.quote(body)}, has_text=${this.quote(options.hasText!)})`; + case 'test-id': + return `get_by_test_id(${this.quote(body)})`; + case 'text': + return this.toCallWithExact('get_by_text', body, !!options.exact); + case 'alt': + return this.toCallWithExact('get_by_alt_text', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact('get_by_placeholder', body, !!options.exact); + case 'label': + return this.toCallWithExact('get_by_label', body, !!options.exact); + case 'title': + return this.toCallWithExact('get_by_title', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + 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}))`; + } + if (exact) + return `${method}(${this.quote(body)}, exact=true)`; + return `${method}(${this.quote(body)})`; + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class JavaLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { + let clazz: string; + switch (base) { + case 'page': clazz = 'Page'; break; + case 'frame-locator': clazz = 'FrameLocator'; break; + case 'locator': clazz = 'Locator'; break; + } + switch (kind) { + case 'default': + return `locator(${this.quote(body)})`; + case 'nth': + return `nth(${body})`; + case 'first': + return `first()`; + case 'last': + return `last()`; + case 'role': + const attrs: string[] = []; + 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(${this.quote(body)}${attrString})`; + case 'has-text': + return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasText(${this.quote(options.hasText!)}))`; + case 'test-id': + return `getByTestId(${this.quote(body)})`; + case 'text': + return this.toCallWithExact(clazz, 'getByText', body, !!options.exact); + case 'alt': + return this.toCallWithExact(clazz, 'getByAltText', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact(clazz, 'getByPlaceholder', body, !!options.exact); + case 'label': + return this.toCallWithExact(clazz, 'getByLabel', body, !!options.exact); + case 'title': + return this.toCallWithExact(clazz, 'getByTitle', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + 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}))`; + } + if (exact) + return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(exact))`; + return `${method}(${this.quote(body)})`; + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class CSharpLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { + switch (kind) { + case 'default': + return `Locator(${this.quote(body)})`; + case 'nth': + return `Nth(${body})`; + case 'first': + return `First`; + case 'last': + return `Last`; + case 'role': + const attrs: string[] = []; + 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(${this.quote(body)}${attrString})`; + case 'has-text': + return `Locator(${this.quote(body)}, new () { HasTextString: ${this.quote(options.hasText!)} })`; + case 'test-id': + return `GetByTestId(${this.quote(body)})`; + case 'text': + return this.toCallWithExact('GetByText', body, !!options.exact); + case 'alt': + return this.toCallWithExact('GetByAltText', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact('GetByPlaceholder', body, !!options.exact); + case 'label': + return this.toCallWithExact('GetByLabel', body, !!options.exact); + case 'title': + return this.toCallWithExact('GetByTitle', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + 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}))`; + } + if (exact) + return `${method}(${this.quote(body)}, new () { Exact: true })`; + return `${method}(${this.quote(body)})`; + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +const generators: Record = { + javascript: new JavaScriptLocatorFactory(), + python: new PythonLocatorFactory(), + java: new JavaLocatorFactory(), + csharp: new CSharpLocatorFactory(), +}; diff --git a/packages/playwright-core/src/server/recorder/csharp.ts b/packages/playwright-core/src/server/recorder/csharp.ts index c292a68f92..37d36e8e2c 100644 --- a/packages/playwright-core/src/server/recorder/csharp.ts +++ b/packages/playwright-core/src/server/recorder/csharp.ts @@ -15,15 +15,15 @@ */ import type { BrowserContextOptions } from '../../..'; -import { asLocator } from './language'; -import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language'; +import type { LanguageGenerator, LanguageGeneratorOptions } from './language'; import { sanitizeDeviceOptions, toSignalMap } from './language'; import type { ActionInContext } from './codeGenerator'; import type { Action } from './recorderActions'; import type { MouseClickOptions } from './utils'; import { toModifiers } from './utils'; -import { escapeWithQuotes, toTitleCase } from '../../utils/isomorphic/stringUtils'; +import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils'; import deviceDescriptors from '../deviceDescriptors'; +import { asLocator } from '../isomorphic/locatorGenerators'; type CSharpLanguageMode = 'library' | 'mstest' | 'nunit'; @@ -165,7 +165,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator { } private _asLocator(selector: string) { - return asLocator(this, selector); + return asLocator('csharp', selector); } generateHeader(options: LanguageGeneratorOptions): string { @@ -221,52 +221,6 @@ export class CSharpLanguageGenerator implements LanguageGenerator { return `${storageStateLine} } }\n`; } - - generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { - switch (kind) { - case 'default': - return `Locator(${quote(body)})`; - case 'nth': - return `Nth(${body})`; - case 'first': - return `First`; - case 'last': - return `Last`; - case 'role': - const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) - attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? quote(value) : value}`); - const attrString = attrs.length ? `, new () { ${attrs.join(', ')} }` : ''; - return `GetByRole(${quote(body)}${attrString})`; - case 'has-text': - return `Locator(${quote(body)}, new () { HasTextString: ${quote(options.hasText!)} })`; - case 'test-id': - return `GetByTestId(${quote(body)})`; - case 'text': - return toCallWithExact('GetByText', body, !!options.exact); - case 'alt': - return toCallWithExact('GetByAltText', body, !!options.exact); - case 'placeholder': - return toCallWithExact('GetByPlaceholder', body, !!options.exact); - case 'label': - return toCallWithExact('GetByLabel', body, !!options.exact); - case 'title': - return toCallWithExact('GetByTitle', body, !!options.exact); - default: - throw new Error('Unknown selector kind ' + kind); - } - } -} - -function 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(${quote(regex)}${suffix}))`; - } - if (exact) - return `${method}(${quote(body)}, new () { Exact: true })`; - return `${method}(${quote(body)})`; } function formatObject(value: any, indent = ' ', name = ''): string { diff --git a/packages/playwright-core/src/server/recorder/java.ts b/packages/playwright-core/src/server/recorder/java.ts index 0903531258..26aa518ca8 100644 --- a/packages/playwright-core/src/server/recorder/java.ts +++ b/packages/playwright-core/src/server/recorder/java.ts @@ -15,8 +15,7 @@ */ import type { BrowserContextOptions } from '../../..'; -import { asLocator } from './language'; -import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language'; +import type { LanguageGenerator, LanguageGeneratorOptions } from './language'; import { toSignalMap } from './language'; import type { ActionInContext } from './codeGenerator'; import type { Action } from './recorderActions'; @@ -24,7 +23,8 @@ import type { MouseClickOptions } from './utils'; import { toModifiers } from './utils'; import deviceDescriptors from '../deviceDescriptors'; import { JavaScriptFormatter } from './javascript'; -import { escapeWithQuotes, toTitleCase } from '../../utils/isomorphic/stringUtils'; +import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils'; +import { asLocator } from '../isomorphic/locatorGenerators'; export class JavaLanguageGenerator implements LanguageGenerator { id = 'java'; @@ -134,7 +134,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { } private _asLocator(selector: string, inFrameLocator: boolean) { - return asLocator(this, selector, inFrameLocator); + return asLocator('java', selector, inFrameLocator); } generateHeader(options: LanguageGeneratorOptions): string { @@ -159,58 +159,6 @@ export class JavaLanguageGenerator implements LanguageGenerator { } }`; } - - generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { - let clazz: string; - switch (base) { - case 'page': clazz = 'Page'; break; - case 'frame-locator': clazz = 'FrameLocator'; break; - case 'locator': clazz = 'Locator'; break; - } - switch (kind) { - case 'default': - return `locator(${quote(body)})`; - case 'nth': - return `nth(${body})`; - case 'first': - return `first()`; - case 'last': - return `last()`; - case 'role': - const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) - attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? quote(value) : value})`); - const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : ''; - return `getByRole(${quote(body)}${attrString})`; - case 'has-text': - return `locator(${quote(body)}, new ${clazz}.LocatorOptions().setHasText(${quote(options.hasText!)}))`; - case 'test-id': - return `getByTestId(${quote(body)})`; - case 'text': - return toCallWithExact(clazz, 'getByText', body, !!options.exact); - case 'alt': - return toCallWithExact(clazz, 'getByAltText', body, !!options.exact); - case 'placeholder': - return toCallWithExact(clazz, 'getByPlaceholder', body, !!options.exact); - case 'label': - return toCallWithExact(clazz, 'getByLabel', body, !!options.exact); - case 'title': - return toCallWithExact(clazz, 'getByTitle', body, !!options.exact); - default: - throw new Error('Unknown selector kind ' + kind); - } - } -} - -function 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(${quote(regex)}${suffix}))`; - } - if (exact) - return `${method}(${quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(exact))`; - return `${method}(${quote(body)})`; } function formatPath(files: string | string[]): string { diff --git a/packages/playwright-core/src/server/recorder/javascript.ts b/packages/playwright-core/src/server/recorder/javascript.ts index dfdab4d17b..e8449680fe 100644 --- a/packages/playwright-core/src/server/recorder/javascript.ts +++ b/packages/playwright-core/src/server/recorder/javascript.ts @@ -15,8 +15,7 @@ */ import type { BrowserContextOptions } from '../../..'; -import { asLocator } from './language'; -import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language'; +import type { LanguageGenerator, LanguageGeneratorOptions } from './language'; import { sanitizeDeviceOptions, toSignalMap } from './language'; import type { ActionInContext } from './codeGenerator'; import type { Action } from './recorderActions'; @@ -24,6 +23,7 @@ import type { MouseClickOptions } from './utils'; import { toModifiers } from './utils'; import deviceDescriptors from '../deviceDescriptors'; import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils'; +import { asLocator } from '../isomorphic/locatorGenerators'; export class JavaScriptLanguageGenerator implements LanguageGenerator { id: string; @@ -155,7 +155,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { } private _asLocator(selector: string) { - return asLocator(this, selector); + return asLocator('javascript', selector); } generateHeader(options: LanguageGeneratorOptions): string { @@ -202,47 +202,6 @@ ${useText ? '\ntest.use(' + useText + ');\n' : ''} await browser.close(); })();`; } - - generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { - switch (kind) { - case 'default': - return `locator(${quote(body)})`; - case 'nth': - return `nth(${body})`; - case 'first': - return `first()`; - case 'last': - return `last()`; - case 'role': - const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) - attrs.push(`${name}: ${typeof value === 'string' ? quote(value) : value}`); - const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : ''; - return `getByRole(${quote(body)}${attrString})`; - case 'has-text': - return `locator(${quote(body)}, { hasText: ${quote(options.hasText!)} })`; - case 'test-id': - return `getByTestId(${quote(body)})`; - case 'text': - return toCallWithExact('getByText', body, !!options.exact); - case 'alt': - return toCallWithExact('getByAltText', body, !!options.exact); - case 'placeholder': - return toCallWithExact('getByPlaceholder', body, !!options.exact); - case 'label': - return toCallWithExact('getByLabel', body, !!options.exact); - case 'title': - return toCallWithExact('getByTitle', body, !!options.exact); - default: - throw new Error('Unknown selector kind ' + kind); - } - } -} - -function toCallWithExact(method: string, body: string, exact: boolean) { - if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) - return `${method}(${body})`; - return exact ? `${method}(${quote(body)}, { exact: true })` : `${method}(${quote(body)})`; } function formatOptions(value: any, hasArguments: boolean): string { diff --git a/packages/playwright-core/src/server/recorder/language.ts b/packages/playwright-core/src/server/recorder/language.ts index 1545705dc4..8a5c919966 100644 --- a/packages/playwright-core/src/server/recorder/language.ts +++ b/packages/playwright-core/src/server/recorder/language.ts @@ -15,9 +15,6 @@ */ import type { BrowserContextOptions, LaunchOptions } from '../../..'; -import type { CSSComplexSelectorList } from '../isomorphic/cssParser'; -import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; -import type { ParsedSelector } from '../isomorphic/selectorParser'; import type { ActionInContext } from './codeGenerator'; import type { Action, DialogSignal, DownloadSignal, NavigationSignal, PopupSignal } from './recorderActions'; @@ -40,7 +37,6 @@ export interface LanguageGenerator { generateHeader(options: LanguageGeneratorOptions): string; generateAction(actionInContext: ActionInContext): string; generateFooter(saveStorage: string | undefined): string; - generateLocator(base: LocatorBase, kind: LocatorType, body: string, options?: { attrs?: Record, hasText?: string, exact?: boolean }): string; } export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions { @@ -75,85 +71,3 @@ export function toSignalMap(action: Action) { dialog, }; } - -function detectExact(text: string): { exact: boolean, text: string } { - let exact = false; - if (text.startsWith('"') && text.endsWith('"')) { - text = JSON.parse(text); - exact = true; - } - return { exact, text }; -} - -export function asLocator(generator: LanguageGenerator, selector: string, isFrameLocator: boolean = false): string { - const parsed = parseSelector(selector); - const tokens: string[] = []; - for (const part of parsed.parts) { - const base = part === parsed.parts[0] ? (isFrameLocator ? 'frame-locator' : 'page') : 'locator'; - if (part.name === 'nth') { - if (part.body === '0') - tokens.push(generator.generateLocator(base, 'first', '')); - else if (part.body === '-1') - tokens.push(generator.generateLocator(base, 'last', '')); - else - tokens.push(generator.generateLocator(base, 'nth', part.body as string)); - continue; - } - if (part.name === 'text') { - const { exact, text } = detectExact(part.body as string); - tokens.push(generator.generateLocator(base, 'text', text, { exact })); - continue; - } - if (part.name === 'internal:label') { - const { exact, text } = detectExact(part.body as string); - tokens.push(generator.generateLocator(base, 'label', text, { exact })); - continue; - } - if (part.name === 'role') { - const attrSelector = parseAttributeSelector(part.body as string, true); - const attrs: Record = {}; - for (const attr of attrSelector.attributes!) - attrs[attr.name === 'include-hidden' ? 'includeHidden' : attr.name] = attr.value; - tokens.push(generator.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(generator.generateLocator(base, 'has-text', parsed[0].simples[0].selector.css!, { hasText })); - continue; - } - } - - if (part.name === 'internal:attr') { - const attrSelector = parseAttributeSelector(part.body as string, true); - const { name, value } = attrSelector.attributes[0]; - if (name === 'data-testid') { - tokens.push(generator.generateLocator(base, 'test-id', value)); - continue; - } - - const { exact, text } = detectExact(value); - if (name === 'placeholder') { - tokens.push(generator.generateLocator(base, 'placeholder', text, { exact })); - continue; - } - if (name === 'alt') { - tokens.push(generator.generateLocator(base, 'alt', text, { exact })); - continue; - } - if (name === 'title') { - tokens.push(generator.generateLocator(base, 'title', text, { exact })); - continue; - } - if (name === 'label') { - tokens.push(generator.generateLocator(base, 'label', text, { exact })); - continue; - } - } - const p: ParsedSelector = { parts: [part] }; - tokens.push(generator.generateLocator(base, 'default', stringifySelector(p))); - } - return tokens.join('.'); -} diff --git a/packages/playwright-core/src/server/recorder/python.ts b/packages/playwright-core/src/server/recorder/python.ts index b11b509f42..01ec2e3e27 100644 --- a/packages/playwright-core/src/server/recorder/python.ts +++ b/packages/playwright-core/src/server/recorder/python.ts @@ -15,15 +15,15 @@ */ import type { BrowserContextOptions } from '../../..'; -import { asLocator } from './language'; -import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language'; +import type { LanguageGenerator, LanguageGeneratorOptions } from './language'; import { sanitizeDeviceOptions, toSignalMap } from './language'; import type { ActionInContext } from './codeGenerator'; import type { Action } from './recorderActions'; import type { MouseClickOptions } from './utils'; import { toModifiers } from './utils'; -import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils'; +import { escapeWithQuotes, toSnakeCase } from '../../utils/isomorphic/stringUtils'; import deviceDescriptors from '../deviceDescriptors'; +import { asLocator } from '../isomorphic/locatorGenerators'; export class PythonLanguageGenerator implements LanguageGenerator { id: string; @@ -150,7 +150,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { } private _asLocator(selector: string) { - return asLocator(this, selector); + return asLocator('python', selector); } generateHeader(options: LanguageGeneratorOptions): string { @@ -220,52 +220,6 @@ with sync_playwright() as playwright: `; } } - - generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record, hasText?: string, exact?: boolean } = {}): string { - switch (kind) { - case 'default': - return `locator(${quote(body)})`; - case 'nth': - return `nth(${body})`; - case 'first': - return `first`; - case 'last': - return `last`; - case 'role': - const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) - attrs.push(`${toSnakeCase(name)}=${typeof value === 'string' ? quote(value) : value}`); - const attrString = attrs.length ? `, ${attrs.join(', ')}` : ''; - return `get_by_role(${quote(body)}${attrString})`; - case 'has-text': - return `locator(${quote(body)}, has_text=${quote(options.hasText!)})`; - case 'test-id': - return `get_by_test_id(${quote(body)})`; - case 'text': - return toCallWithExact('get_by_text', body, !!options.exact); - case 'alt': - return toCallWithExact('get_by_alt_text', body, !!options.exact); - case 'placeholder': - return toCallWithExact('get_by_placeholder', body, !!options.exact); - case 'label': - return toCallWithExact('get_by_label', body, !!options.exact); - case 'title': - return toCallWithExact('get_by_title', body, !!options.exact); - default: - throw new Error('Unknown selector kind ' + kind); - } - } -} - -function 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${quote(regex)}${suffix}))`; - } - if (exact) - return `${method}(${quote(body)}, exact=true)`; - return `${method}(${quote(body)})`; } function formatValue(value: any): string { @@ -284,11 +238,6 @@ function formatValue(value: any): string { return String(value); } -function toSnakeCase(name: string): string { - const toSnakeCaseRegex = /((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))/g; - return name.replace(toSnakeCaseRegex, `_$1`).toLowerCase(); -} - function formatOptions(value: any, hasArguments: boolean, asDict?: boolean): string { const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); if (!keys.length) diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index d759e658db..24833813a5 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -31,6 +31,11 @@ export function toTitleCase(name: string) { return name.charAt(0).toUpperCase() + name.substring(1); } +export function toSnakeCase(name: string): string { + const toSnakeCaseRegex = /((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))/g; + return name.replace(toSnakeCaseRegex, `_$1`).toLowerCase(); +} + export function cssEscape(s: string): string { let result = ''; for (let i = 0; i < s.length; i++)