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 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'; 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 { export interface LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: LocatorOptions): string; 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 { 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) { if (playSafe) {
try { try {
return innerAsLocator(generators[lang], parseSelector(selector), isFrameLocator); return innerAsLocators(generators[lang], parseSelector(selector), isFrameLocator, maxOutputSize);
} catch (e) { } catch (e) {
// Tolerate invalid input. // Tolerate invalid input.
return selector; return [selector];
} }
} else { } 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]; const parts = [...parsed.parts];
// frameLocator('iframe').first is actually "iframe >> nth=0 >> internal:control=enter-frame" // 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" // 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'; let nextBase: LocatorBase = isFrameLocator ? 'frame-locator' : 'page';
for (let index = 0; index < parts.length; index++) { for (let index = 0; index < parts.length; index++) {
const part = parts[index]; const part = parts[index];
@ -61,23 +71,23 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
if (part.name === 'nth') { if (part.name === 'nth') {
if (part.body === '0') 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') else if (part.body === '-1')
tokens.push(factory.generateLocator(base, 'last', '')); tokens.push([factory.generateLocator(base, 'last', ''), factory.generateLocator(base, 'nth', '-1')]);
else else
tokens.push(factory.generateLocator(base, 'nth', part.body as string)); tokens.push([factory.generateLocator(base, 'nth', part.body as string)]);
continue; continue;
} }
if (part.name === 'internal:text') { if (part.name === 'internal:text') {
const { exact, text } = detectExact(part.body as string); 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; continue;
} }
if (part.name === 'internal:has-text') { if (part.name === 'internal:has-text') {
const { exact, text } = detectExact(part.body as string); const { exact, text } = detectExact(part.body as string);
// There is no locator equivalent for strict has-text, leave it as is. // There is no locator equivalent for strict has-text, leave it as is.
if (!exact) { if (!exact) {
tokens.push(factory.generateLocator(base, 'has-text', text, { exact })); tokens.push([factory.generateLocator(base, 'has-text', text, { exact })]);
continue; continue;
} }
} }
@ -85,33 +95,33 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
const { exact, text } = detectExact(part.body as string); const { exact, text } = detectExact(part.body as string);
// There is no locator equivalent for strict has-not-text, leave it as is. // There is no locator equivalent for strict has-not-text, leave it as is.
if (!exact) { if (!exact) {
tokens.push(factory.generateLocator(base, 'has-not-text', text, { exact })); tokens.push([factory.generateLocator(base, 'has-not-text', text, { exact })]);
continue; continue;
} }
} }
if (part.name === 'internal:has') { if (part.name === 'internal:has') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(factory.generateLocator(base, 'has', inner)); tokens.push(inners.map(inner => factory.generateLocator(base, 'has', inner)));
continue; continue;
} }
if (part.name === 'internal:has-not') { if (part.name === 'internal:has-not') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(factory.generateLocator(base, 'hasNot', inner)); tokens.push(inners.map(inner => factory.generateLocator(base, 'hasNot', inner)));
continue; continue;
} }
if (part.name === 'internal:and') { if (part.name === 'internal:and') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(factory.generateLocator(base, 'and', inner)); tokens.push(inners.map(inner => factory.generateLocator(base, 'and', inner)));
continue; continue;
} }
if (part.name === 'internal:or') { if (part.name === 'internal:or') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(factory.generateLocator(base, 'or', inner)); tokens.push(inners.map(inner => factory.generateLocator(base, 'or', inner)));
continue; continue;
} }
if (part.name === 'internal:label') { if (part.name === 'internal:label') {
const { exact, text } = detectExact(part.body as string); 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; continue;
} }
if (part.name === 'internal:role') { 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 }); 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; continue;
} }
if (part.name === 'internal:testid') { if (part.name === 'internal:testid') {
const attrSelector = parseAttributeSelector(part.body as string, true); const attrSelector = parseAttributeSelector(part.body as string, true);
const { value } = attrSelector.attributes[0]; const { value } = attrSelector.attributes[0];
tokens.push(factory.generateLocator(base, 'test-id', value)); tokens.push([factory.generateLocator(base, 'test-id', value)]);
continue; continue;
} }
if (part.name === 'internal:attr') { if (part.name === 'internal:attr') {
@ -142,15 +152,15 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
const text = value as string | RegExp; const text = value as string | RegExp;
const exact = !!caseSensitive; const exact = !!caseSensitive;
if (name === 'placeholder') { if (name === 'placeholder') {
tokens.push(factory.generateLocator(base, 'placeholder', text, { exact })); tokens.push([factory.generateLocator(base, 'placeholder', text, { exact })]);
continue; continue;
} }
if (name === 'alt') { if (name === 'alt') {
tokens.push(factory.generateLocator(base, 'alt', text, { exact })); tokens.push([factory.generateLocator(base, 'alt', text, { exact })]);
continue; continue;
} }
if (name === 'title') { if (name === 'title') {
tokens.push(factory.generateLocator(base, 'title', text, { exact })); tokens.push([factory.generateLocator(base, 'title', text, { exact })]);
continue; continue;
} }
} }
@ -164,10 +174,54 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
index++; index++;
} }
const p: ParsedSelector = { parts: [part] }; const selectorPart = stringifySelector({ parts: [part] });
tokens.push(factory.generateLocator(base, locatorType, stringifySelector(p))); 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 } { 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 { generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
switch (kind) { switch (kind) {
case 'default': 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)})`; return `locator(${this.quote(body as string)})`;
case 'frame': case 'frame':
return `frameLocator(${this.quote(body as string)})`; return `frameLocator(${this.quote(body as string)})`;
@ -215,9 +273,9 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : ''; const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
return `getByRole(${this.quote(body as string)}${attrString})`; return `getByRole(${this.quote(body as string)}${attrString})`;
case 'has-text': case 'has-text':
return `filter({ hasText: ${this.toHasText(body as string)} })`; return `filter({ hasText: ${this.toHasText(body)} })`;
case 'has-not-text': case 'has-not-text':
return `filter({ hasNotText: ${this.toHasText(body as string)} })`; return `filter({ hasNotText: ${this.toHasText(body)} })`;
case 'has': case 'has':
return `filter({ has: ${body} })`; return `filter({ has: ${body} })`;
case 'hasNot': case 'hasNot':
@ -264,6 +322,10 @@ export class PythonLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
switch (kind) { switch (kind) {
case 'default': 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)})`; return `locator(${this.quote(body as string)})`;
case 'frame': case 'frame':
return `frame_locator(${this.quote(body as string)})`; return `frame_locator(${this.quote(body as string)})`;
@ -291,9 +353,9 @@ export class PythonLocatorFactory implements LocatorFactory {
const attrString = attrs.length ? `, ${attrs.join(', ')}` : ''; const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
return `get_by_role(${this.quote(body as string)}${attrString})`; return `get_by_role(${this.quote(body as string)}${attrString})`;
case 'has-text': case 'has-text':
return `filter(has_text=${this.toHasText(body as string)})`; return `filter(has_text=${this.toHasText(body)})`;
case 'has-not-text': case 'has-not-text':
return `filter(has_not_text=${this.toHasText(body as string)})`; return `filter(has_not_text=${this.toHasText(body)})`;
case 'has': case 'has':
return `filter(has=${body})`; return `filter(has=${body})`;
case 'hasNot': case 'hasNot':
@ -353,6 +415,10 @@ export class JavaLocatorFactory implements LocatorFactory {
} }
switch (kind) { switch (kind) {
case 'default': 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)})`; return `locator(${this.quote(body as string)})`;
case 'frame': case 'frame':
return `frameLocator(${this.quote(body as string)})`; 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 { generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
switch (kind) { switch (kind) {
case 'default': 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)})`; return `Locator(${this.quote(body as string)})`;
case 'frame': case 'frame':
return `FrameLocator(${this.quote(body as string)})`; return `FrameLocator(${this.quote(body as string)})`;

