chore: make locators generator isomorphic (#17850)
This commit is contained in:
parent
30179d4d78
commit
3ecaa36e25
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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('.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
Loading…
Reference in a new issue