From 77a601b58d764d1c99ceb1042c6d3637d8b64c80 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 23 Sep 2026 15:00:57 -0700 Subject: [PATCH 1/4] chore(locators): rewrite locator parser with per-language parsers Replace regex-based template rewriting with a tokenizer, a recursive descent parser per language producing language-neutral calls, and a shared selector builder. Also parses and(), or() and locator(locator). --- packages/isomorphic/locatorParser.ts | 730 ++++++++++++++++++------ tests/library/locator-generator.spec.ts | 100 ++++ 2 files changed, 642 insertions(+), 188 deletions(-) diff --git a/packages/isomorphic/locatorParser.ts b/packages/isomorphic/locatorParser.ts index 6f5c8ac31efd2..9dde730b1fe66 100644 --- a/packages/isomorphic/locatorParser.ts +++ b/packages/isomorphic/locatorParser.ts @@ -15,211 +15,565 @@ */ import { asLocators } from './locatorGenerators'; -import { encodeTestIdAttributeName } from './locatorUtils'; +import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from './locatorUtils'; import { parseSelector } from './selectorParser'; import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils'; import type { Language, Quote } from './locatorGenerators'; -type TemplateParams = { quote: string, text: string }[]; -function parseLocator(locator: string, testIdAttributeName: string): { selector: string, preferredQuote: Quote | undefined } { - locator = locator - .replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase()) - .replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`); - const params: TemplateParams = []; - let template = ''; - for (let i = 0; i < locator.length; ++i) { - const quote = locator[i]; - if (quote !== '"' && quote !== '\'' && quote !== '`' && quote !== '/') { - template += quote; - continue; - } - const isRegexEscaping = locator[i - 1] === 'r' || locator[i] === '/'; - ++i; +// Locator source code is parsed in two steps: +// 1. A language-specific parser turns the source into a language-neutral chain of calls, +// e.g. `get_by_role("button", name="Submit")` becomes { method: 'getByRole', args: ['button'], options: { name: 'Submit' } }. +// 2. SelectorBuilder turns the chain of calls into a selector. + +type Value = string | number | boolean | RegExp | LocatorCall[]; + +type LocatorCall = { + method: string; + args: Value[]; + options: Map; +}; + +type Token = + | { kind: 'identifier', value: string } + | { kind: 'string', value: string, quote: Quote } + | { kind: 'regex', value: RegExp } + | { kind: 'number', value: number } + | { kind: 'punctuation', value: string } + | { kind: 'end' }; + +type ParserOptions = { + quotes: string; + // Python r"..." strings. + rawStrings?: boolean; + // JavaScript /.../flags literals. + regexLiterals?: boolean; + booleans?: [string, string]; +}; + +const kEscapes = new Map([['n', '\n'], ['r', '\r'], ['t', '\t'], ['b', '\b'], ['f', '\f']]); +const kIdentifierRegex = /[A-Za-z_$][\w$]*/y; +const kNumberRegex = /-?\d+(\.\d+)?/y; +const kRegexFlagsRegex = /[a-z]*/y; + +function tokenize(source: string, options: ParserOptions): Token[] { + const tokens: Token[] = []; + let pos = 0; + + const match = (re: RegExp) => { + re.lastIndex = pos; + const result = re.exec(source)?.[0]; + if (result) + pos += result.length; + return result; + }; + + // In raw strings, backslash only escapes the closing quote and is preserved otherwise. + const readString = (quote: string, raw: boolean) => { let text = ''; - while (i < locator.length) { - if (locator[i] === '\\') { - if (isRegexEscaping) { - if (locator[i + 1] !== quote) - text += locator[i]; - ++i; - text += locator[i]; - } else { - ++i; - if (locator[i] === 'n') - text += '\n'; - else if (locator[i] === 'r') - text += '\r'; - else if (locator[i] === 't') - text += '\t'; - else - text += locator[i]; - } - ++i; + ++pos; + while (pos < source.length && source[pos] !== quote) { + if (source[pos] !== '\\') { + text += source[pos++]; continue; } - if (locator[i] !== quote) { - text += locator[i++]; - continue; + const next = source[pos + 1] ?? ''; + pos += 2; + if (raw) { + text += next === quote ? next : '\\' + next; + } else if (next === 'u') { + text += String.fromCharCode(parseInt(source.substring(pos, pos + 4), 16)); + pos += 4; + } else { + text += kEscapes.get(next) ?? next; } - break; } - params.push({ quote, text }); - template += (quote === '/' ? 'r' : '') + '$' + params.length; - } - - // Equalize languages. - template = template.toLowerCase() - .replace(/get_by_alt_text/g, 'getbyalttext') - .replace(/get_by_test_id/g, 'getbytestid') - .replace(/get_by_([\w]+)/g, 'getby$1') - .replace(/has_not_text/g, 'hasnottext') - .replace(/has_text/g, 'hastext') - .replace(/has_not/g, 'hasnot') - .replace(/frame_locator/g, 'framelocator') - .replace(/content_frame/g, 'contentframe') - .replace(/[{}\s]/g, '') - .replace(/new\(\)/g, '') - .replace(/new[\w]+\.[\w]+options\(\)/g, '') - .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, '=') - .replace(/,re\.ignorecase/g, 'i') - .replace(/,pattern.case_insensitive/g, 'i') - .replace(/,regexoptions.ignorecase/g, 'i') - .replace(/re.compile\(([^)]+)\)/g, '$1') // Python has regex strings as r"foo" - .replace(/pattern.compile\(([^)]+)\)/g, 'r$1') - .replace(/newregex\(([^)]+)\)/g, 'r$1') - .replace(/string=/g, '=') - .replace(/regex=/g, '=') - .replace(/,,/g, ',') - .replace(/,\)/g, ')'); - - const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0] as Quote | undefined; - return { selector: transform(template, params, testIdAttributeName), preferredQuote }; -} - -function countParams(template: string) { - return [...template.matchAll(/\$\d+/g)].length; -} - -function shiftParams(template: string, sub: number) { - return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`); -} - -function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { - // Recursively handle filter(has=, hasnot=, sethas(), sethasnot()). - // TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()). - while (true) { - const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/); - if (!hasMatch) - break; - - // Extract inner locator based on balanced parens. - const start = hasMatch.index! + hasMatch[0].length; - let balance = 0; - let end = start; - for (; end < template.length; end++) { - if (template[end] === '(') - balance++; - else if (template[end] === ')') - balance--; - if (balance < 0) + if (pos >= source.length) + throw new Error(`Unterminated string in ${source}`); + ++pos; + return text; + }; + + while (pos < source.length) { + const char = source[pos]; + let text: string | undefined; + if (/\s/.test(char)) { + ++pos; + } else if (options.quotes.includes(char)) { + tokens.push({ kind: 'string', value: readString(char, false), quote: char as Quote }); + } else if (options.rawStrings && char === 'r' && options.quotes.includes(source[pos + 1])) { + const quote = source[++pos] as Quote; + tokens.push({ kind: 'string', value: readString(quote, true), quote }); + } else if (options.regexLiterals && char === '/') { + const regexSource = readString('/', true); + tokens.push({ kind: 'regex', value: new RegExp(regexSource, match(kRegexFlagsRegex)) }); + } else if ('(){}.,:='.includes(char)) { + tokens.push({ kind: 'punctuation', value: char }); + ++pos; + } else if ((text = match(kIdentifierRegex))) { + tokens.push({ kind: 'identifier', value: text }); + } else if ((text = match(kNumberRegex))) { + tokens.push({ kind: 'number', value: +text }); + } else { + throw new Error(`Unexpected character "${char}" in ${source}`); + } + } + tokens.push({ kind: 'end' }); + return tokens; +} + +function isToken(token: Token, kind: 'identifier' | 'punctuation', value: string): boolean { + return token.kind === kind && token.value === value; +} + +abstract class LocatorParser { + private _tokens: Token[]; + private _pos = 0; + private _booleans: [string, string]; + + constructor(source: string, options: ParserOptions) { + this._tokens = tokenize(source, options); + this._booleans = options.booleans ?? ['true', 'false']; + } + + parse(): { calls: LocatorCall[], preferredQuote: Quote | undefined } { + const calls = this.parseChain(); + if (this.peek().kind !== 'end') + this.unexpected(); + const preferredQuote = this._tokens.find(token => token.kind === 'string')?.quote; + return { calls, preferredQuote }; + } + + // Methods are normalized to their JavaScript names, e.g. `get_by_role` => `getByRole`. + protected methodName(identifier: string): string { + return identifier; + } + + // Parses a single argument that is either a positional value or an options bag. + protected abstract parseArgument(call: LocatorCall): void; + + // Parses a language-specific regular expression, e.g. `re.compile(...)`. + protected parseRegex(): RegExp | undefined { + return undefined; + } + + protected parseChain(): LocatorCall[] { + const calls = [this.parseCall()]; + while (this.eat('.')) + calls.push(this.parseCall()); + return calls; + } + + private parseCall(): LocatorCall { + const call: LocatorCall = { method: this.methodName(this.expectIdentifier()), args: [], options: new Map() }; + if (this.eat('(') && !this.eat(')')) { + do + this.parseArgument(call); + while (this.eat(',')); + this.expect(')'); + } + return call; + } + + protected parseValue(): Value { + const token = this.peek(); + if (token.kind === 'string' || token.kind === 'number' || token.kind === 'regex') { + this.next(); + return token.value; + } + if (token.kind !== 'identifier') + this.unexpected(); + const [trueLiteral, falseLiteral] = this._booleans; + if (this.eatIdentifier(trueLiteral)) + return true; + if (this.eatIdentifier(falseLiteral)) + return false; + // Java and C# roles, e.g. `AriaRole.BUTTON`. + if (this.eatIdentifier('AriaRole')) { + this.expect('.'); + return this.expectIdentifier().toLowerCase(); + } + return this.parseRegex() ?? this.parseChain(); + } + + // Parses `{ name value, ... }` after the opening brace. + protected parseOptionsBag(call: LocatorCall, separator: string, optionName: (identifier: string) => string) { + while (!this.eat('}')) { + const name = optionName(this.expectIdentifier()); + this.expect(separator); + call.options.set(name, this.parseValue()); + if (!this.eat(',')) { + this.expect('}'); break; + } + } + } + + // Parses `(source[, .])`. + protected parseRegexArguments(flagsEnum: string, ignoreCaseFlag: string): RegExp { + this.expect('('); + const source = this.expectString(); + let flags = ''; + if (this.eat(',')) { + this.expectIdentifier(flagsEnum); + this.expect('.'); + this.expectIdentifier(ignoreCaseFlag); + flags = 'i'; + } + this.expect(')'); + return new RegExp(source, flags); + } + + protected peek(offset = 0): Token { + return this._tokens[Math.min(this._pos + offset, this._tokens.length - 1)]; + } + + protected next(): Token { + const token = this.peek(); + if (token.kind !== 'end') + ++this._pos; + return token; + } + + protected eat(punctuation: string): boolean { + if (!isToken(this.peek(), 'punctuation', punctuation)) + return false; + this.next(); + return true; + } + + protected eatIdentifier(identifier: string): boolean { + if (!isToken(this.peek(), 'identifier', identifier)) + return false; + this.next(); + return true; + } + + protected expect(punctuation: string) { + if (!this.eat(punctuation)) + this.unexpected(); + } + + protected expectIdentifier(identifier?: string): string { + const token = this.peek(); + if (token.kind !== 'identifier' || (identifier !== undefined && token.value !== identifier)) + this.unexpected(); + this.next(); + return token.value; + } + + protected expectString(): string { + const token = this.next(); + if (token.kind !== 'string') + this.unexpected(); + return token.value; + } + + protected unexpected(): never { + const token = this.peek(); + throw new Error(`Unexpected ${token.kind === 'end' ? 'end of input' : `token "${token.value}"`}`); + } +} + +// getByRole('button', { name: /submit/i, exact: true }) +class JavaScriptLocatorParser extends LocatorParser { + constructor(source: string) { + super(source, { quotes: '\'"`', regexLiterals: true }); + } + + protected parseArgument(call: LocatorCall) { + if (this.eat('{')) + this.parseOptionsBag(call, ':', name => name); + else + call.args.push(this.parseValue()); + } +} + +// get_by_role("button", name=re.compile(r"submit", re.IGNORECASE), exact=True) +class PythonLocatorParser extends LocatorParser { + constructor(source: string) { + super(source, { quotes: '\'"', rawStrings: true, booleans: ['True', 'False'] }); + } + + protected override methodName(identifier: string): string { + return snakeToCamelCase(identifier.replace(/_+$/, '')); + } + + protected parseArgument(call: LocatorCall) { + if (!isToken(this.peek(1), 'punctuation', '=')) { + call.args.push(this.parseValue()); + return; } + const name = this.expectIdentifier(); + this.expect('='); + call.options.set(snakeToCamelCase(name), this.parseValue()); + } - // 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='); + protected override parseRegex(): RegExp | undefined { + if (!this.eatIdentifier('re')) + return; + this.expect('.'); + this.expectIdentifier('compile'); + return this.parseRegexArguments('re', 'IGNORECASE'); + } +} + +// getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(Pattern.compile("submit", Pattern.CASE_INSENSITIVE)).setExact(true)) +class JavaLocatorParser extends LocatorParser { + constructor(source: string) { + super(source, { quotes: '"' }); + } + + protected parseArgument(call: LocatorCall) { + if (!this.eatIdentifier('new')) { + call.args.push(this.parseValue()); + return; + } + // Options class, e.g. `Locator.FilterOptions`. + this.expectIdentifier(); + this.expect('.'); + this.expectIdentifier(); + this.expect('('); + this.expect(')'); + while (this.eat('.')) { + const setter = this.expectIdentifier(); + if (!setter.startsWith('set')) + throw new Error(`Unexpected options method ${setter}`); + this.expect('('); + call.options.set(lowerFirst(setter.substring('set'.length)), this.parseValue()); + this.expect(')'); } + } + + protected override parseRegex(): RegExp | undefined { + if (!this.eatIdentifier('Pattern')) + return; + this.expect('.'); + this.expectIdentifier('compile'); + return this.parseRegexArguments('Pattern', 'CASE_INSENSITIVE'); + } +} + +// GetByRole(AriaRole.Button, new() { NameRegex = new Regex("submit", RegexOptions.IgnoreCase), Exact = true }) +class CSharpLocatorParser extends LocatorParser { + constructor(source: string) { + super(source, { quotes: '"' }); + } - const paramsCountBeforeHas = countParams(template.substring(0, start)); - const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas); - const paramsCountInHas = countParams(hasTemplate); - const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas); - const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, 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 = prefix.replace(/=$/, '2=') + `$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1); - - // Replace inner params with $5 value. - const paramsBeforeHas = params.slice(0, paramsCountBeforeHas); - const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas); - params = paramsBeforeHas.concat([{ quote: '"', text: hasSelector }]).concat(paramsAfterHas); - } - - // Transform to selector engines. - template = template - .replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) - .replace(/framelocator\(\)/g, 'internal:control=any-frame') - .replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') - .replace(/contentframe(\(\))?/g, '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') - .replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1') - .replace(/getbytestid\(([^)]+)\)/g, `internal:testid=[${encodeTestIdAttributeName(testIdAttributeName)}=$1]`) - .replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 'internal:attr=[$1=$2]') - .replace(/first(\(\))?/g, 'nth=0') - .replace(/last(\(\))?/g, 'nth=-1') - .replace(/nth\(([^)]+)\)/g, 'nth=$1') - .replace(/filter\(,?visible=true\)/g, 'visible=true') - .replace(/filter\(,?visible=false\)/g, 'visible=false') - .replace(/\.visible(\(\))?(?!=)/g, '.visible=true') - .replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1') - .replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1') - .replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1') - .replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1') - .replace(/,exact=false/g, '') - .replace(/(,name=\$\d+)(,description=\$\d+),exact=true/g, '$1s$2s') // exact=true is shared between name and description, so we need a special case for applying it to both. - .replace(/,exact=true/g, 's') - .replace(/,includehidden=/g, ',include-hidden=') - .replace(/\,/g, ']['); - - const parts = template.split('.'); - // Turn "internal:control=enter-frame >> nth=0" into "nth=0 >> internal:control=enter-frame" - // because these are swapped in locators vs selectors. - for (let index = 0; index < parts.length - 1; index++) { - if (parts[index] === 'internal:control=enter-frame' && parts[index + 1].startsWith('nth=')) { - // Swap nth and enter-frame. - const [nth] = parts.splice(index, 1); - parts.splice(index + 1, 0, nth); + protected override methodName(identifier: string): string { + return lowerFirst(identifier); + } + + protected parseArgument(call: LocatorCall) { + // `new() { ... }` is an options bag, while `new Regex(...)` is a value. + if (!isToken(this.peek(), 'identifier', 'new') || !isToken(this.peek(1), 'punctuation', '(')) { + call.args.push(this.parseValue()); + return; + } + this.next(); + this.expect('('); + this.expect(')'); + this.expect('{'); + // Regular expression options have a "Regex" suffix, e.g. `NameRegex`. + this.parseOptionsBag(call, '=', name => lowerFirst(name.replace(/Regex$/, ''))); + } + + protected override parseRegex(): RegExp | undefined { + if (!this.eatIdentifier('new')) + return; + this.expectIdentifier('Regex'); + return this.parseRegexArguments('RegexOptions', 'IgnoreCase'); + } +} + +const parsers: Partial LocatorParser>> = { + javascript: JavaScriptLocatorParser, + python: PythonLocatorParser, + java: JavaLocatorParser, + csharp: CSharpLocatorParser, +}; + +const kFilterOptions = ['hasText', 'hasNotText', 'has', 'hasNot', 'visible']; +const kFrameLocatorNthMethods = new Set(['first', 'last', 'nth']); +const kTextSelectors: Record string> = { + getByAltText: getByAltTextSelector, + getByLabel: getByLabelSelector, + getByPlaceholder: getByPlaceholderSelector, + getByText: getByTextSelector, + getByTitle: getByTitleSelector, +}; + +class SelectorBuilder { + private _testIdAttributeName: string; + + constructor(testIdAttributeName: string) { + this._testIdAttributeName = testIdAttributeName; + } + + build(calls: LocatorCall[]): string { + const parts: string[] = []; + // FrameLocator enters the frame lazily, so that first(), last() and nth() apply to the frame element. + let inFrameLocator = false; + for (const call of calls) { + if (inFrameLocator && !kFrameLocatorNthMethods.has(call.method)) { + parts.push('internal:control=enter-frame'); + inFrameLocator = false; + } + if (call.method === 'contentFrame') { + checkArguments(call, 0, []); + inFrameLocator = true; + continue; + } + parts.push(...this._callToSelectorParts(call)); + if (call.method === 'frameLocator' && call.args.length) + inFrameLocator = true; } + if (inFrameLocator) + parts.push('internal:control=enter-frame'); + return parts.join(' >> '); } - // Substitute params. - return parts.map(t => { - if (!t.startsWith('internal:') || t === 'internal:control') - return t.replace(/\$(\d+)/g, (_, ordinal) => { const param = params[+ordinal - 1]; return param.text; }); - t = t.includes('[') ? t.replace(/\]/, '') + ']' : t; - t = t - .replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => { - const param = params[+ordinal - 1]; - if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role')) - return escapeForAttributeSelector(new RegExp(param.text), false) + (suffix || ''); - return escapeForTextSelector(new RegExp(param.text, suffix), false); - }) - .replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => { - const param = params[+ordinal - 1]; - if (t.startsWith('internal:has=') || t.startsWith('internal:has-not=')) - return param.text; - if (t.startsWith('internal:testid')) - return escapeForAttributeSelector(param.text, true); - if (t.startsWith('internal:attr') || t.startsWith('internal:role')) - return escapeForAttributeSelector(param.text, suffix === 's'); - return escapeForTextSelector(param.text, suffix === 's'); - }); - return t; - }).join(' >> '); + private _callToSelectorParts(call: LocatorCall): string[] { + const { method } = call; + switch (method) { + case 'locator': { + checkArguments(call, 1, kFilterOptions); + const inner = arg(call, 0, (value): value is string | LocatorCall[] => isString(value) || isLocator(value)); + const selector = isString(inner) ? inner : 'internal:chain=' + JSON.stringify(this.build(inner)); + return [selector, ...this._filters(call)]; + } + case 'filter': + checkArguments(call, 0, kFilterOptions); + return this._filters(call); + case 'and': + case 'or': + checkArguments(call, 1, []); + return [`internal:${method}=` + JSON.stringify(this.build(arg(call, 0, isLocator)))]; + case 'frameLocator': + checkArguments(call, 1, []); + return [call.args.length ? arg(call, 0, isString) : 'internal:control=any-frame']; + case 'first': + checkArguments(call, 0, []); + return ['nth=0']; + case 'last': + checkArguments(call, 0, []); + return ['nth=-1']; + case 'nth': + checkArguments(call, 1, []); + return [`nth=${arg(call, 0, isNumber)}`]; + case 'visible': + checkArguments(call, 0, []); + return ['visible=true']; + case 'getByRole': + return [this._role(call)]; + case 'getByTestId': + checkArguments(call, 1, []); + return [getByTestIdSelector(this._testIdAttributeName, arg(call, 0, isText))]; + case 'getByAltText': + case 'getByLabel': + case 'getByPlaceholder': + case 'getByText': + case 'getByTitle': + checkArguments(call, 1, ['exact']); + return [kTextSelectors[method](arg(call, 0, isText), { exact: option(call, 'exact', isBoolean) })]; + } + throw new Error(`Unsupported locator method ${method}`); + } + + private _filters(call: LocatorCall): string[] { + const parts: string[] = []; + const hasText = option(call, 'hasText', isText); + if (hasText !== undefined) + parts.push(`internal:has-text=${escapeForTextSelector(hasText, false)}`); + const hasNotText = option(call, 'hasNotText', isText); + if (hasNotText !== undefined) + parts.push(`internal:has-not-text=${escapeForTextSelector(hasNotText, false)}`); + const has = option(call, 'has', isLocator); + if (has) + parts.push(`internal:has=` + JSON.stringify(this.build(has))); + const hasNot = option(call, 'hasNot', isLocator); + if (hasNot) + parts.push(`internal:has-not=` + JSON.stringify(this.build(hasNot))); + const visible = option(call, 'visible', isBoolean); + if (visible !== undefined) + parts.push(`visible=${visible}`); + return parts; + } + + private _role(call: LocatorCall): string { + checkArguments(call, 1, ['name', 'description', 'exact', 'checked', 'disabled', 'expanded', 'includeHidden', 'level', 'pressed', 'selected']); + const exact = !!option(call, 'exact', isBoolean); + let selector = `internal:role=${arg(call, 0, isString)}`; + // Keep the attributes in the source order, so that the selector renders back into the same locator. + for (const name of call.options.keys()) { + if (name === 'name' || name === 'description') + selector += `[${name}=${escapeForAttributeSelector(option(call, name, isText)!, exact)}]`; + else if (name === 'level') + selector += `[level=${option(call, name, isNumber)}]`; + else if (name !== 'exact') + selector += `[${name === 'includeHidden' ? 'include-hidden' : name}=${option(call, name, isBoolean)}]`; + } + return selector; + } +} + +function checkArguments(call: LocatorCall, maxArgs: number, allowedOptions: string[]) { + if (call.args.length > maxArgs) + throw new Error(`Too many arguments for ${call.method}`); + for (const name of call.options.keys()) { + if (!allowedOptions.includes(name)) + throw new Error(`Unsupported option ${name} for ${call.method}`); + } +} + +function arg(call: LocatorCall, index: number, is: (value: Value) => value is T): T { + const value = call.args[index]; + if (value === undefined || !is(value)) + throw new Error(`Unexpected argument #${index} for ${call.method}`); + return value; +} + +function option(call: LocatorCall, name: string, is: (value: Value) => value is T): T | undefined { + const value = call.options.get(name); + if (value !== undefined && !is(value)) + throw new Error(`Unexpected ${name} option for ${call.method}`); + return value; +} + +function isString(value: Value): value is string { + return typeof value === 'string'; +} + +function isText(value: Value): value is string | RegExp { + return typeof value === 'string' || value instanceof RegExp; +} + +function isNumber(value: Value): value is number { + return typeof value === 'number'; +} + +function isBoolean(value: Value): value is boolean { + return typeof value === 'boolean'; +} + +function isLocator(value: Value): value is LocatorCall[] { + return Array.isArray(value); +} + +function snakeToCamelCase(identifier: string): string { + return identifier.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); +} + +function lowerFirst(identifier: string): string { + return identifier.charAt(0).toLowerCase() + identifier.substring(1); +} + +function parseLocator(language: Language, locator: string, testIdAttributeName: string): { selector: string, preferredQuote: Quote | undefined } { + const Parser = parsers[language]; + if (!Parser) + throw new Error(`Parsing ${language} locators is not supported`); + const { calls, preferredQuote } = new Parser(locator).parse(); + return { selector: new SelectorBuilder(testIdAttributeName).build(calls), preferredQuote }; } export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string = 'data-testid'): string { @@ -236,7 +590,7 @@ export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: s return locator; } catch (e) { } - const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); + const { selector, preferredQuote } = parseLocator(language, locator, testIdAttributeName); const locators = asLocators(language, selector, undefined, undefined, preferredQuote); const digest = digestForComparison(language, locator); if (locators.some(candidate => digestForComparison(language, candidate) === digest)) diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 4476a8c26578b..1163f8fb6bf62 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -611,6 +611,14 @@ it('asLocator internal:chain', async () => { expect.soft(asLocator('csharp', 'div >> internal:chain="span >> article"')).toBe(`Locator("div").Locator(Locator("span").Locator("article"))`); }); +it('parseLocator and, or, chain', async () => { + for (const kind of ['and', 'or', 'chain']) { + const selector = `div >> internal:${kind}="span >> article"`; + for (const lang of ['javascript', 'python', 'java', 'csharp'] as const) + expect.soft(parseLocator(lang, asLocator(lang, selector), 'data-testid'), lang).toBe(selector); + } +}); + it('asLocator xpath', async () => { const selector = `//*[contains(normalizer-text(), 'foo']`; expect.soft(asLocator('javascript', selector)).toBe(`locator('//*[contains(normalizer-text(), \\'foo\\']')`); @@ -665,6 +673,90 @@ it('parseLocator options', async () => { expect.soft(parseLocator('javascript', `getByRole('checkbox', { checked:false, includeHidden: true })`, '')).toBe(`internal:role=checkbox[checked=false][include-hidden=true]`); }); +it('parseLocator role options', async () => { + expect.soft(parseLocator('python', `get_by_role("checkbox", checked=True, include_hidden=True)`, '')).toBe(`internal:role=checkbox[checked=true][include-hidden=true]`); + expect.soft(parseLocator('javascript', `getByRole('button', { nme: 'foo' })`, '')).toBe(``); +}); + +it('parseLocator round-trips selectors', async () => { + const selectors = [ + 'div', + '.foo', + '//div', + 'internal:text="hello"i', + 'internal:text="hello"s', + 'internal:text=/he\\"llo/i', + 'internal:text=/a\\/b/', + 'internal:text="a\'b\\"c`d"i', + 'internal:text="a\\\\b"i', + 'internal:text="tab\\there\\nnewline"i', + 'internal:text="\\u0001"i', + 'internal:label="x"s', + 'internal:label=/x/', + 'internal:attr=[placeholder="p"i]', + 'internal:attr=[alt="a"s]', + 'internal:attr=[title=/t/i]', + 'internal:testid=[data-testid="id"s]', + 'internal:testid=[data-testid=/id/]', + 'internal:role=button', + 'internal:role=button[name="ok"s]', + 'internal:role=button[name=/ok/i]', + 'internal:role=checkbox[checked=true][include-hidden=true]', + 'internal:role=heading[name="h"s][level=2]', + 'internal:role=alert[name="Upload"s][description="doc.pdf"s]', + 'internal:role=alert[name=/U/][description="d"i]', + 'internal:role=button[pressed=false][disabled=true][expanded=true][selected=true]', + 'div >> nth=0', + 'div >> nth=-1', + 'div >> nth=3', + 'div >> visible=true', + 'div >> visible=false', + 'div >> internal:has-text="foo"i >> span', + 'div >> internal:has-not-text=/foo/', + 'div >> internal:has="span >> internal:has=\\"b\\""', + 'div >> internal:has-not="internal:role=button[name=\\"x\\"i]"', + 'iframe >> internal:control=enter-frame >> div', + 'iframe >> nth=0 >> internal:control=enter-frame >> div', + 'internal:attr=[title="t"i] >> internal:control=enter-frame', + 'div >> internal:and="span >> nth=0"', + 'div >> internal:or="span"', + 'div >> internal:chain="span >> internal:has-text=\\"x\\"i"', + ]; + for (const selector of selectors) { + for (const lang of ['javascript', 'python', 'java', 'csharp'] as const) { + const [canonical, ...alternatives] = asLocators(lang, selector); + expect.soft(parseLocator(lang, canonical, 'data-testid'), `${lang}: ${canonical}`).toBe(selector); + // Alternatives may parse into an equivalent selector, e.g. `css=div` instead of `div`. + for (const locator of alternatives) + expect.soft(asLocator(lang, parseLocator(lang, locator, 'data-testid')), `${lang}: ${locator}`).toBe(canonical); + } + } +}); + +it('parseLocator rejects malformed locators', async () => { + const locators = [ + ['javascript', `locator('div').nth(0))`], + ['javascript', `locator('div').nth(-)`], + ['javascript', `locator('div')..locator('span')`], + ['javascript', `getByRole('button', { nme: 'ok' })`], + ['javascript', `getByRole('button', { name: 'ok', exct: true })`], + ['javascript', `getByText('foo', { exact: 'true' })`], + ['javascript', `locator('div').filter({ hasText: 'foo' }})`], + ['python', `get_by_role("checkbox", cheked=True)`], + ['python', `locator("div").filter(has_text=="foo")`], + ['python', `locator("div").nth(0))`], + ['java', `locator("div").filter(newLocator.FilterOptions().setHasText("foo"))`], + ['java', `getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setNme("ok"))`], + ['java', `locator('div')`], + ['csharp', `GetByTestId(newRegex("id"))`], + ['csharp', `GetByRole(AriaRole.Button, new() { Nme = "ok" })`], + ['csharp', `Locator('div')`], + ['csharp', `Locator("div").Nth(0))`], + ] as const; + for (const [lang, locator] of locators) + expect.soft(parseLocator(lang, locator, 'data-testid'), `${lang}: ${locator}`).toBe(''); +}); + it('parse locators strictly', () => { const selector = 'div >> internal:has-text=\"Goodbye world\"i >> span'; @@ -689,6 +781,7 @@ it('parse locators strictly', () => { expect.soft(parseLocator('java', `locator("div").filter(new Locator.FilterOptions().setHasText("Goodbye world"))..locator("span")`)).not.toBe(selector); expect.soft(parseLocator('javascript', `locator('div').filter({ hasText: 'Goodbye world' }}).locator('span')`)).not.toBe(selector); expect.soft(parseLocator('python', `locator("div").filter(has_text=="Goodbye world").locator("span")`)).not.toBe(selector); + expect.soft(parseLocator('javascript', `locator('div').nth(0))`)).toBe(''); }); it('parseLocator frames', async () => { @@ -697,6 +790,13 @@ it('parseLocator frames', async () => { expect.soft(parseLocator('javascript', `frameLocator('css=iframe').getByText('foo')`, '')).toBe(`css=iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('javascript', `getByTitle('iframe title').contentFrame()`)).toBe(`internal:attr=[title=\"iframe title\"i] >> internal:control=enter-frame`); + // FrameLocator.first() and nth() pick the frame element, so there is no locator for nth inside a frame. + const nthInsideFrame = 'iframe >> internal:control=enter-frame >> nth=1'; + for (const lang of ['javascript', 'python', 'java', 'csharp'] as const) { + for (const locator of asLocators(lang, nthInsideFrame)) + expect.soft(parseLocator(lang, locator), `${lang}: ${locator}`).not.toBe(nthInsideFrame); + } + expect.soft(asLocators('javascript', 'internal:attr=[title=\"iframe title\"i] >> internal:control=enter-frame')).toEqual([`getByTitle('iframe title').contentFrame()`]); expect.soft(parseLocator('python', `locator("iframe").content_frame.get_by_text("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); From 047ebb589a19ddbceb56f6747fc5e22c6649b8ed Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 23 Sep 2026 16:40:57 -0700 Subject: [PATCH 2/4] chore(locators): parse locators straight into ParsedSelector Map each language's method names to call handlers that produce selector parts, and pass the ParsedSelector to asLocators directly. Support multiple regex flags in Python, Java and C# locators. --- packages/isomorphic/locatorGenerators.ts | 26 +- packages/isomorphic/locatorParser.ts | 462 +++++++++++++---------- tests/library/locator-generator.spec.ts | 40 +- 3 files changed, 314 insertions(+), 214 deletions(-) diff --git a/packages/isomorphic/locatorGenerators.ts b/packages/isomorphic/locatorGenerators.ts index 4aec1339aafda..a9e0f10c298fe 100644 --- a/packages/isomorphic/locatorGenerators.ts +++ b/packages/isomorphic/locatorGenerators.ts @@ -74,12 +74,13 @@ export function asLocator(lang: Language, selector: string, isFrameLocator: bool return asLocators(lang, selector, isFrameLocator, 1)[0]; } -export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { +export function asLocators(lang: Language, selector: string | ParsedSelector, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { try { - return innerAsLocators(new generators[lang](preferredQuote), parseSelector(selector), isFrameLocator, maxOutputSize); + const parsed = typeof selector === 'string' ? parseSelector(selector) : selector; + return innerAsLocators(new generators[lang](preferredQuote), parsed, isFrameLocator, maxOutputSize); } catch (e) { // Tolerate invalid input. - return [selector]; + return [stringifySelector(selector)]; } } @@ -493,7 +494,7 @@ export class PythonLocatorFactory implements LocatorFactory { } private regexToString(body: RegExp) { - const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : ''; + const suffix = regexFlagsSuffix(body, 're', regexFlagNames.python); return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`; } @@ -605,7 +606,7 @@ export class JavaLocatorFactory implements LocatorFactory { } private regexToString(body: RegExp) { - const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : ''; + const suffix = regexFlagsSuffix(body, 'Pattern', regexFlagNames.java); return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; } @@ -711,7 +712,7 @@ export class CSharpLocatorFactory implements LocatorFactory { } private regexToString(body: RegExp): string { - const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : ''; + const suffix = regexFlagsSuffix(body, 'RegexOptions', regexFlagNames.csharp); return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; } @@ -776,6 +777,19 @@ const generators: Record LocatorFactor jsonl: JsonlLocatorFactory, }; +// Regular expression flags supported by the Python, Java and C# locators. +export const regexFlagNames = { + python: { i: 'IGNORECASE', m: 'MULTILINE', s: 'DOTALL' }, + java: { i: 'CASE_INSENSITIVE', m: 'MULTILINE', s: 'DOTALL' }, + csharp: { i: 'IgnoreCase', m: 'Multiline', s: 'Singleline' }, +}; + +// E.g. `, re.IGNORECASE | re.MULTILINE`. +function regexFlagsSuffix(body: RegExp, flagsEnum: string, names: Record): string { + const flags = [...body.flags].filter(flag => flag in names).map(flag => `${flagsEnum}.${names[flag]}`); + return flags.length ? `, ${flags.join(' | ')}` : ''; +} + function isRegExp(obj: any): obj is RegExp { return obj instanceof RegExp; } diff --git a/packages/isomorphic/locatorParser.ts b/packages/isomorphic/locatorParser.ts index 9dde730b1fe66..287e6ffe7b6ae 100644 --- a/packages/isomorphic/locatorParser.ts +++ b/packages/isomorphic/locatorParser.ts @@ -14,26 +14,28 @@ * limitations under the License. */ -import { asLocators } from './locatorGenerators'; +import { asLocators, regexFlagNames } from './locatorGenerators'; import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from './locatorUtils'; -import { parseSelector } from './selectorParser'; +import { parseSelector, stringifySelector } from './selectorParser'; import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils'; import type { Language, Quote } from './locatorGenerators'; +import type { ParsedSelector, ParsedSelectorPart } from './selectorParser'; -// Locator source code is parsed in two steps: -// 1. A language-specific parser turns the source into a language-neutral chain of calls, -// e.g. `get_by_role("button", name="Submit")` becomes { method: 'getByRole', args: ['button'], options: { name: 'Submit' } }. -// 2. SelectorBuilder turns the chain of calls into a selector. +// Locator source code is parsed by a language-specific parser straight into a ParsedSelector. +// Each language maps its method names to call handlers that turn call arguments into selector parts. -type Value = string | number | boolean | RegExp | LocatorCall[]; +type Value = string | number | boolean | RegExp | ParsedSelector; -type LocatorCall = { +// Arguments of a single call, collected until the call is converted into selector parts. +type CallArguments = { method: string; args: Value[]; options: Map; }; +type CallHandler = (call: CallArguments, testIdAttributeName: string) => ParsedSelectorPart[]; + type Token = | { kind: 'identifier', value: string } | { kind: 'string', value: string, quote: Quote } @@ -43,6 +45,7 @@ type Token = | { kind: 'end' }; type ParserOptions = { + methods: Map; quotes: string; // Python r"..." strings. rawStrings?: boolean; @@ -107,7 +110,7 @@ function tokenize(source: string, options: ParserOptions): Token[] { } else if (options.regexLiterals && char === '/') { const regexSource = readString('/', true); tokens.push({ kind: 'regex', value: new RegExp(regexSource, match(kRegexFlagsRegex)) }); - } else if ('(){}.,:='.includes(char)) { + } else if ('(){}.,:=|'.includes(char)) { tokens.push({ kind: 'punctuation', value: char }); ++pos; } else if ((text = match(kIdentifierRegex))) { @@ -129,50 +132,68 @@ function isToken(token: Token, kind: 'identifier' | 'punctuation', value: string abstract class LocatorParser { private _tokens: Token[]; private _pos = 0; + private _methods: Map; private _booleans: [string, string]; + private _testIdAttributeName: string; - constructor(source: string, options: ParserOptions) { + constructor(source: string, options: ParserOptions, testIdAttributeName: string) { this._tokens = tokenize(source, options); + this._methods = options.methods; this._booleans = options.booleans ?? ['true', 'false']; + this._testIdAttributeName = testIdAttributeName; } - parse(): { calls: LocatorCall[], preferredQuote: Quote | undefined } { - const calls = this.parseChain(); + parse(): { selector: ParsedSelector, preferredQuote: Quote | undefined } { + const selector = this.parseLocator(); if (this.peek().kind !== 'end') this.unexpected(); + // Same as in parseSelector(), nested selectors like internal:chain cannot be first. + if (selector.parts.length && isNestedSelectorPart(selector.parts[0])) + throw new Error(`"${selector.parts[0].name}" selector cannot be first`); const preferredQuote = this._tokens.find(token => token.kind === 'string')?.quote; - return { calls, preferredQuote }; - } - - // Methods are normalized to their JavaScript names, e.g. `get_by_role` => `getByRole`. - protected methodName(identifier: string): string { - return identifier; + return { selector, preferredQuote }; } // Parses a single argument that is either a positional value or an options bag. - protected abstract parseArgument(call: LocatorCall): void; + protected abstract parseArgument(call: CallArguments): void; // Parses a language-specific regular expression, e.g. `re.compile(...)`. protected parseRegex(): RegExp | undefined { return undefined; } - protected parseChain(): LocatorCall[] { - const calls = [this.parseCall()]; - while (this.eat('.')) - calls.push(this.parseCall()); - return calls; + protected parseLocator(): ParsedSelector { + const parts: ParsedSelectorPart[] = []; + // FrameLocator enters the frame lazily, so that first(), last() and nth() apply to the frame element. + let inFrameLocator = false; + do { + const { handler, call } = this.parseCall(); + if (inFrameLocator && !kFrameElementHandlers.has(handler)) { + parts.push(selectorPart('internal:control', 'enter-frame')); + inFrameLocator = false; + } + parts.push(...handler(call, this._testIdAttributeName)); + if (handler === contentFrameParts || (handler === frameLocatorParts && call.args.length)) + inFrameLocator = true; + } while (this.eat('.')); + if (inFrameLocator) + parts.push(selectorPart('internal:control', 'enter-frame')); + return { parts }; } - private parseCall(): LocatorCall { - const call: LocatorCall = { method: this.methodName(this.expectIdentifier()), args: [], options: new Map() }; + private parseCall(): { handler: CallHandler, call: CallArguments } { + const method = this.expectIdentifier(); + const handler = this._methods.get(method); + if (!handler) + throw new Error(`Unsupported locator method ${method}`); + const call: CallArguments = { method, args: [], options: new Map() }; if (this.eat('(') && !this.eat(')')) { do this.parseArgument(call); while (this.eat(',')); this.expect(')'); } - return call; + return { handler, call }; } protected parseValue(): Value { @@ -193,11 +214,11 @@ abstract class LocatorParser { this.expect('.'); return this.expectIdentifier().toLowerCase(); } - return this.parseRegex() ?? this.parseChain(); + return this.parseRegex() ?? this.parseLocator(); } // Parses `{ name value, ... }` after the opening brace. - protected parseOptionsBag(call: LocatorCall, separator: string, optionName: (identifier: string) => string) { + protected parseOptionsBag(call: CallArguments, separator: string, optionName: (identifier: string) => string) { while (!this.eat('}')) { const name = optionName(this.expectIdentifier()); this.expect(separator); @@ -209,16 +230,21 @@ abstract class LocatorParser { } } - // Parses `(source[, .])`. - protected parseRegexArguments(flagsEnum: string, ignoreCaseFlag: string): RegExp { + // Parses `(source[, . ( "|" . )*])`. + protected parseRegexArguments(flagsEnum: string, flagNames: Record): RegExp { this.expect('('); const source = this.expectString(); let flags = ''; if (this.eat(',')) { - this.expectIdentifier(flagsEnum); - this.expect('.'); - this.expectIdentifier(ignoreCaseFlag); - flags = 'i'; + do { + this.expectIdentifier(flagsEnum); + this.expect('.'); + const name = this.expectIdentifier(); + const flag = Object.keys(flagNames).find(flag => flagNames[flag] === name); + if (!flag) + throw new Error(`Unsupported regular expression flag ${flagsEnum}.${name}`); + flags += flag; + } while (this.eat('|')); } this.expect(')'); return new RegExp(source, flags); @@ -277,11 +303,11 @@ abstract class LocatorParser { // getByRole('button', { name: /submit/i, exact: true }) class JavaScriptLocatorParser extends LocatorParser { - constructor(source: string) { - super(source, { quotes: '\'"`', regexLiterals: true }); + constructor(source: string, testIdAttributeName: string) { + super(source, { methods: kJavaScriptMethods, quotes: '\'"`', regexLiterals: true }, testIdAttributeName); } - protected parseArgument(call: LocatorCall) { + protected parseArgument(call: CallArguments) { if (this.eat('{')) this.parseOptionsBag(call, ':', name => name); else @@ -291,22 +317,18 @@ class JavaScriptLocatorParser extends LocatorParser { // get_by_role("button", name=re.compile(r"submit", re.IGNORECASE), exact=True) class PythonLocatorParser extends LocatorParser { - constructor(source: string) { - super(source, { quotes: '\'"', rawStrings: true, booleans: ['True', 'False'] }); + constructor(source: string, testIdAttributeName: string) { + super(source, { methods: kPythonMethods, quotes: '\'"', rawStrings: true, booleans: ['True', 'False'] }, testIdAttributeName); } - protected override methodName(identifier: string): string { - return snakeToCamelCase(identifier.replace(/_+$/, '')); - } - - protected parseArgument(call: LocatorCall) { + protected parseArgument(call: CallArguments) { if (!isToken(this.peek(1), 'punctuation', '=')) { call.args.push(this.parseValue()); return; } const name = this.expectIdentifier(); this.expect('='); - call.options.set(snakeToCamelCase(name), this.parseValue()); + call.options.set(name.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), this.parseValue()); } protected override parseRegex(): RegExp | undefined { @@ -314,17 +336,17 @@ class PythonLocatorParser extends LocatorParser { return; this.expect('.'); this.expectIdentifier('compile'); - return this.parseRegexArguments('re', 'IGNORECASE'); + return this.parseRegexArguments('re', regexFlagNames.python); } } // getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(Pattern.compile("submit", Pattern.CASE_INSENSITIVE)).setExact(true)) class JavaLocatorParser extends LocatorParser { - constructor(source: string) { - super(source, { quotes: '"' }); + constructor(source: string, testIdAttributeName: string) { + super(source, { methods: kJavaScriptMethods, quotes: '"' }, testIdAttributeName); } - protected parseArgument(call: LocatorCall) { + protected parseArgument(call: CallArguments) { if (!this.eatIdentifier('new')) { call.args.push(this.parseValue()); return; @@ -350,21 +372,17 @@ class JavaLocatorParser extends LocatorParser { return; this.expect('.'); this.expectIdentifier('compile'); - return this.parseRegexArguments('Pattern', 'CASE_INSENSITIVE'); + return this.parseRegexArguments('Pattern', regexFlagNames.java); } } // GetByRole(AriaRole.Button, new() { NameRegex = new Regex("submit", RegexOptions.IgnoreCase), Exact = true }) class CSharpLocatorParser extends LocatorParser { - constructor(source: string) { - super(source, { quotes: '"' }); + constructor(source: string, testIdAttributeName: string) { + super(source, { methods: kCSharpMethods, quotes: '"' }, testIdAttributeName); } - protected override methodName(identifier: string): string { - return lowerFirst(identifier); - } - - protected parseArgument(call: LocatorCall) { + protected parseArgument(call: CallArguments) { // `new() { ... }` is an options bag, while `new Regex(...)` is a value. if (!isToken(this.peek(), 'identifier', 'new') || !isToken(this.peek(1), 'punctuation', '(')) { call.args.push(this.parseValue()); @@ -382,11 +400,11 @@ class CSharpLocatorParser extends LocatorParser { if (!this.eatIdentifier('new')) return; this.expectIdentifier('Regex'); - return this.parseRegexArguments('RegexOptions', 'IgnoreCase'); + return this.parseRegexArguments('RegexOptions', regexFlagNames.csharp); } } -const parsers: Partial LocatorParser>> = { +const parsers: Partial LocatorParser>> = { javascript: JavaScriptLocatorParser, python: PythonLocatorParser, java: JavaLocatorParser, @@ -394,130 +412,189 @@ const parsers: Partial LocatorParser>> }; const kFilterOptions = ['hasText', 'hasNotText', 'has', 'hasNot', 'visible']; -const kFrameLocatorNthMethods = new Set(['first', 'last', 'nth']); -const kTextSelectors: Record string> = { - getByAltText: getByAltTextSelector, - getByLabel: getByLabelSelector, - getByPlaceholder: getByPlaceholderSelector, - getByText: getByTextSelector, - getByTitle: getByTitleSelector, -}; -class SelectorBuilder { - private _testIdAttributeName: string; +function locatorParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 1, kFilterOptions); + const inner = arg(call, 0, (value): value is string | ParsedSelector => isString(value) || isSelector(value)); + const parts = isString(inner) ? parseSelector(inner).parts : [nestedSelectorPart('internal:chain', inner)]; + return [...parts, ...filterSelectorParts(call)]; +} - constructor(testIdAttributeName: string) { - this._testIdAttributeName = testIdAttributeName; - } +function filterParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 0, kFilterOptions); + return filterSelectorParts(call); +} - build(calls: LocatorCall[]): string { - const parts: string[] = []; - // FrameLocator enters the frame lazily, so that first(), last() and nth() apply to the frame element. - let inFrameLocator = false; - for (const call of calls) { - if (inFrameLocator && !kFrameLocatorNthMethods.has(call.method)) { - parts.push('internal:control=enter-frame'); - inFrameLocator = false; - } - if (call.method === 'contentFrame') { - checkArguments(call, 0, []); - inFrameLocator = true; - continue; - } - parts.push(...this._callToSelectorParts(call)); - if (call.method === 'frameLocator' && call.args.length) - inFrameLocator = true; - } - if (inFrameLocator) - parts.push('internal:control=enter-frame'); - return parts.join(' >> '); - } - - private _callToSelectorParts(call: LocatorCall): string[] { - const { method } = call; - switch (method) { - case 'locator': { - checkArguments(call, 1, kFilterOptions); - const inner = arg(call, 0, (value): value is string | LocatorCall[] => isString(value) || isLocator(value)); - const selector = isString(inner) ? inner : 'internal:chain=' + JSON.stringify(this.build(inner)); - return [selector, ...this._filters(call)]; - } - case 'filter': - checkArguments(call, 0, kFilterOptions); - return this._filters(call); - case 'and': - case 'or': - checkArguments(call, 1, []); - return [`internal:${method}=` + JSON.stringify(this.build(arg(call, 0, isLocator)))]; - case 'frameLocator': - checkArguments(call, 1, []); - return [call.args.length ? arg(call, 0, isString) : 'internal:control=any-frame']; - case 'first': - checkArguments(call, 0, []); - return ['nth=0']; - case 'last': - checkArguments(call, 0, []); - return ['nth=-1']; - case 'nth': - checkArguments(call, 1, []); - return [`nth=${arg(call, 0, isNumber)}`]; - case 'visible': - checkArguments(call, 0, []); - return ['visible=true']; - case 'getByRole': - return [this._role(call)]; - case 'getByTestId': - checkArguments(call, 1, []); - return [getByTestIdSelector(this._testIdAttributeName, arg(call, 0, isText))]; - case 'getByAltText': - case 'getByLabel': - case 'getByPlaceholder': - case 'getByText': - case 'getByTitle': - checkArguments(call, 1, ['exact']); - return [kTextSelectors[method](arg(call, 0, isText), { exact: option(call, 'exact', isBoolean) })]; - } - throw new Error(`Unsupported locator method ${method}`); - } - - private _filters(call: LocatorCall): string[] { - const parts: string[] = []; - const hasText = option(call, 'hasText', isText); - if (hasText !== undefined) - parts.push(`internal:has-text=${escapeForTextSelector(hasText, false)}`); - const hasNotText = option(call, 'hasNotText', isText); - if (hasNotText !== undefined) - parts.push(`internal:has-not-text=${escapeForTextSelector(hasNotText, false)}`); - const has = option(call, 'has', isLocator); - if (has) - parts.push(`internal:has=` + JSON.stringify(this.build(has))); - const hasNot = option(call, 'hasNot', isLocator); - if (hasNot) - parts.push(`internal:has-not=` + JSON.stringify(this.build(hasNot))); - const visible = option(call, 'visible', isBoolean); - if (visible !== undefined) - parts.push(`visible=${visible}`); - return parts; - } - - private _role(call: LocatorCall): string { - checkArguments(call, 1, ['name', 'description', 'exact', 'checked', 'disabled', 'expanded', 'includeHidden', 'level', 'pressed', 'selected']); - const exact = !!option(call, 'exact', isBoolean); - let selector = `internal:role=${arg(call, 0, isString)}`; - // Keep the attributes in the source order, so that the selector renders back into the same locator. - for (const name of call.options.keys()) { - if (name === 'name' || name === 'description') - selector += `[${name}=${escapeForAttributeSelector(option(call, name, isText)!, exact)}]`; - else if (name === 'level') - selector += `[level=${option(call, name, isNumber)}]`; - else if (name !== 'exact') - selector += `[${name === 'includeHidden' ? 'include-hidden' : name}=${option(call, name, isBoolean)}]`; - } - return selector; - } +function nestedParts(name: string): CallHandler { + return call => { + checkArguments(call, 1, []); + return [nestedSelectorPart(name, arg(call, 0, isSelector))]; + }; } -function checkArguments(call: LocatorCall, maxArgs: number, allowedOptions: string[]) { +function frameLocatorParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 1, []); + return call.args.length ? parseSelector(arg(call, 0, isString)).parts : [selectorPart('internal:control', 'any-frame')]; +} + +// Entering the frame is deferred by the parser. +function contentFrameParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 0, []); + return []; +} + +function fixedNthParts(index: string): CallHandler { + return call => { + checkArguments(call, 0, []); + return [selectorPart('nth', index)]; + }; +} + +function nthParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 1, []); + return [selectorPart('nth', String(arg(call, 0, isNumber)))]; +} + +function visibleParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 0, []); + return [selectorPart('visible', 'true')]; +} + +function getByTestIdParts(call: CallArguments, testIdAttributeName: string): ParsedSelectorPart[] { + checkArguments(call, 1, []); + return parseSelector(getByTestIdSelector(testIdAttributeName, arg(call, 0, isText))).parts; +} + +function textParts(toSelector: (text: string | RegExp, options: { exact?: boolean }) => string): CallHandler { + return call => { + checkArguments(call, 1, ['exact']); + return parseSelector(toSelector(arg(call, 0, isText), { exact: option(call, 'exact', isBoolean) })).parts; + }; +} + +const andParts = nestedParts('internal:and'); +const orParts = nestedParts('internal:or'); +const firstParts = fixedNthParts('0'); +const lastParts = fixedNthParts('-1'); +const getByTextParts = textParts(getByTextSelector); +const getByLabelParts = textParts(getByLabelSelector); +const getByAltTextParts = textParts(getByAltTextSelector); +const getByPlaceholderParts = textParts(getByPlaceholderSelector); +const getByTitleParts = textParts(getByTitleSelector); + +// FrameLocator methods that apply to the frame element rather than enter the frame. +const kFrameElementHandlers = new Set([firstParts, lastParts, nthParts]); + +// JavaScript and Java share the method names. +const kJavaScriptMethods = new Map([ + ['locator', locatorParts], + ['filter', filterParts], + ['and', andParts], + ['or', orParts], + ['frameLocator', frameLocatorParts], + ['contentFrame', contentFrameParts], + ['first', firstParts], + ['last', lastParts], + ['nth', nthParts], + ['visible', visibleParts], + ['getByRole', getByRoleParts], + ['getByText', getByTextParts], + ['getByLabel', getByLabelParts], + ['getByTestId', getByTestIdParts], + ['getByAltText', getByAltTextParts], + ['getByPlaceholder', getByPlaceholderParts], + ['getByTitle', getByTitleParts], +]); + +const kPythonMethods = new Map([ + ['locator', locatorParts], + ['filter', filterParts], + ['and_', andParts], + ['or_', orParts], + ['frame_locator', frameLocatorParts], + ['content_frame', contentFrameParts], + ['first', firstParts], + ['last', lastParts], + ['nth', nthParts], + ['visible', visibleParts], + ['get_by_role', getByRoleParts], + ['get_by_text', getByTextParts], + ['get_by_label', getByLabelParts], + ['get_by_test_id', getByTestIdParts], + ['get_by_alt_text', getByAltTextParts], + ['get_by_placeholder', getByPlaceholderParts], + ['get_by_title', getByTitleParts], +]); + +const kCSharpMethods = new Map([ + ['Locator', locatorParts], + ['Filter', filterParts], + ['And', andParts], + ['Or', orParts], + ['FrameLocator', frameLocatorParts], + ['ContentFrame', contentFrameParts], + ['First', firstParts], + ['Last', lastParts], + ['Nth', nthParts], + ['Visible', visibleParts], + ['GetByRole', getByRoleParts], + ['GetByText', getByTextParts], + ['GetByLabel', getByLabelParts], + ['GetByTestId', getByTestIdParts], + ['GetByAltText', getByAltTextParts], + ['GetByPlaceholder', getByPlaceholderParts], + ['GetByTitle', getByTitleParts], +]); + +function filterSelectorParts(call: CallArguments): ParsedSelectorPart[] { + const parts: ParsedSelectorPart[] = []; + const hasText = option(call, 'hasText', isText); + if (hasText !== undefined) + parts.push(selectorPart('internal:has-text', escapeForTextSelector(hasText, false))); + const hasNotText = option(call, 'hasNotText', isText); + if (hasNotText !== undefined) + parts.push(selectorPart('internal:has-not-text', escapeForTextSelector(hasNotText, false))); + const has = option(call, 'has', isSelector); + if (has) + parts.push(nestedSelectorPart('internal:has', has)); + const hasNot = option(call, 'hasNot', isSelector); + if (hasNot) + parts.push(nestedSelectorPart('internal:has-not', hasNot)); + const visible = option(call, 'visible', isBoolean); + if (visible !== undefined) + parts.push(selectorPart('visible', String(visible))); + return parts; +} + +function getByRoleParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 1, ['name', 'description', 'exact', 'checked', 'disabled', 'expanded', 'includeHidden', 'level', 'pressed', 'selected']); + const exact = !!option(call, 'exact', isBoolean); + let body = arg(call, 0, isString); + // Keep the attributes in the source order, so that the selector renders back into the same locator. + for (const name of call.options.keys()) { + if (name === 'name' || name === 'description') + body += `[${name}=${escapeForAttributeSelector(option(call, name, isText)!, exact)}]`; + else if (name === 'level') + body += `[level=${option(call, name, isNumber)}]`; + else if (name !== 'exact') + body += `[${name === 'includeHidden' ? 'include-hidden' : name}=${option(call, name, isBoolean)}]`; + } + return [selectorPart('internal:role', body)]; +} + +function selectorPart(name: string, body: string): ParsedSelectorPart { + return { name, body, source: body }; +} + +function nestedSelectorPart(name: string, parsed: ParsedSelector): ParsedSelectorPart { + return { name, body: { parsed }, source: JSON.stringify(stringifySelector(parsed)) }; +} + +function isNestedSelectorPart(part: ParsedSelectorPart): boolean { + return typeof part.body === 'object' && 'parsed' in part.body; +} + +function checkArguments(call: CallArguments, maxArgs: number, allowedOptions: string[]) { if (call.args.length > maxArgs) throw new Error(`Too many arguments for ${call.method}`); for (const name of call.options.keys()) { @@ -526,14 +603,14 @@ function checkArguments(call: LocatorCall, maxArgs: number, allowedOptions: stri } } -function arg(call: LocatorCall, index: number, is: (value: Value) => value is T): T { +function arg(call: CallArguments, index: number, is: (value: Value) => value is T): T { const value = call.args[index]; if (value === undefined || !is(value)) throw new Error(`Unexpected argument #${index} for ${call.method}`); return value; } -function option(call: LocatorCall, name: string, is: (value: Value) => value is T): T | undefined { +function option(call: CallArguments, name: string, is: (value: Value) => value is T): T | undefined { const value = call.options.get(name); if (value !== undefined && !is(value)) throw new Error(`Unexpected ${name} option for ${call.method}`); @@ -556,26 +633,14 @@ function isBoolean(value: Value): value is boolean { return typeof value === 'boolean'; } -function isLocator(value: Value): value is LocatorCall[] { - return Array.isArray(value); -} - -function snakeToCamelCase(identifier: string): string { - return identifier.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); +function isSelector(value: Value): value is ParsedSelector { + return typeof value === 'object' && !(value instanceof RegExp); } function lowerFirst(identifier: string): string { return identifier.charAt(0).toLowerCase() + identifier.substring(1); } -function parseLocator(language: Language, locator: string, testIdAttributeName: string): { selector: string, preferredQuote: Quote | undefined } { - const Parser = parsers[language]; - if (!Parser) - throw new Error(`Parsing ${language} locators is not supported`); - const { calls, preferredQuote } = new Parser(locator).parse(); - return { selector: new SelectorBuilder(testIdAttributeName).build(calls), preferredQuote }; -} - export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string = 'data-testid'): string { try { return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName); @@ -590,11 +655,14 @@ export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: s return locator; } catch (e) { } - const { selector, preferredQuote } = parseLocator(language, locator, testIdAttributeName); + const Parser = parsers[language]; + if (!Parser) + return ''; + const { selector, preferredQuote } = new Parser(locator, testIdAttributeName).parse(); const locators = asLocators(language, selector, undefined, undefined, preferredQuote); const digest = digestForComparison(language, locator); if (locators.some(candidate => digestForComparison(language, candidate) === digest)) - return selector; + return stringifySelector(selector); return ''; } diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 1163f8fb6bf62..eff89aa338b47 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -627,14 +627,14 @@ it('asLocator xpath', async () => { expect.soft(asLocator('csharp', selector)).toBe(`Locator(\"//*[contains(normalizer-text(), 'foo']\")`); expect.soft(parseLocator('javascript', `locator('//*[contains(normalizer-text(), \\'foo\\']')`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); expect.soft(parseLocator('javascript', `locator("//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); - expect.soft(parseLocator('javascript', `locator('xpath=//*[contains(normalizer-text(), \\'foo\\']')`, 'data-testid')).toBe("xpath=//*[contains(normalizer-text(), 'foo']"); - expect.soft(parseLocator('javascript', `locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("xpath=//*[contains(normalizer-text(), 'foo']"); + expect.soft(parseLocator('javascript', `locator('xpath=//*[contains(normalizer-text(), \\'foo\\']')`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); + expect.soft(parseLocator('javascript', `locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); expect.soft(parseLocator('python', `locator("//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); - expect.soft(parseLocator('python', `locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("xpath=//*[contains(normalizer-text(), 'foo']"); + expect.soft(parseLocator('python', `locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); expect.soft(parseLocator('java', `locator("//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); - expect.soft(parseLocator('java', `locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("xpath=//*[contains(normalizer-text(), 'foo']"); + expect.soft(parseLocator('java', `locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); expect.soft(parseLocator('csharp', `Locator("//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); - expect.soft(parseLocator('csharp', `Locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("xpath=//*[contains(normalizer-text(), 'foo']"); + expect.soft(parseLocator('csharp', `Locator("xpath=//*[contains(normalizer-text(), 'foo']")`, 'data-testid')).toBe("//*[contains(normalizer-text(), 'foo']"); }); it('parseLocator quotes', async () => { @@ -658,13 +658,13 @@ it('parseLocator quotes', async () => { it('parseLocator css', async () => { expect.soft(parseLocator('javascript', `locator('.foo')`, '')).toBe(`.foo`); - expect.soft(parseLocator('javascript', `locator('css=.foo')`, '')).toBe(`css=.foo`); + expect.soft(parseLocator('javascript', `locator('css=.foo')`, '')).toBe(`.foo`); expect.soft(parseLocator('python', `locator(".foo")`, '')).toBe(`.foo`); - expect.soft(parseLocator('python', `locator("css=.foo")`, '')).toBe(`css=.foo`); + expect.soft(parseLocator('python', `locator("css=.foo")`, '')).toBe(`.foo`); expect.soft(parseLocator('java', `locator(".foo")`, '')).toBe(`.foo`); - expect.soft(parseLocator('java', `locator("css=.foo")`, '')).toBe(`css=.foo`); + expect.soft(parseLocator('java', `locator("css=.foo")`, '')).toBe(`.foo`); expect.soft(parseLocator('csharp', `Locator(".foo")`, '')).toBe(`.foo`); - expect.soft(parseLocator('csharp', `Locator("css=.foo")`, '')).toBe(`css=.foo`); + expect.soft(parseLocator('csharp', `Locator("css=.foo")`, '')).toBe(`.foo`); }); @@ -687,6 +687,8 @@ it('parseLocator round-trips selectors', async () => { 'internal:text="hello"s', 'internal:text=/he\\"llo/i', 'internal:text=/a\\/b/', + 'internal:text=/a.b/ims', + 'internal:role=button[name=/a.b/ms]', 'internal:text="a\'b\\"c`d"i', 'internal:text="a\\\\b"i', 'internal:text="tab\\there\\nnewline"i', @@ -718,6 +720,7 @@ it('parseLocator round-trips selectors', async () => { 'iframe >> internal:control=enter-frame >> div', 'iframe >> nth=0 >> internal:control=enter-frame >> div', 'internal:attr=[title="t"i] >> internal:control=enter-frame', + 'internal:control=any-frame >> div', 'div >> internal:and="span >> nth=0"', 'div >> internal:or="span"', 'div >> internal:chain="span >> internal:has-text=\\"x\\"i"', @@ -733,6 +736,21 @@ it('parseLocator round-trips selectors', async () => { } }); +it('reverse engineer regex flags', async () => { + expect.soft(generateForSelector('internal:text=/a.b/ims')).toEqual({ + csharp: 'GetByText(new Regex("a.b", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Singleline))', + java: 'getByText(Pattern.compile("a.b", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL))', + javascript: 'getByText(/a.b/ims)', + python: 'get_by_text(re.compile(r"a.b", re.IGNORECASE | re.MULTILINE | re.DOTALL))', + }); + expect.soft(generateForSelector('internal:role=button[name=/a.b/s]')).toEqual({ + csharp: 'GetByRole(AriaRole.Button, new() { NameRegex = new Regex("a.b", RegexOptions.Singleline) })', + java: 'getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(Pattern.compile("a.b", Pattern.DOTALL)))', + javascript: `getByRole('button', { name: /a.b/s })`, + python: 'get_by_role("button", name=re.compile(r"a.b", re.DOTALL))', + }); +}); + it('parseLocator rejects malformed locators', async () => { const locators = [ ['javascript', `locator('div').nth(0))`], @@ -787,7 +805,7 @@ it('parse locators strictly', () => { it('parseLocator frames', async () => { expect.soft(parseLocator('javascript', `locator('iframe').contentFrame().getByText('foo')`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('javascript', `frameLocator('iframe').getByText('foo')`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); - expect.soft(parseLocator('javascript', `frameLocator('css=iframe').getByText('foo')`, '')).toBe(`css=iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('javascript', `frameLocator('css=iframe').getByText('foo')`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('javascript', `getByTitle('iframe title').contentFrame()`)).toBe(`internal:attr=[title=\"iframe title\"i] >> internal:control=enter-frame`); // FrameLocator.first() and nth() pick the frame element, so there is no locator for nth inside a frame. @@ -801,7 +819,7 @@ it('parseLocator frames', async () => { expect.soft(parseLocator('python', `locator("iframe").content_frame.get_by_text("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('python', `frame_locator("iframe").get_by_text("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); - expect.soft(parseLocator('python', `frame_locator("css=iframe").get_by_text("foo")`, '')).toBe(`css=iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('python', `frame_locator("css=iframe").get_by_text("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('csharp', `Locator("iframe").ContentFrame.GetByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('csharp', `FrameLocator("iframe").GetByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); From f102bed5a9b9357dc5d656a735f2b57a1bf0ed07 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 23 Sep 2026 17:04:48 -0700 Subject: [PATCH 3/4] chore(locators): return parsed selector without round-trip check The parser validates methods, arguments and options itself, so the selector no longer needs to be rendered back and compared to the input. --- packages/isomorphic/locatorGenerators.ts | 7 +++--- packages/isomorphic/locatorParser.ts | 30 +++++++----------------- tests/library/locator-generator.spec.ts | 4 ++-- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/packages/isomorphic/locatorGenerators.ts b/packages/isomorphic/locatorGenerators.ts index a9e0f10c298fe..7522baf51e5e7 100644 --- a/packages/isomorphic/locatorGenerators.ts +++ b/packages/isomorphic/locatorGenerators.ts @@ -74,13 +74,12 @@ export function asLocator(lang: Language, selector: string, isFrameLocator: bool return asLocators(lang, selector, isFrameLocator, 1)[0]; } -export function asLocators(lang: Language, selector: string | ParsedSelector, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { +export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { try { - const parsed = typeof selector === 'string' ? parseSelector(selector) : selector; - return innerAsLocators(new generators[lang](preferredQuote), parsed, isFrameLocator, maxOutputSize); + return innerAsLocators(new generators[lang](preferredQuote), parseSelector(selector), isFrameLocator, maxOutputSize); } catch (e) { // Tolerate invalid input. - return [stringifySelector(selector)]; + return [selector]; } } diff --git a/packages/isomorphic/locatorParser.ts b/packages/isomorphic/locatorParser.ts index 287e6ffe7b6ae..aa27a78773694 100644 --- a/packages/isomorphic/locatorParser.ts +++ b/packages/isomorphic/locatorParser.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { asLocators, regexFlagNames } from './locatorGenerators'; +import { regexFlagNames } from './locatorGenerators'; import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from './locatorUtils'; import { parseSelector, stringifySelector } from './selectorParser'; import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils'; -import type { Language, Quote } from './locatorGenerators'; +import type { Language } from './locatorGenerators'; import type { ParsedSelector, ParsedSelectorPart } from './selectorParser'; // Locator source code is parsed by a language-specific parser straight into a ParsedSelector. @@ -38,7 +38,7 @@ type CallHandler = (call: CallArguments, testIdAttributeName: string) => ParsedS type Token = | { kind: 'identifier', value: string } - | { kind: 'string', value: string, quote: Quote } + | { kind: 'string', value: string } | { kind: 'regex', value: RegExp } | { kind: 'number', value: number } | { kind: 'punctuation', value: string } @@ -103,10 +103,9 @@ function tokenize(source: string, options: ParserOptions): Token[] { if (/\s/.test(char)) { ++pos; } else if (options.quotes.includes(char)) { - tokens.push({ kind: 'string', value: readString(char, false), quote: char as Quote }); + tokens.push({ kind: 'string', value: readString(char, false) }); } else if (options.rawStrings && char === 'r' && options.quotes.includes(source[pos + 1])) { - const quote = source[++pos] as Quote; - tokens.push({ kind: 'string', value: readString(quote, true), quote }); + tokens.push({ kind: 'string', value: readString(source[++pos], true) }); } else if (options.regexLiterals && char === '/') { const regexSource = readString('/', true); tokens.push({ kind: 'regex', value: new RegExp(regexSource, match(kRegexFlagsRegex)) }); @@ -143,15 +142,14 @@ abstract class LocatorParser { this._testIdAttributeName = testIdAttributeName; } - parse(): { selector: ParsedSelector, preferredQuote: Quote | undefined } { + parse(): ParsedSelector { const selector = this.parseLocator(); if (this.peek().kind !== 'end') this.unexpected(); // Same as in parseSelector(), nested selectors like internal:chain cannot be first. if (selector.parts.length && isNestedSelectorPart(selector.parts[0])) throw new Error(`"${selector.parts[0].name}" selector cannot be first`); - const preferredQuote = this._tokens.find(token => token.kind === 'string')?.quote; - return { selector, preferredQuote }; + return selector; } // Parses a single argument that is either a positional value or an options bag. @@ -658,17 +656,5 @@ export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: s const Parser = parsers[language]; if (!Parser) return ''; - const { selector, preferredQuote } = new Parser(locator, testIdAttributeName).parse(); - const locators = asLocators(language, selector, undefined, undefined, preferredQuote); - const digest = digestForComparison(language, locator); - if (locators.some(candidate => digestForComparison(language, candidate) === digest)) - return stringifySelector(selector); - return ''; -} - -function digestForComparison(language: Language, locator: string) { - locator = locator.replace(/\s/g, ''); - if (language === 'javascript') - locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, ''); - return locator; + return stringifySelector(new Parser(locator, testIdAttributeName).parse()); } diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index eff89aa338b47..dfdba4c4729dd 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -642,7 +642,7 @@ it('parseLocator quotes', async () => { expect.soft(parseLocator('javascript', `locator("text='bar'")`, '')).toBe(`text='bar'`); expect.soft(parseLocator('javascript', "locator(`text='bar'`)", '')).toBe(`text='bar'`); expect.soft(parseLocator('python', `locator("text='bar'")`, '')).toBe(`text='bar'`); - expect.soft(parseLocator('python', `locator('text="bar"')`, '')).toBe(``); + expect.soft(parseLocator('python', `locator('text="bar"')`, '')).toBe(`text="bar"`); expect.soft(parseLocator('java', `locator("text='bar'")`, '')).toBe(`text='bar'`); expect.soft(parseLocator('java', `locator('text="bar"')`, '')).toBe(``); expect.soft(parseLocator('csharp', `Locator("text='bar'")`, '')).toBe(`text='bar'`); @@ -786,7 +786,7 @@ it('parse locators strictly', () => { // Quotes expect.soft(parseLocator('javascript', `locator("div").filter({ hasText: "Goodbye world" }).locator("span")`)).toBe(selector); - expect.soft(parseLocator('python', `locator('div').filter(has_text='Goodbye world').locator('span')`)).not.toBe(selector); + expect.soft(parseLocator('python', `locator('div').filter(has_text='Goodbye world').locator('span')`)).toBe(selector); // Whitespace expect.soft(parseLocator('csharp', `Locator("div") . Filter (new ( ) { HasText = "Goodbye world" }).Locator( "span" )`)).toBe(selector); From 3bfca2ddee443f7c8a56df3b361663a3c2a1b38f Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 23 Sep 2026 17:21:25 -0700 Subject: [PATCH 4/4] chore(locators): make parseRegex abstract and drop redundant comments --- packages/isomorphic/locatorParser.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/isomorphic/locatorParser.ts b/packages/isomorphic/locatorParser.ts index aa27a78773694..52672077e71f6 100644 --- a/packages/isomorphic/locatorParser.ts +++ b/packages/isomorphic/locatorParser.ts @@ -22,9 +22,6 @@ import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils import type { Language } from './locatorGenerators'; import type { ParsedSelector, ParsedSelectorPart } from './selectorParser'; -// Locator source code is parsed by a language-specific parser straight into a ParsedSelector. -// Each language maps its method names to call handlers that turn call arguments into selector parts. - type Value = string | number | boolean | RegExp | ParsedSelector; // Arguments of a single call, collected until the call is converted into selector parts. @@ -47,9 +44,7 @@ type Token = type ParserOptions = { methods: Map; quotes: string; - // Python r"..." strings. rawStrings?: boolean; - // JavaScript /.../flags literals. regexLiterals?: boolean; booleans?: [string, string]; }; @@ -156,9 +151,7 @@ abstract class LocatorParser { protected abstract parseArgument(call: CallArguments): void; // Parses a language-specific regular expression, e.g. `re.compile(...)`. - protected parseRegex(): RegExp | undefined { - return undefined; - } + protected abstract parseRegex(): RegExp | undefined; protected parseLocator(): ParsedSelector { const parts: ParsedSelectorPart[] = []; @@ -311,6 +304,11 @@ class JavaScriptLocatorParser extends LocatorParser { else call.args.push(this.parseValue()); } + + // Regular expression literals are tokens. + protected parseRegex(): RegExp | undefined { + return undefined; + } } // get_by_role("button", name=re.compile(r"submit", re.IGNORECASE), exact=True) @@ -329,7 +327,7 @@ class PythonLocatorParser extends LocatorParser { call.options.set(name.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), this.parseValue()); } - protected override parseRegex(): RegExp | undefined { + protected parseRegex(): RegExp | undefined { if (!this.eatIdentifier('re')) return; this.expect('.'); @@ -365,7 +363,7 @@ class JavaLocatorParser extends LocatorParser { } } - protected override parseRegex(): RegExp | undefined { + protected parseRegex(): RegExp | undefined { if (!this.eatIdentifier('Pattern')) return; this.expect('.'); @@ -394,7 +392,7 @@ class CSharpLocatorParser extends LocatorParser { this.parseOptionsBag(call, '=', name => lowerFirst(name.replace(/Regex$/, ''))); } - protected override parseRegex(): RegExp | undefined { + protected parseRegex(): RegExp | undefined { if (!this.eatIdentifier('new')) return; this.expectIdentifier('Regex'); @@ -480,7 +478,6 @@ const getByAltTextParts = textParts(getByAltTextSelector); const getByPlaceholderParts = textParts(getByPlaceholderSelector); const getByTitleParts = textParts(getByTitleSelector); -// FrameLocator methods that apply to the frame element rather than enter the frame. const kFrameElementHandlers = new Set([firstParts, lastParts, nthParts]); // JavaScript and Java share the method names. @@ -568,7 +565,6 @@ function getByRoleParts(call: CallArguments): ParsedSelectorPart[] { checkArguments(call, 1, ['name', 'description', 'exact', 'checked', 'disabled', 'expanded', 'includeHidden', 'level', 'pressed', 'selected']); const exact = !!option(call, 'exact', isBoolean); let body = arg(call, 0, isString); - // Keep the attributes in the source order, so that the selector renders back into the same locator. for (const name of call.options.keys()) { if (name === 'name' || name === 'description') body += `[${name}=${escapeForAttributeSelector(option(call, name, isText)!, exact)}]`;