mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
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:
parent
f469e4b1eb
commit
73f9f81db4
@ -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)})`;
|
||||||
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
@ -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' });
|
||||||
|
Loading…
x
Reference in New Issue
Block a user