diff --git a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts index 1fb7f9c8a2..e20fdb8d86 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts @@ -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': diff --git a/packages/playwright-core/src/server/isomorphic/locatorParser.ts b/packages/playwright-core/src/server/isomorphic/locatorParser.ts index 284979264d..c3ab9f8b45 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorParser.ts @@ -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'); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index d9f4126047..ea95d731cb 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -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' });