View File

@ -15,7 +15,7 @@
*/ */
import { escapeForAttributeSelector, escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; import { escapeForAttributeSelector, escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
import { asLocator } from './locatorGenerators'; import { asLocators } from './locatorGenerators';
import type { Language } from './locatorGenerators'; import type { Language } from './locatorGenerators';
import { parseSelector } from './selectorParser'; import { parseSelector } from './selectorParser';
@ -78,7 +78,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string {
.replace(/[{}\s]/g, '') .replace(/[{}\s]/g, '')
.replace(/new\(\)/g, '') .replace(/new\(\)/g, '')
.replace(/new[\w]+\.[\w]+options\(\)/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(/\.or_\(/g, 'or(') // Python has "or_" instead of "or".
.replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and". .replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and".
.replace(/:/g, '=') .replace(/:/g, '=')
@ -104,10 +104,10 @@ function shiftParams(template: string, sub: number) {
} }
function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
// Recursively handle filter(has=, hasnot=). // Recursively handle filter(has=, hasnot=, sethas(), sethasnot()).
// TODO: handle and(locator), or(locator). // TODO: handle and(locator), or(locator), locator(has=, hasnot=, sethas(), sethasnot()).
while (true) { while (true) {
const hasMatch = template.match(/filter\(,?(has|hasnot)=/); const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/);
if (!hasMatch) if (!hasMatch)
break; break;
@ -124,6 +124,15 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
break; 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 paramsCountBeforeHas = countParams(template.substring(0, start));
const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas); const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas);
const paramsCountInHas = countParams(hasTemplate); 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(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. // 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. // Replace inner params with $5 value.
const paramsBeforeHas = params.slice(0, paramsCountBeforeHas); const paramsBeforeHas = params.slice(0, paramsCountBeforeHas);
@ -142,7 +151,11 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
// Transform to selector engines. // Transform to selector engines.
template = template template = template
.replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase())
.replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') .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(/locator\(([^)]+)\)/g, '$1')
.replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1') .replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1')
.replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1') .replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1')
@ -203,7 +216,9 @@ export function locatorOrSelectorAsSelector(language: Language, locator: string,
} }
try { try {
const selector = parseLocator(locator, testIdAttributeName); 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; return selector;
} catch (e) { } catch (e) {
} }

View File

@ -15,7 +15,7 @@
*/ */
import { contextTest as it, expect } from '../config/browserTest'; 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 { locatorOrSelectorAsSelector as parseLocator } from '../../packages/playwright-core/lib/utils/isomorphic/locatorParser';
import type { Page, Frame, Locator, FrameLocator } from 'playwright-core'; 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')`); 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.describe(() => {
it.beforeEach(async ({ context }) => { it.beforeEach(async ({ context }) => {
await (context as any)._enableRecorder({ language: 'javascript' }); await (context as any)._enableRecorder({ language: 'javascript' });