Skip to content

Commit

Permalink
fix(generator): generate nice locators for arbitrary selectors
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Oct 11, 2022
1 parent 1a43af3 commit 1d1a735
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 45 deletions.
Expand Up @@ -158,7 +158,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, acces
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
const input = element as HTMLInputElement | HTMLTextAreaElement;
if (input.placeholder)
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: 3 });
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, false)}]`, score: 3 });
const label = input.labels?.[0];
if (label) {
const labelText = elementText(injectedScript._evaluator._cacheText, label).full.trim();
Expand All @@ -176,7 +176,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, acces
}

if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName))
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, true)}]`, score: 10 });
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: 10 });

if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'FRAME', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName))
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteAttributeValue(element.getAttribute('name')!)}]`, score: 50 });
Expand Down
87 changes: 46 additions & 41 deletions packages/playwright-core/src/server/isomorphic/locatorGenerators.ts
Expand Up @@ -24,7 +24,7 @@ export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder'
export type LocatorBase = 'page' | 'locator' | 'frame-locator';

export interface LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options?: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean }): string;
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean }): string;
}

export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
Expand Down Expand Up @@ -74,13 +74,14 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato

if (part.name === 'internal:attr') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { name, value } = attrSelector.attributes[0];
const { name, value, caseSensitive } = attrSelector.attributes[0];
if (name === 'data-testid') {
tokens.push(factory.generateLocator(base, 'test-id', value));
continue;
}

const { exact, text } = detectExact(value);
const text = value as string | RegExp;
const exact = !!caseSensitive;
if (name === 'placeholder') {
tokens.push(factory.generateLocator(base, 'placeholder', text, { exact }));
continue;
Expand All @@ -104,8 +105,11 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
return tokens.join('.');
}

function detectExact(text: string): { exact: boolean, text: string } {
function detectExact(text: string): { exact?: boolean, text: string | RegExp } {
let exact = false;
const match = text.match(/^\/(.*)\/([igm]*)$/);
if (match)
return { text: new RegExp(match[1], match[2]) };
if (text.startsWith('"') && text.endsWith('"')) {
text = JSON.parse(text);
exact = true;
Expand All @@ -114,10 +118,10 @@ function detectExact(text: string): { exact: boolean, text: string } {
}

export class JavaScriptLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
switch (kind) {
case 'default':
return `locator(${this.quote(body)})`;
return `locator(${this.quote(body as string)})`;
case 'nth':
return `nth(${body})`;
case 'first':
Expand All @@ -129,11 +133,11 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`${name}: ${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
return `getByRole(${this.quote(body)}${attrString})`;
return `getByRole(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `locator(${this.quote(body)}, { hasText: ${this.quote(options.hasText!)} })`;
return `locator(${this.quote(body as string)}, { hasText: ${this.quote(options.hasText!)} })`;
case 'test-id':
return `getByTestId(${this.quote(body)})`;
return `getByTestId(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact('getByText', body, !!options.exact);
case 'alt':
Expand All @@ -149,8 +153,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
}
}

private toCallWithExact(method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i')))
private toCallWithExact(method: string, body: string | RegExp, exact?: boolean) {
if (isRegExp(body))
return `${method}(${body})`;
return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`;
}
Expand All @@ -161,10 +165,10 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
}

export class PythonLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
switch (kind) {
case 'default':
return `locator(${this.quote(body)})`;
return `locator(${this.quote(body as string)})`;
case 'nth':
return `nth(${body})`;
case 'first':
Expand All @@ -176,11 +180,11 @@ export class PythonLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`${toSnakeCase(name)}=${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
return `get_by_role(${this.quote(body)}${attrString})`;
return `get_by_role(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `locator(${this.quote(body)}, has_text=${this.quote(options.hasText!)})`;
return `locator(${this.quote(body as string)}, has_text=${this.quote(options.hasText!)})`;
case 'test-id':
return `get_by_test_id(${this.quote(body)})`;
return `get_by_test_id(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact('get_by_text', body, !!options.exact);
case 'alt':
Expand All @@ -196,11 +200,10 @@ export class PythonLocatorFactory implements LocatorFactory {
}
}

private toCallWithExact(method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
const regex = body.substring(1, body.lastIndexOf('/'));
const suffix = body.endsWith('i') ? ', re.IGNORECASE' : '';
return `${method}(re.compile(r${this.quote(regex)}${suffix}))`;
private toCallWithExact(method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body)) {
const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : '';
return `${method}(re.compile(r${this.quote(body.source)}${suffix}))`;
}
if (exact)
return `${method}(${this.quote(body)}, exact=true)`;
Expand All @@ -213,7 +216,7 @@ export class PythonLocatorFactory implements LocatorFactory {
}

export class JavaLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
let clazz: string;
switch (base) {
case 'page': clazz = 'Page'; break;
Expand All @@ -222,7 +225,7 @@ export class JavaLocatorFactory implements LocatorFactory {
}
switch (kind) {
case 'default':
return `locator(${this.quote(body)})`;
return `locator(${this.quote(body as string)})`;
case 'nth':
return `nth(${body})`;
case 'first':
Expand All @@ -234,11 +237,11 @@ export class JavaLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})`);
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
return `getByRole(AriaRole.${toSnakeCase(body).toUpperCase()}${attrString})`;
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
case 'has-text':
return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasText(${this.quote(options.hasText!)}))`;
return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.quote(options.hasText!)}))`;
case 'test-id':
return `getByTestId(${this.quote(body)})`;
return `getByTestId(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact(clazz, 'getByText', body, !!options.exact);
case 'alt':
Expand All @@ -254,11 +257,10 @@ export class JavaLocatorFactory implements LocatorFactory {
}
}

