diff --git a/packages/isomorphic/locatorGenerators.ts b/packages/isomorphic/locatorGenerators.ts index 4aec1339aafda..7522baf51e5e7 100644 --- a/packages/isomorphic/locatorGenerators.ts +++ b/packages/isomorphic/locatorGenerators.ts @@ -493,7 +493,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 +605,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 +711,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 +776,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 6f5c8ac31efd2..52672077e71f6 100644 --- a/packages/isomorphic/locatorParser.ts +++ b/packages/isomorphic/locatorParser.ts @@ -14,212 +14,625 @@ * limitations under the License. */ -import { asLocators } from './locatorGenerators'; -import { encodeTestIdAttributeName } from './locatorUtils'; -import { parseSelector } from './selectorParser'; +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'; - -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; +import type { Language } from './locatorGenerators'; +import type { ParsedSelector, ParsedSelectorPart } from './selectorParser'; + +type Value = string | number | boolean | RegExp | ParsedSelector; + +// 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 } + | { kind: 'regex', value: RegExp } + | { kind: 'number', value: number } + | { kind: 'punctuation', value: string } + | { kind: 'end' }; + +type ParserOptions = { + methods: Map; + quotes: string; + rawStrings?: boolean; + 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; + } + } + 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) }); + } else if (options.rawStrings && char === 'r' && options.quotes.includes(source[pos + 1])) { + 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)) }); + } 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 _methods: Map; + private _booleans: [string, string]; + private _testIdAttributeName: string; + + 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(): 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`); + return selector; + } + + // Parses a single argument that is either a positional value or an options bag. + protected abstract parseArgument(call: CallArguments): void; + + // Parses a language-specific regular expression, e.g. `re.compile(...)`. + protected abstract parseRegex(): RegExp | undefined; + + 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; } - break; + 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(): { 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(')'); } - 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) + return { handler, 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.parseLocator(); + } + + // Parses `{ name value, ... }` after the opening brace. + protected parseOptionsBag(call: CallArguments, 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, flagNames: Record): RegExp { + this.expect('('); + const source = this.expectString(); + let flags = ''; + if (this.eat(',')) { + 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); + } + + 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, testIdAttributeName: string) { + super(source, { methods: kJavaScriptMethods, quotes: '\'"`', regexLiterals: true }, testIdAttributeName); + } + + protected parseArgument(call: CallArguments) { + if (this.eat('{')) + this.parseOptionsBag(call, ':', name => name); + 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) +class PythonLocatorParser extends LocatorParser { + constructor(source: string, testIdAttributeName: string) { + super(source, { methods: kPythonMethods, quotes: '\'"', rawStrings: true, booleans: ['True', 'False'] }, testIdAttributeName); + } + + 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(name.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), this.parseValue()); + } + + protected parseRegex(): RegExp | undefined { + if (!this.eatIdentifier('re')) + return; + this.expect('.'); + this.expectIdentifier('compile'); + 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, testIdAttributeName: string) { + super(source, { methods: kJavaScriptMethods, quotes: '"' }, testIdAttributeName); + } - // 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 parseArgument(call: CallArguments) { + 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 parseRegex(): RegExp | undefined { + if (!this.eatIdentifier('Pattern')) + return; + this.expect('.'); + this.expectIdentifier('compile'); + 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, testIdAttributeName: string) { + super(source, { methods: kCSharpMethods, quotes: '"' }, testIdAttributeName); + } - 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 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()); + 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 parseRegex(): RegExp | undefined { + if (!this.eatIdentifier('new')) + return; + this.expectIdentifier('Regex'); + return this.parseRegexArguments('RegexOptions', regexFlagNames.csharp); + } +} + +const parsers: Partial LocatorParser>> = { + javascript: JavaScriptLocatorParser, + python: PythonLocatorParser, + java: JavaLocatorParser, + csharp: CSharpLocatorParser, +}; + +const kFilterOptions = ['hasText', 'hasNotText', 'has', 'hasNot', 'visible']; + +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)]; +} + +function filterParts(call: CallArguments): ParsedSelectorPart[] { + checkArguments(call, 0, kFilterOptions); + return filterSelectorParts(call); +} + +function nestedParts(name: string): CallHandler { + return call => { + checkArguments(call, 1, []); + return [nestedSelectorPart(name, arg(call, 0, isSelector))]; + }; +} + +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); + +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); + 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()) { + if (!allowedOptions.includes(name)) + throw new Error(`Unsupported option ${name} for ${call.method}`); } +} - // 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(' >> '); +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: 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}`); + 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 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); } export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string = 'data-testid'): string { @@ -236,17 +649,8 @@ export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: s return locator; } catch (e) { } - const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); - const locators = asLocators(language, selector, undefined, undefined, preferredQuote); - const digest = digestForComparison(language, locator); - if (locators.some(candidate => digestForComparison(language, candidate) === digest)) - return selector; - return ''; -} - -function digestForComparison(language: Language, locator: string) { - locator = locator.replace(/\s/g, ''); - if (language === 'javascript') - locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, ''); - return locator; + const Parser = parsers[language]; + if (!Parser) + return ''; + return stringifySelector(new Parser(locator, testIdAttributeName).parse()); } diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 4476a8c26578b..dfdba4c4729dd 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\\']')`); @@ -619,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 () => { @@ -634,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'`); @@ -650,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`); }); @@ -665,6 +673,108 @@ 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/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', + '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', + 'internal:control=any-frame >> div', + '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('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))`], + ['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'; @@ -676,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); @@ -689,19 +799,27 @@ 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 () => { 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. + 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`); 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`);