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.
This commit is contained in:
Dmitry Gozman 2023-05-15 19:42:51 -07:00 committed by GitHub
parent f469e4b1eb
commit 73f9f81db4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 209 additions and 41 deletions

View File

@ -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)})`;

View File

@ -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) {
}

View File

@ -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' });