From 73f9f81db49f1ce702982d2b649adcaf5542ec89 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 15 May 2023 19:42:51 -0700 Subject: [PATCH] feat(locators): allow ambiguous locators when parsing (#23034) This supports locators like `nth(0)` and `locator('div', { hasText: 'foo' })` that are not canonical, but still work. Fixes #22990, #22965. --- .../src/utils/isomorphic/locatorGenerators.ts | 136 +++++++++++++----- .../src/utils/isomorphic/locatorParser.ts | 29 +++- tests/library/locator-generator.spec.ts | 85 ++++++++++- 3 files changed, 209 insertions(+), 41 deletions(-) diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 7a26ed785c..6f0920299e 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -22,25 +22,35 @@ export type Language = 'javascript' | 'python' | 'java' | 'csharp'; export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'and' | 'or'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; -type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp }; +type LocatorOptions = { + attrs?: { name: string, value: string | boolean | number }[], + exact?: boolean, + name?: string | RegExp, + hasText?: string | RegExp, + hasNotText?: string | RegExp, +}; export interface LocatorFactory { generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: LocatorOptions): string; } export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false, playSafe: boolean = false): string { + return asLocators(lang, selector, isFrameLocator, playSafe)[0]; +} + +export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, playSafe: boolean = false, maxOutputSize = 20): string[] { if (playSafe) { try { - return innerAsLocator(generators[lang], parseSelector(selector), isFrameLocator); + return innerAsLocators(generators[lang], parseSelector(selector), isFrameLocator, maxOutputSize); } catch (e) { // Tolerate invalid input. - return selector; + return [selector]; } } else { - return innerAsLocator(generators[lang], parseSelector(selector), isFrameLocator); + return innerAsLocators(generators[lang], parseSelector(selector), isFrameLocator, maxOutputSize); } } -function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrameLocator: boolean = false): string { +function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFrameLocator: boolean = false, maxOutputSize = 20): string[] { const parts = [...parsed.parts]; // frameLocator('iframe').first is actually "iframe >> nth=0 >> internal:control=enter-frame" // To make it easier to parse, we turn it into "iframe >> internal:control=enter-frame >> nth=0" @@ -52,7 +62,7 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame } } - const tokens: string[] = []; + const tokens: string[][] = []; let nextBase: LocatorBase = isFrameLocator ? 'frame-locator' : 'page'; for (let index = 0; index < parts.length; index++) { const part = parts[index]; @@ -61,23 +71,23 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame if (part.name === 'nth') { if (part.body === '0') - tokens.push(factory.generateLocator(base, 'first', '')); + tokens.push([factory.generateLocator(base, 'first', ''), factory.generateLocator(base, 'nth', '0')]); else if (part.body === '-1') - tokens.push(factory.generateLocator(base, 'last', '')); + tokens.push([factory.generateLocator(base, 'last', ''), factory.generateLocator(base, 'nth', '-1')]); else - tokens.push(factory.generateLocator(base, 'nth', part.body as string)); + tokens.push([factory.generateLocator(base, 'nth', part.body as string)]); continue; } if (part.name === 'internal:text') { const { exact, text } = detectExact(part.body as string); - tokens.push(factory.generateLocator(base, 'text', text, { exact })); + tokens.push([factory.generateLocator(base, 'text', text, { exact })]); continue; } if (part.name === 'internal:has-text') { const { exact, text } = detectExact(part.body as string); // There is no locator equivalent for strict has-text, leave it as is. if (!exact) { - tokens.push(factory.generateLocator(base, 'has-text', text, { exact })); + tokens.push([factory.generateLocator(base, 'has-text', text, { exact })]); continue; } } @@ -85,33 +95,33 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame const { exact, text } = detectExact(part.body as string); // There is no locator equivalent for strict has-not-text, leave it as is. if (!exact) { - tokens.push(factory.generateLocator(base, 'has-not-text', text, { exact })); + tokens.push([factory.generateLocator(base, 'has-not-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)); + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'has', inner))); continue; } if (part.name === 'internal:has-not') { - const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); - tokens.push(factory.generateLocator(base, 'hasNot', inner)); + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'hasNot', inner))); continue; } if (part.name === 'internal:and') { - const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); - tokens.push(factory.generateLocator(base, 'and', inner)); + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'and', inner))); continue; } if (part.name === 'internal:or') { - const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); - tokens.push(factory.generateLocator(base, 'or', inner)); + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'or', inner))); continue; } if (part.name === 'internal:label') { const { exact, text } = detectExact(part.body as string); - tokens.push(factory.generateLocator(base, 'label', text, { exact })); + tokens.push([factory.generateLocator(base, 'label', text, { exact })]); continue; } if (part.name === 'internal:role') { @@ -127,13 +137,13 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame options.attrs!.push({ name: attr.name === 'include-hidden' ? 'includeHidden' : attr.name, value: attr.value }); } } - tokens.push(factory.generateLocator(base, 'role', attrSelector.name, options)); + tokens.push([factory.generateLocator(base, 'role', attrSelector.name, options)]); continue; } if (part.name === 'internal:testid') { const attrSelector = parseAttributeSelector(part.body as string, true); const { value } = attrSelector.attributes[0]; - tokens.push(factory.generateLocator(base, 'test-id', value)); + tokens.push([factory.generateLocator(base, 'test-id', value)]); continue; } if (part.name === 'internal:attr') { @@ -142,15 +152,15 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame const text = value as string | RegExp; const exact = !!caseSensitive; if (name === 'placeholder') { - tokens.push(factory.generateLocator(base, 'placeholder', text, { exact })); + tokens.push([factory.generateLocator(base, 'placeholder', text, { exact })]); continue; } if (name === 'alt') { - tokens.push(factory.generateLocator(base, 'alt', text, { exact })); + tokens.push([factory.generateLocator(base, 'alt', text, { exact })]); continue; } if (name === 'title') { - tokens.push(factory.generateLocator(base, 'title', text, { exact })); + tokens.push([factory.generateLocator(base, 'title', text, { exact })]); continue; } } @@ -164,10 +174,54 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame index++; } - const p: ParsedSelector = { parts: [part] }; - tokens.push(factory.generateLocator(base, locatorType, stringifySelector(p))); + const selectorPart = stringifySelector({ parts: [part] }); + const locatorPart = factory.generateLocator(base, locatorType, selectorPart); + + if (locatorType === 'default' && nextPart && ['internal:has-text', 'internal:has-not-text'].includes(nextPart.name)) { + const { exact, text } = detectExact(nextPart.body as string); + // There is no locator equivalent for strict has-text and has-not-text, leave it as is. + if (!exact) { + const nextLocatorPart = factory.generateLocator('locator', nextPart.name === 'internal:has-text' ? 'has-text' : 'has-not-text', text, { exact }); + const options: LocatorOptions = {}; + if (nextPart.name === 'internal:has-text') + options.hasText = text; + else + options.hasNotText = text; + const combinedPart = factory.generateLocator(base, 'default', selectorPart, options); + // Two options: + // - locator('div').filter({ hasText: 'foo' }) + // - locator('div', { hasText: 'foo' }) + tokens.push([locatorPart + '.' + nextLocatorPart, combinedPart]); + index++; + continue; + } + } + + tokens.push([locatorPart]); } - return tokens.join('.'); + + return combineTokens(tokens, maxOutputSize); +} + +function combineTokens(tokens: string[][], maxOutputSize: number): string[] { + const currentTokens = tokens.map(() => ''); + const result: string[] = []; + + const visit = (index: number) => { + if (index === tokens.length) { + result.push(currentTokens.join('.')); + return currentTokens.length < maxOutputSize; + } + for (const taken of tokens[index]) { + currentTokens[index] = taken; + if (!visit(index + 1)) + return false; + } + return true; + }; + + visit(0); + return result; } function detectExact(text: string): { exact?: boolean, text: string | RegExp } { @@ -192,6 +246,10 @@ export class JavaScriptLocatorFactory implements LocatorFactory { generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { switch (kind) { case 'default': + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, { hasText: ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`; return `locator(${this.quote(body as string)})`; case 'frame': return `frameLocator(${this.quote(body as string)})`; @@ -215,9 +273,9 @@ export class JavaScriptLocatorFactory implements LocatorFactory { const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : ''; return `getByRole(${this.quote(body as string)}${attrString})`; case 'has-text': - return `filter({ hasText: ${this.toHasText(body as string)} })`; + return `filter({ hasText: ${this.toHasText(body)} })`; case 'has-not-text': - return `filter({ hasNotText: ${this.toHasText(body as string)} })`; + return `filter({ hasNotText: ${this.toHasText(body)} })`; case 'has': return `filter({ has: ${body} })`; case 'hasNot': @@ -264,6 +322,10 @@ export class PythonLocatorFactory implements LocatorFactory { generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { switch (kind) { case 'default': + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, has_text=${this.toHasText(options.hasText)})`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})`; return `locator(${this.quote(body as string)})`; case 'frame': return `frame_locator(${this.quote(body as string)})`; @@ -291,9 +353,9 @@ export class PythonLocatorFactory implements LocatorFactory { const attrString = attrs.length ? `, ${attrs.join(', ')}` : ''; return `get_by_role(${this.quote(body as string)}${attrString})`; case 'has-text': - return `filter(has_text=${this.toHasText(body as string)})`; + return `filter(has_text=${this.toHasText(body)})`; case 'has-not-text': - return `filter(has_not_text=${this.toHasText(body as string)})`; + return `filter(has_not_text=${this.toHasText(body)})`; case 'has': return `filter(has=${body})`; case 'hasNot': @@ -353,6 +415,10 @@ export class JavaLocatorFactory implements LocatorFactory { } switch (kind) { case 'default': + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`; return `locator(${this.quote(body as string)})`; case 'frame': return `frameLocator(${this.quote(body as string)})`; @@ -432,6 +498,10 @@ export class CSharpLocatorFactory implements LocatorFactory { generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { switch (kind) { case 'default': + if (options.hasText !== undefined) + return `Locator(${this.quote(body as string)}, new() { ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== undefined) + return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })`; return `Locator(${this.quote(body as string)})`; case 'frame': return `FrameLocator(${this.quote(body as string)})`; diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index e243700b2a..ca8ad5dc24 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -15,7 +15,7 @@ */ import { escapeForAttributeSelector, escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; -import { asLocator } from './locatorGenerators'; +import { asLocators } from './locatorGenerators'; import type { Language } from './locatorGenerators'; import { parseSelector } from './selectorParser'; @@ -78,7 +78,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string { .replace(/[{}\s]/g, '') .replace(/new\(\)/g, '') .replace(/new[\w]+\.[\w]+options\(\)/g, '') - .replace(/\.set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) + .replace(/\.set/g, ',set') .replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or". .replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and". .replace(/:/g, '=') @@ -104,10 +104,10 @@ function shiftParams(template: string, sub: number) { } function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { - // Recursively handle filter(has=, hasnot=). - // TODO: handle and(locator), or(locator). + // Recursively handle filter(has=, hasnot=, sethas(), sethasnot()). + // TODO: handle and(locator), or(locator), locator(has=, hasnot=, sethas(), sethasnot()). while (true) { - const hasMatch = template.match(/filter\(,?(has|hasnot)=/); + const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/); if (!hasMatch) break; @@ -124,6 +124,15 @@ function transform(template: string, params: TemplateParams, testIdAttributeName break; } + // Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=... + let prefix = template.substring(0, start); + let extraSymbol = 0; + if (['sethas(', 'sethasnot('].includes(hasMatch[1])) { + // Eat extra ) symbol at the end of sethas(...) + extraSymbol = 1; + prefix = prefix.replace(/sethas\($/, 'has=').replace(/sethasnot\($/, 'hasnot='); + } + const paramsCountBeforeHas = countParams(template.substring(0, start)); const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas); const paramsCountInHas = countParams(hasTemplate); @@ -132,7 +141,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName // Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again. // Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again. - template = template.substring(0, start - 1) + `2=$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end), paramsCountInHas - 1); + template = prefix.replace(/=$/, '2=') + `$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1); // Replace inner params with $5 value. const paramsBeforeHas = params.slice(0, paramsCountBeforeHas); @@ -142,7 +151,11 @@ function transform(template: string, params: TemplateParams, testIdAttributeName // Transform to selector engines. template = template + .replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) .replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') + .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') + .replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2') + .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') .replace(/locator\(([^)]+)\)/g, '$1') .replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1') .replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1') @@ -203,7 +216,9 @@ export function locatorOrSelectorAsSelector(language: Language, locator: string, } try { const selector = parseLocator(locator, testIdAttributeName); - if (digestForComparison(asLocator(language, selector)) === digestForComparison(locator)) + const locators = asLocators(language, selector); + const digest = digestForComparison(locator); + if (locators.some(candidate => digestForComparison(candidate) === digest)) return selector; } catch (e) { } diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index d322cb91e1..de880e18b3 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -15,7 +15,7 @@ */ import { contextTest as it, expect } from '../config/browserTest'; -import { asLocator } from '../../packages/playwright-core/lib/utils/isomorphic/locatorGenerators'; +import { asLocator, asLocators } from '../../packages/playwright-core/lib/utils/isomorphic/locatorGenerators'; import { locatorOrSelectorAsSelector as parseLocator } from '../../packages/playwright-core/lib/utils/isomorphic/locatorParser'; import type { Page, Frame, Locator, FrameLocator } from 'playwright-core'; @@ -361,6 +361,89 @@ it('reverse engineer frameLocator', async ({ page }) => { expect.soft(asLocator('javascript', selector, false)).toBe(`locator('div').frameLocator('iframe').locator('span')`); }); +it('generate multiple locators', async ({ page }) => { + const selector = (page.locator('div', { hasText: 'foo' }).nth(0).filter({ has: page.locator('span', { hasNotText: 'bar' }).nth(-1) }) as any)._selector; + const locators = { + javascript: [ + `locator('div').filter({ hasText: 'foo' }).first().filter({ has: locator('span').filter({ hasNotText: 'bar' }).last() })`, + `locator('div').filter({ hasText: 'foo' }).first().filter({ has: locator('span').filter({ hasNotText: 'bar' }).nth(-1) })`, + `locator('div').filter({ hasText: 'foo' }).first().filter({ has: locator('span', { hasNotText: 'bar' }).last() })`, + `locator('div').filter({ hasText: 'foo' }).first().filter({ has: locator('span', { hasNotText: 'bar' }).nth(-1) })`, + `locator('div').filter({ hasText: 'foo' }).nth(0).filter({ has: locator('span').filter({ hasNotText: 'bar' }).last() })`, + `locator('div').filter({ hasText: 'foo' }).nth(0).filter({ has: locator('span').filter({ hasNotText: 'bar' }).nth(-1) })`, + `locator('div').filter({ hasText: 'foo' }).nth(0).filter({ has: locator('span', { hasNotText: 'bar' }).last() })`, + `locator('div').filter({ hasText: 'foo' }).nth(0).filter({ has: locator('span', { hasNotText: 'bar' }).nth(-1) })`, + `locator('div', { hasText: 'foo' }).first().filter({ has: locator('span').filter({ hasNotText: 'bar' }).last() })`, + `locator('div', { hasText: 'foo' }).first().filter({ has: locator('span').filter({ hasNotText: 'bar' }).nth(-1) })`, + `locator('div', { hasText: 'foo' }).first().filter({ has: locator('span', { hasNotText: 'bar' }).last() })`, + `locator('div', { hasText: 'foo' }).first().filter({ has: locator('span', { hasNotText: 'bar' }).nth(-1) })`, + `locator('div', { hasText: 'foo' }).nth(0).filter({ has: locator('span').filter({ hasNotText: 'bar' }).last() })`, + `locator('div', { hasText: 'foo' }).nth(0).filter({ has: locator('span').filter({ hasNotText: 'bar' }).nth(-1) })`, + `locator('div', { hasText: 'foo' }).nth(0).filter({ has: locator('span', { hasNotText: 'bar' }).last() })`, + `locator('div', { hasText: 'foo' }).nth(0).filter({ has: locator('span', { hasNotText: 'bar' }).nth(-1) })`, + ], + java: [ + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).last()))`, + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).nth(-1)))`, + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).last()))`, + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).nth(-1)))`, + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).last()))`, + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).nth(-1)))`, + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).last()))`, + `locator("div").filter(new Locator.FilterOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).nth(-1)))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).last()))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).nth(-1)))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).last()))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).first().filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).nth(-1)))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).last()))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span").filter(new Locator.FilterOptions().setHasNotText("bar")).nth(-1)))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).last()))`, + `locator("div", new Page.LocatorOptions().setHasText("foo")).nth(0).filter(new Locator.FilterOptions().setHas(locator("span", new Page.LocatorOptions().setHasNotText("bar")).nth(-1)))`, + ], + python: [ + `locator("div").filter(has_text="foo").first.filter(has=locator("span").filter(has_not_text="bar").last)`, + `locator("div").filter(has_text="foo").first.filter(has=locator("span").filter(has_not_text="bar").nth(-1))`, + `locator("div").filter(has_text="foo").first.filter(has=locator("span", has_not_text="bar").last)`, + `locator("div").filter(has_text="foo").first.filter(has=locator("span", has_not_text="bar").nth(-1))`, + `locator("div").filter(has_text="foo").nth(0).filter(has=locator("span").filter(has_not_text="bar").last)`, + `locator("div").filter(has_text="foo").nth(0).filter(has=locator("span").filter(has_not_text="bar").nth(-1))`, + `locator("div").filter(has_text="foo").nth(0).filter(has=locator("span", has_not_text="bar").last)`, + `locator("div").filter(has_text="foo").nth(0).filter(has=locator("span", has_not_text="bar").nth(-1))`, + `locator("div", has_text="foo").first.filter(has=locator("span").filter(has_not_text="bar").last)`, + `locator("div", has_text="foo").first.filter(has=locator("span").filter(has_not_text="bar").nth(-1))`, + `locator("div", has_text="foo").first.filter(has=locator("span", has_not_text="bar").last)`, + `locator("div", has_text="foo").first.filter(has=locator("span", has_not_text="bar").nth(-1))`, + `locator("div", has_text="foo").nth(0).filter(has=locator("span").filter(has_not_text="bar").last)`, + `locator("div", has_text="foo").nth(0).filter(has=locator("span").filter(has_not_text="bar").nth(-1))`, + `locator("div", has_text="foo").nth(0).filter(has=locator("span", has_not_text="bar").last)`, + `locator("div", has_text="foo").nth(0).filter(has=locator("span", has_not_text="bar").nth(-1))`, + ], + csharp: [ + `Locator("div").Filter(new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Last })`, + `Locator("div").Filter(new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Nth(-1) })`, + `Locator("div").Filter(new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Last })`, + `Locator("div").Filter(new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Nth(-1) })`, + `Locator("div").Filter(new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Last })`, + `Locator("div").Filter(new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Nth(-1) })`, + `Locator("div").Filter(new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Last })`, + `Locator("div").Filter(new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Nth(-1) })`, + `Locator("div", new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Last })`, + `Locator("div", new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Nth(-1) })`, + `Locator("div", new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Last })`, + `Locator("div", new() { HasText = "foo" }).First.Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Nth(-1) })`, + `Locator("div", new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Last })`, + `Locator("div", new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span").Filter(new() { HasNotText = "bar" }).Nth(-1) })`, + `Locator("div", new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Last })`, + `Locator("div", new() { HasText = "foo" }).Nth(0).Filter(new() { Has = Locator("span", new() { HasNotText = "bar" }).Nth(-1) })`, + ], + }; + for (const lang of ['javascript', 'java', 'python', 'csharp'] as const) { + expect.soft(asLocators(lang, selector, false)).toEqual(locators[lang]); + for (const locator of locators[lang]) + expect.soft(parseLocator(lang, locator, 'data-testid'), `parse(${lang}): ${locator}`).toBe(selector); + } +}); + it.describe(() => { it.beforeEach(async ({ context }) => { await (context as any)._enableRecorder({ language: 'javascript' });