private toCallWithExact(clazz: string, method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
const regex = body.substring(1, body.lastIndexOf('/'));
const suffix = body.endsWith('i') ? ', Pattern.CASE_INSENSITIVE' : '';
return `${method}(Pattern.compile(${this.quote(regex)}${suffix}))`;
private toCallWithExact(clazz: string, method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body)) {
const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : '';
return `${method}(Pattern.compile(${this.quote(body.source)}${suffix}))`;
}
if (exact)
return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(exact))`;
Expand All @@ -271,10 +273,10 @@ export class JavaLocatorFactory implements LocatorFactory {
}

export class CSharpLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
switch (kind) {
case 'default':
return `Locator(${this.quote(body)})`;
return `Locator(${this.quote(body as string)})`;
case 'nth':
return `Nth(${body})`;
case 'first':
Expand All @@ -286,11 +288,11 @@ export class CSharpLocatorFactory implements LocatorFactory {
for (const [name, value] of Object.entries(options.attrs!))
attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, new () { ${attrs.join(', ')} }` : '';
return `GetByRole(AriaRole.${toTitleCase(body)}${attrString})`;
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
case 'has-text':
return `Locator(${this.quote(body)}, new () { HasTextString: ${this.quote(options.hasText!)} })`;
return `Locator(${this.quote(body as string)}, new () { HasTextString: ${this.quote(options.hasText!)} })`;
case 'test-id':
return `GetByTestId(${this.quote(body)})`;
return `GetByTestId(${this.quote(body as string)})`;
case 'text':
return this.toCallWithExact('GetByText', body, !!options.exact);
case 'alt':
Expand All @@ -306,11 +308,10 @@ export class CSharpLocatorFactory implements LocatorFactory {
}
}

private toCallWithExact(method: string, body: string, exact: boolean) {
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
const regex = body.substring(1, body.lastIndexOf('/'));
const suffix = body.endsWith('i') ? ', RegexOptions.IgnoreCase' : '';
return `${method}(new Regex(${this.quote(regex)}${suffix}))`;
private toCallWithExact(method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body)) {
const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : '';
return `${method}(new Regex(${this.quote(body.source)}${suffix}))`;
}
if (exact)
return `${method}(${this.quote(body)}, new () { Exact: true })`;
Expand All @@ -328,3 +329,7 @@ const generators: Record<Language, LocatorFactory> = {
java: new JavaLocatorFactory(),
csharp: new CSharpLocatorFactory(),
};

export function isRegExp(obj: any): obj is RegExp {
return obj instanceof RegExp;
}
4 changes: 2 additions & 2 deletions tests/library/inspector/cli-codegen-3.spec.ts
Expand Up @@ -267,7 +267,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input placeholder="Country"></input>`);

const selector = await recorder.hoverOverElement('input');
expect(selector).toBe('internal:attr=[placeholder="Country"]');
expect(selector).toBe('internal:attr=[placeholder="Country"i]');

const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'click'),
Expand Down Expand Up @@ -296,7 +296,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input alt="Country"></input>`);

const selector = await recorder.hoverOverElement('input');
expect(selector).toBe('internal:attr=[alt="Country"]');
expect(selector).toBe('internal:attr=[alt="Country"i]');

const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'click'),
Expand Down

0 comments on commit 1d1a735

Please sign in to comment.