chore: make locators generator isomorphic (#17850)

This commit is contained in:
Pavel Feldman 2022-10-05 12:13:22 -08:00 committed by GitHub
parent 30179d4d78
commit 3ecaa36e25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 351 additions and 291 deletions

View file

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

View file

@ -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<string, string | boolean>, 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<string, boolean | string> = {};
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<string, string | boolean>, 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<string, string | boolean>, 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<string, string | boolean>, 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<string, string | boolean>, 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<Language, LocatorFactory> = {
javascript: new JavaScriptLocatorFactory(),
python: new PythonLocatorFactory(),
java: new JavaLocatorFactory(),
csharp: new CSharpLocatorFactory(),
};

View file

@ -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<string, string | boolean>, 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 {

View file

@ -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<string, string | boolean>, 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 {

View file

@ -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<string, string | boolean>, 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 {

View file

@ -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<string, string | boolean>, 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<string, boolean | string> = {};
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('.');
}

View file

@ -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<string, string | boolean>, 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)

View file

@ -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++)