mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(locators): asLocator supports internal:has= (#18625)
The following snippet: ```js rowLocator .filter({ hasText: 'John' }) .filter({ has: page.getByRole('button', { name: 'Say hello' }) }) ``` is shown in the logs: ```log pw:api waiting for getByRole('listitem').filter({ hasText: 'John' }).filter({ has: getByRole('button', { name: 'Say hello' }) }) ```
This commit is contained in:
parent
712ce0dce9
commit
05b623e6b0
@ -15,11 +15,11 @@
|
||||
*/
|
||||
|
||||
import { escapeWithQuotes, toSnakeCase, toTitleCase } from '../../utils/isomorphic/stringUtils';
|
||||
import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
||||
import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
||||
import type { ParsedSelector } from '../isomorphic/selectorParser';
|
||||
|
||||
export 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 LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has';
|
||||
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
|
||||
|
||||
export interface LocatorFactory {
|
||||
@ -27,11 +27,10 @@ export interface LocatorFactory {
|
||||
}
|
||||
|
||||
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
|
||||
return innerAsLocator(generators[lang], selector, isFrameLocator);
|
||||
return innerAsLocator(generators[lang], parseSelector(selector), isFrameLocator);
|
||||
}
|
||||
|
||||
function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocator: boolean = false): string {
|
||||
const parsed = parseSelector(selector);
|
||||
function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrameLocator: boolean = false): string {
|
||||
const tokens: string[] = [];
|
||||
for (const part of parsed.parts) {
|
||||
const base = part === parsed.parts[0] ? (isFrameLocator ? 'frame-locator' : 'page') : 'locator';
|
||||
@ -54,6 +53,11 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
|
||||
tokens.push(factory.generateLocator(base, 'has-text', text, { exact }));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:has') {
|
||||
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed);
|
||||
tokens.push(factory.generateLocator(base, 'has', inner));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:label') {
|
||||
const { exact, text } = detectExact(part.body as string);
|
||||
tokens.push(factory.generateLocator(base, 'label', text, { exact }));
|
||||
@ -133,6 +137,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
|
||||
return `getByRole(${this.quote(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `filter({ hasText: ${this.toHasText(body as string)} })`;
|
||||
case 'has':
|
||||
return `filter({ has: ${body} })`;
|
||||
case 'test-id':
|
||||
return `getByTestId(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
@ -186,6 +192,8 @@ export class PythonLocatorFactory implements LocatorFactory {
|
||||
return `get_by_role(${this.quote(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `filter(has_text=${this.toHasText(body as string)})`;
|
||||
case 'has':
|
||||
return `filter(has=${body})`;
|
||||
case 'test-id':
|
||||
return `get_by_test_id(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
@ -251,6 +259,8 @@ export class JavaLocatorFactory implements LocatorFactory {
|
||||
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
|
||||
case 'has-text':
|
||||
return `filter(new ${clazz}.LocatorOptions().setHasText(${this.toHasText(body)}))`;
|
||||
case 'has':
|
||||
return `filter(new ${clazz}.LocatorOptions().setHas(${body}))`;
|
||||
case 'test-id':
|
||||
return `getByTestId(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
@ -312,6 +322,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
|
||||
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `Filter(new() { HasTextString: ${this.toHasText(body)} })`;
|
||||
case 'has':
|
||||
return `Filter(new() { Has: ${body} })`;
|
||||
case 'test-id':
|
||||
return `GetByTestId(${this.quote(body as string)})`;
|
||||
case 'text':
|
||||
|
@ -19,11 +19,12 @@ import { asLocator } from './locatorGenerators';
|
||||
import type { Language } from './locatorGenerators';
|
||||
import { parseSelector } from './selectorParser';
|
||||
|
||||
type TemplateParams = { quote: string, text: string }[];
|
||||
function parseLocator(locator: string): string {
|
||||
locator = locator
|
||||
.replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase())
|
||||
.replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`);
|
||||
const params: { quote: string, text: string }[] = [];
|
||||
const params: TemplateParams = [];
|
||||
let template = '';
|
||||
for (let i = 0; i < locator.length; ++i) {
|
||||
const quote = locator[i];
|
||||
@ -86,7 +87,53 @@ function parseLocator(locator: string): string {
|
||||
.replace(/regex=/g, '=')
|
||||
.replace(/,,/g, ',');
|
||||
|
||||
// Transform.
|
||||
return transform(template, params);
|
||||
}
|
||||
|
||||
function countParams(template: string) {
|
||||
return [...template.matchAll(/\$\d+/g)].length;
|
||||
}
|
||||
|
||||
function shiftParams(template: string, sub: number) {
|
||||
return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`);
|
||||
}
|
||||
|
||||
function transform(template: string, params: TemplateParams): string {
|
||||
// Recursively handle filter(has=).
|
||||
while (true) {
|
||||
const hasMatch = template.match(/filter\(,?has=/);
|
||||
if (!hasMatch)
|
||||
break;
|
||||
|
||||
// Extract inner locator based on balanced parens.
|
||||
const start = hasMatch.index! + hasMatch[0].length;
|
||||
let balance = 0;
|
||||
let end = start;
|
||||
for (; end < template.length; end++) {
|
||||
if (template[end] === '(')
|
||||
balance++;
|
||||
else if (template[end] === ')')
|
||||
balance--;
|
||||
if (balance < 0)
|
||||
break;
|
||||
}
|
||||
|
||||
const paramsCountBeforeHas = countParams(template.substring(0, start));
|
||||
const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas);
|
||||
const paramsCountInHas = countParams(hasTemplate);
|
||||
const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas);
|
||||
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams));
|
||||
|
||||
// Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again.
|
||||
template = template.substring(0, start - 1) + `2=$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end), paramsCountInHas - 1);
|
||||
|
||||
// Replace inner params with $5 value.
|
||||
const paramsBeforeHas = params.slice(0, paramsCountBeforeHas);
|
||||
const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas);
|
||||
params = paramsBeforeHas.concat([{ quote: '"', text: hasSelector }]).concat(paramsAfterHas);
|
||||
}
|
||||
|
||||
// Transform to selector engines.
|
||||
template = template
|
||||
.replace(/locator\(([^)]+)\)/g, '$1')
|
||||
.replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1')
|
||||
@ -97,11 +144,13 @@ function parseLocator(locator: string): string {
|
||||
.replace(/first(\(\))?/g, 'nth=0')
|
||||
.replace(/last(\(\))?/g, 'nth=-1')
|
||||
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
|
||||
.replace(/filter\(.*hastext=([^)]+)\)/g, 'internal:has-text=$1')
|
||||
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
|
||||
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')
|
||||
.replace(/,exact=false/g, '')
|
||||
.replace(/,exact=true/g, 's')
|
||||
.replace(/\,/g, '][');
|
||||
|
||||
// Substitute params.
|
||||
return template.split('.').map(t => {
|
||||
if (!t.startsWith('internal:'))
|
||||
return t.replace(/\$(\d+)/g, (_, ordinal) => { const param = params[+ordinal - 1]; return param.text; });
|
||||
@ -115,6 +164,8 @@ function parseLocator(locator: string): string {
|
||||
})
|
||||
.replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => {
|
||||
const param = params[+ordinal - 1];
|
||||
if (t.startsWith('internal:has='))
|
||||
return param.text;
|
||||
if (t.startsWith('internal:attr') || t.startsWith('internal:role'))
|
||||
return escapeForAttributeSelector(param.text, suffix === 's');
|
||||
return escapeForTextSelector(param.text, suffix === 's');
|
||||
|
@ -258,6 +258,27 @@ it('reverse engineer hasText', async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reverse engineer has', async ({ page }) => {
|
||||
expect.soft(generate(page.getByText('Hello').filter({ has: page.locator('div').getByText('bye') }))).toEqual({
|
||||
csharp: `GetByText("Hello").Filter(new() { Has: Locator("div").GetByText("bye") })`,
|
||||
java: `getByText("Hello").filter(new Locator.LocatorOptions().setHas(locator("div").getByText("bye")))`,
|
||||
javascript: `getByText('Hello').filter({ has: locator('div').getByText('bye') })`,
|
||||
python: `get_by_text("Hello").filter(has=locator("div").get_by_text("bye"))`,
|
||||
});
|
||||
|
||||
const locator = page
|
||||
.locator('section')
|
||||
.filter({ has: page.locator('div').filter({ has: page.locator('span') }) })
|
||||
.filter({ hasText: 'foo' })
|
||||
.filter({ has: page.locator('a') });
|
||||
expect.soft(generate(locator)).toEqual({
|
||||
csharp: `Locator("section").Filter(new() { Has: Locator("div").Filter(new() { Has: Locator("span") }) }).Filter(new() { HasTextString: "foo" }).Filter(new() { Has: Locator("a") })`,
|
||||
java: `locator("section").filter(new Locator.LocatorOptions().setHas(locator("div").filter(new Locator.LocatorOptions().setHas(locator("span"))))).filter(new Locator.LocatorOptions().setHasText("foo")).filter(new Locator.LocatorOptions().setHas(locator("a")))`,
|
||||
javascript: `locator('section').filter({ has: locator('div').filter({ has: locator('span') }) }).filter({ hasText: 'foo' }).filter({ has: locator('a') })`,
|
||||
python: `locator("section").filter(has=locator("div").filter(has=locator("span"))).filter(has_text="foo").filter(has=locator("a"))`,
|
||||
});
|
||||
});
|
||||
|
||||
it.describe(() => {
|
||||
it.beforeEach(async ({ context }) => {
|
||||
await (context as any)._enableRecorder({ language: 'javascript' });
|
||||
|
Loading…
x
Reference in New Issue
Block a user