/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { asLocators } from './locatorGenerators'; 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; 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; continue; } if (locator[i] !== quote) { text += locator[i++]; continue; } 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) break; } // Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=... let prefix = template.substring(0, start); let extraSymbol = 0; if (['sethas(', 'sethasnot('].includes(hasMatch[1])) { // Eat extra ) symbol at the end of sethas(...) extraSymbol = 1; prefix = prefix.replace(/sethas\($/, 'has=').replace(/sethasnot\($/, 'hasnot='); } const paramsCountBeforeHas = countParams(template.substring(0, start)); const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas); const paramsCountInHas = countParams(hasTemplate); 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, '$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=[${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(/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); } } // 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(' >> '); } export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { try { return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName); } catch (e) { return ''; } } export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { try { parseSelector(locator); 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; }