diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index ad8216f71576a..7af0d2d938292 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -40,7 +40,7 @@ export type InjectedScriptPoll = { export class InjectedScript { private _enginesV1: Map; - private _evaluator: SelectorEvaluatorImpl; + _evaluator: SelectorEvaluatorImpl; constructor(customEngines: { name: string, engine: SelectorEngine}[]) { this._enginesV1 = new Map(); @@ -75,9 +75,12 @@ export class InjectedScript { querySelector(selector: ParsedSelector, root: Node): Element | undefined { if (!(root as any)['querySelector']) throw new Error('Node is not queryable.'); - const result = this._querySelectorRecursively(root as SelectorRoot, selector, 0); - this._evaluator.clearCaches(); - return result; + this._evaluator.begin(); + try { + return this._querySelectorRecursively(root as SelectorRoot, selector, 0); + } finally { + this._evaluator.end(); + } } private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined { @@ -95,30 +98,34 @@ export class InjectedScript { querySelectorAll(selector: ParsedSelector, root: Node): Element[] { if (!(root as any)['querySelectorAll']) throw new Error('Node is not queryable.'); - const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture; - // Query all elements up to the capture. - const partsToQueryAll = selector.parts.slice(0, capture + 1); - // Check they have a descendant matching everything after the capture. - const partsToCheckOne = selector.parts.slice(capture + 1); - let set = new Set([ root as SelectorRoot ]); - for (const part of partsToQueryAll) { - const newSet = new Set(); - for (const prev of set) { - for (const next of this._queryEngineAll(part, prev)) { - if (newSet.has(next)) - continue; - newSet.add(next); + this._evaluator.begin(); + try { + const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture; + // Query all elements up to the capture. + const partsToQueryAll = selector.parts.slice(0, capture + 1); + // Check they have a descendant matching everything after the capture. + const partsToCheckOne = selector.parts.slice(capture + 1); + let set = new Set([ root as SelectorRoot ]); + for (const part of partsToQueryAll) { + const newSet = new Set(); + for (const prev of set) { + for (const next of this._queryEngineAll(part, prev)) { + if (newSet.has(next)) + continue; + newSet.add(next); + } } + set = newSet; } - set = newSet; - } - let result = Array.from(set) as Element[]; - if (partsToCheckOne.length) { - const partial = { parts: partsToCheckOne }; - result = result.filter(e => !!this._querySelectorRecursively(e, partial, 0)); + let result = Array.from(set) as Element[]; + if (partsToCheckOne.length) { + const partial = { parts: partsToCheckOne }; + result = result.filter(e => !!this._querySelectorRecursively(e, partial, 0)); + } + return result; + } finally { + this._evaluator.end(); } - this._evaluator.clearCaches(); - return result; } private _queryEngine(part: ParsedSelectorPart, root: SelectorRoot): Element | undefined { diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index 0ae065944fc4f..129e8691fd7ce 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -45,6 +45,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { private _cacheQuerySimple: QueryCache = new Map(); _cacheText = new Map(); private _scoreMap: Map | undefined; + private _retainCacheCounter = 0; constructor(extraEngines: Map) { for (const [name, engine] of extraEngines) @@ -75,16 +76,23 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { throw new Error(`Please keep customCSSNames in sync with evaluator engines`); } - clearCaches() { - this._cacheQueryCSS.clear(); - this._cacheMatches.clear(); - this._cacheQuery.clear(); - this._cacheMatchesSimple.clear(); - this._cacheMatchesParents.clear(); - this._cacheCallMatches.clear(); - this._cacheCallQuery.clear(); - this._cacheQuerySimple.clear(); - this._cacheText.clear(); + begin() { + ++this._retainCacheCounter; + } + + end() { + --this._retainCacheCounter; + if (!this._retainCacheCounter) { + this._cacheQueryCSS.clear(); + this._cacheMatches.clear(); + this._cacheQuery.clear(); + this._cacheMatchesSimple.clear(); + this._cacheMatchesParents.clear(); + this._cacheCallMatches.clear(); + this._cacheCallQuery.clear(); + this._cacheQuerySimple.clear(); + this._cacheText.clear(); + } } private _cached(cache: QueryCache, main: any, rest: any[], cb: () => T): T { @@ -109,43 +117,53 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { matches(element: Element, s: Selector, context: QueryContext): boolean { const selector = this._checkSelector(s); - return this._cached(this._cacheMatches, element, [selector, context], () => { - if (Array.isArray(selector)) - return this._matchesEngine(isEngine, element, selector, context); - if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context)) - return false; - return this._matchesParents(element, selector, selector.simples.length - 2, context); - }); + this.begin(); + try { + return this._cached(this._cacheMatches, element, [selector, context], () => { + if (Array.isArray(selector)) + return this._matchesEngine(isEngine, element, selector, context); + if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context)) + return false; + return this._matchesParents(element, selector, selector.simples.length - 2, context); + }); + } finally { + this.end(); + } } query(context: QueryContext, s: any): Element[] { const selector = this._checkSelector(s); - return this._cached(this._cacheQuery, selector, [context], () => { - if (Array.isArray(selector)) - return this._queryEngine(isEngine, context, selector); - - // query() recursively calls itself, so we set up a new map for this particular query() call. - const previousScoreMap = this._scoreMap; - this._scoreMap = new Map(); - let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector); - elements = elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context)); - if (this._scoreMap.size) { - elements.sort((a, b) => { - const aScore = this._scoreMap!.get(a); - const bScore = this._scoreMap!.get(b); - if (aScore === bScore) - return 0; - if (aScore === undefined) - return 1; - if (bScore === undefined) - return -1; - return aScore - bScore; - }); - } - this._scoreMap = previousScoreMap; + this.begin(); + try { + return this._cached(this._cacheQuery, selector, [context], () => { + if (Array.isArray(selector)) + return this._queryEngine(isEngine, context, selector); + + // query() recursively calls itself, so we set up a new map for this particular query() call. + const previousScoreMap = this._scoreMap; + this._scoreMap = new Map(); + let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector); + elements = elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context)); + if (this._scoreMap.size) { + elements.sort((a, b) => { + const aScore = this._scoreMap!.get(a); + const bScore = this._scoreMap!.get(b); + if (aScore === bScore) + return 0; + if (aScore === undefined) + return 1; + if (bScore === undefined) + return -1; + return aScore - bScore; + }); + } + this._scoreMap = previousScoreMap; - return elements; - }); + return elements; + }); + } finally { + this.end(); + } } _markScore(element: Element, score: number) { @@ -458,7 +476,7 @@ function shouldSkipForTextMatching(element: Element | ShadowRoot) { return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element); } -function elementText(evaluator: SelectorEvaluatorImpl, root: Element | ShadowRoot): string { +export function elementText(evaluator: SelectorEvaluatorImpl, root: Element | ShadowRoot): string { let value = evaluator._cacheText.get(root); if (value === undefined) { value = ''; diff --git a/src/server/supplements/injected/selectorGenerator.ts b/src/server/supplements/injected/selectorGenerator.ts index 189a09f3ae4f5..87dc3ec530a0b 100644 --- a/src/server/supplements/injected/selectorGenerator.ts +++ b/src/server/supplements/injected/selectorGenerator.ts @@ -15,74 +15,150 @@ */ import type InjectedScript from '../../injected/injectedScript'; +import { elementText } from '../../injected/selectorEvaluator'; + +type SelectorToken = { + engine: string; + selector: string; + score: number; // Lower is better. +}; + +const cacheAllowText = new Map(); +const cacheDisallowText = new Map(); export function generateSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } { - const path: SelectorToken[] = []; - let numberOfMatchingElements = Number.MAX_SAFE_INTEGER; - for (let element: Element | null = targetElement; element && element !== document.documentElement; element = parentElementOrShadowHost(element)) { - const selector = buildSelectorCandidate(element); - if (!selector) - continue; - const fullSelector = joinSelector([selector, ...path]); - const parsedSelector = injectedScript.parseSelector(fullSelector); - const selectorTargets = injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument); - if (!selectorTargets.length) - break; - if (selectorTargets[0] === targetElement) - return { selector: fullSelector, elements: selectorTargets }; - if (selectorTargets.length && numberOfMatchingElements > selectorTargets.length) { - numberOfMatchingElements = selectorTargets.length; - path.unshift(selector); - } - } - if (document.documentElement === targetElement) { + injectedScript._evaluator.begin(); + try { + targetElement = targetElement.closest('button,select,input,[role=button],[role=checkbox],[role=radio]') || targetElement; + let bestTokens = generateSelectorFor(injectedScript, targetElement); + + const targetLabel = findTargetLabel(targetElement); + const labelTokens = targetLabel ? generateSelectorFor(injectedScript, targetLabel) : null; + if (labelTokens && combineScores(labelTokens) < combineScores(bestTokens)) + bestTokens = labelTokens; + + const selector = joinTokens(bestTokens); + const parsedSelector = injectedScript.parseSelector(selector); return { - selector: '/html', - elements: [document.documentElement] + selector, + elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument) }; + } finally { + cacheAllowText.clear(); + cacheDisallowText.clear(); + injectedScript._evaluator.end(); } - const selector = - createXPath(document.documentElement, targetElement) || - cssSelectorForElement(injectedScript, targetElement); - const parsedSelector = injectedScript.parseSelector(selector); - return { - selector, - elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument) +} + +function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element): SelectorToken[] { + if (targetElement.ownerDocument.documentElement === targetElement) + return [{ engine: 'css', selector: 'html', score: 1 }]; + + const calculate = (element: Element, allowText: boolean): SelectorToken[] | null => { + const allowNthMatch = element === targetElement; + + const textCandidates = allowText ? buildTextCandidates(injectedScript, element, element === targetElement).map(token => [token]) : []; + const noTextCandidates = buildCandidates(injectedScript, element).map(token => [token]); + let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch); + + const checkWithText = (textCandidatesToUse: SelectorToken[][]) => { + const allowParentText = allowText && !textCandidatesToUse.length; + const candidates = [...textCandidatesToUse, ...noTextCandidates]; + for (let parent = parentElementOrShadowHost(element); parent; parent = parentElementOrShadowHost(parent)) { + const best = chooseFirstSelector(injectedScript, parent, element, candidates, allowNthMatch); + if (!best) + continue; + if (result && combineScores(best) >= combineScores(result)) + continue; + const parentTokens = find(parent, allowParentText); + if (!parentTokens) + continue; + if (!result || combineScores([...parentTokens, ...best]) < combineScores(result)) + result = [...parentTokens, ...best]; + } + }; + + checkWithText(textCandidates); + // Allow skipping text on the target element. + if (element === targetElement && textCandidates.length) + checkWithText([]); + + return result; + }; + + const find = (element: Element, allowText: boolean): SelectorToken[] | null => { + const cache = allowText ? cacheAllowText : cacheDisallowText; + let value = cache.get(element); + if (value === undefined) { + value = calculate(element, allowText); + cache.set(element, value); + } + return value; }; + + const smartTokens = find(targetElement, true); + if (smartTokens) + return smartTokens; + + return [cssFallback(injectedScript, targetElement)]; } -function buildSelectorCandidate(element: Element): SelectorToken | null { - const nodeName = element.nodeName.toLowerCase(); +function buildCandidates(injectedScript: InjectedScript, element: Element): SelectorToken[] { + const candidates: SelectorToken[] = []; for (const attribute of ['data-testid', 'data-test-id', 'data-test']) { if (element.hasAttribute(attribute)) - return { engine: 'css', selector: `${nodeName}[${attribute}=${quoteString(element.getAttribute(attribute)!)}]` }; + candidates.push({ engine: 'css', selector: `[${attribute}=${quoteString(element.getAttribute(attribute)!)}]`, score: 1 }); } - for (const attribute of ['aria-label', 'role']) { - if (element.hasAttribute(attribute)) - return { engine: 'css', selector: `${element.nodeName.toLocaleLowerCase()}[${attribute}=${quoteString(element.getAttribute(attribute)!)}]` }; + + if (element.nodeName === 'INPUT') { + const input = element as HTMLInputElement; + if (input.placeholder) + candidates.push({ engine: 'css', selector: `[placeholder=${quoteString(input.placeholder)}]`, score: 10 }); } - if (['INPUT', 'TEXTAREA'].includes(element.nodeName)) { - const nodeNameLowercase = element.nodeName.toLowerCase(); + if (element.hasAttribute('aria-label')) + candidates.push({ engine: 'css', selector: `[aria-label=${quoteString(element.getAttribute('aria-label')!)}]`, score: 10 }); + if (element.nodeName === 'IMG' && element.getAttribute('alt')) + candidates.push({ engine: 'css', selector: `img[alt=${quoteString(element.getAttribute('alt')!)}]`, score: 10 }); + + if (element.hasAttribute('role')) + candidates.push({ engine: 'css', selector: `${element.nodeName.toLocaleLowerCase()}[role=${quoteString(element.getAttribute('role')!)}]` , score: 50 }); + if (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') { if (element.getAttribute('name')) - return { engine: 'css', selector: `${nodeNameLowercase}[name=${quoteString(element.getAttribute('name')!)}]` }; - if (element.getAttribute('placeholder')) - return { engine: 'css', selector: `${nodeNameLowercase}[placeholder=${quoteString(element.getAttribute('placeholder')!)}]` }; + candidates.push({ engine: 'css', selector: `${element.nodeName.toLowerCase()}[name=${quoteString(element.getAttribute('name')!)}]`, score: 50 }); if (element.getAttribute('type')) - return { engine: 'css', selector: `${nodeNameLowercase}[type=${quoteString(element.getAttribute('type')!)}]` }; - } else if (element.nodeName === 'IMG') { - if (element.getAttribute('alt')) - return { engine: 'css', selector: `img[alt=${quoteString(element.getAttribute('alt')!)}]` }; + candidates.push({ engine: 'css', selector: `${element.nodeName.toLowerCase()}[type=${quoteString(element.getAttribute('type')!)}]`, score: 50 }); } - const textSelector = textSelectorForElement(element); - if (textSelector) - return { engine: 'text', selector: textSelector }; + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName)) + candidates.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: 50 }); - // De-prioritize id, but still use it as a last resort. const idAttr = element.getAttribute('id'); if (idAttr && !isGuidLike(idAttr)) - return { engine: 'css', selector: `${nodeName}[id=${quoteString(idAttr!)}]` }; + candidates.push({ engine: 'css', selector: `#${idAttr}`, score: 100 }); - return null; + candidates.push({ engine: 'css', selector: element.nodeName.toLocaleLowerCase(), score: 200 }); + return candidates; +} + +function buildTextCandidates(injectedScript: InjectedScript, element: Element, allowHasText: boolean): SelectorToken[] { + if (element.nodeName === 'SELECT') + return []; + const text = elementText(injectedScript._evaluator, element).trim().replace(/\s+/g, ' ').substring(0, 80); + if (!text) + return []; + const candidates: SelectorToken[] = []; + + let escaped = text; + if (text.includes('"') || text.includes('>>') || text[0] === '/') + escaped = `/.*${escapeForRegex(text)}.*/`; + + candidates.push({ engine: 'text', selector: escaped, score: 10 }); + if (allowHasText && escaped === text) { + let prefix = element.nodeName.toLocaleLowerCase(); + if (element.hasAttribute('role')) + prefix += `[role=${quoteString(element.getAttribute('role')!)}]`; + candidates.push({ engine: 'css', selector: `${prefix}:has-text("${text}")`, score: 30 }); + } + return candidates; } function parentElementOrShadowHost(element: Element): Element | null { @@ -95,7 +171,27 @@ function parentElementOrShadowHost(element: Element): Element | null { return null; } -function cssSelectorForElement(injectedScript: InjectedScript, targetElement: Element): string { +function ancestorShadowRoot(element: Element): ShadowRoot | null { + while (element.parentElement) + element = element.parentElement; + if (element.parentNode && element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) + return element.parentNode as ShadowRoot; + return null; +} + +function findTargetLabel(element: Element): Element | null { + const docOrShadowRoot = ancestorShadowRoot(element) || element.ownerDocument!; + const labels = docOrShadowRoot.querySelectorAll('label'); + for (const element of labels) { + const label = element as HTMLLabelElement; + if (label.control === element) + return label; + } + return null; +} + +function cssFallback(injectedScript: InjectedScript, targetElement: Element): SelectorToken { + const kFallbackScore = 10000000; const root: Node = targetElement.ownerDocument; const tokens: string[] = []; @@ -118,7 +214,7 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El const token = /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(element.id) ? '#' + element.id : `[id="${element.id}"]`; const selector = uniqueCSSSelector(token); if (selector) - return selector; + return { engine: 'css', selector, score: kFallbackScore }; bestTokenForLevel = token; } @@ -130,7 +226,7 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El const token = '.' + classes.slice(0, i + 1).join('.'); const selector = uniqueCSSSelector(token); if (selector) - return selector; + return { engine: 'css', selector, score: kFallbackScore }; // Even if not unique, does this subset of classes uniquely identify node as a child? if (!bestTokenForLevel && parent) { const sameClassSiblings = parent.querySelectorAll(token); @@ -146,7 +242,7 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El const token = sameTagSiblings.indexOf(element) === 0 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`; const selector = uniqueCSSSelector(token); if (selector) - return selector; + return { engine: 'css', selector, score: kFallbackScore }; if (!bestTokenForLevel) bestTokenForLevel = token; } else if (!bestTokenForLevel) { @@ -154,56 +250,62 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El } tokens.unshift(bestTokenForLevel); } - return uniqueCSSSelector()!; -} - -function textSelectorForElement(node: Node): string | null { - const maxLength = 30; - let needsRegex = false; - let trimmedText: string | null = null; - for (const child of node.childNodes) { - if (child.nodeType !== Node.TEXT_NODE) - continue; - if (child.textContent && child.textContent.trim()) { - if (trimmedText) - return null; - trimmedText = child.textContent.trim().substr(0, maxLength); - needsRegex = child.textContent !== trimmedText; - } else { - needsRegex = true; - } - } - if (!trimmedText) - return null; - return needsRegex ? `/.*${escapeForRegex(trimmedText)}.*/` : `"${trimmedText}"`; + return { engine: 'css', selector: uniqueCSSSelector()!, score: kFallbackScore }; } function escapeForRegex(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.replace(/[.*+?^>${}()|[\]\\]/g, '\\$&'); } function quoteString(text: string): string { - return `"${text.replaceAll(/"/g, '\\"')}"`; + return `"${text.replaceAll(/"/g, '\\"').replaceAll(/\n/g, '\\n')}"`; } -type SelectorToken = { - engine: string; - selector: string; -}; - -function joinSelector(path: SelectorToken[]): string { - const tokens = []; +function joinTokens(tokens: SelectorToken[]): string { + const parts = []; let lastEngine = ''; - for (const { engine, selector } of path) { - if (tokens.length && (lastEngine !== 'css' || engine !== 'css')) - tokens.push('>>'); + for (const { engine, selector } of tokens) { + if (parts.length && (lastEngine !== 'css' || engine !== 'css')) + parts.push('>>'); lastEngine = engine; if (engine === 'css') - tokens.push(selector); + parts.push(selector); else - tokens.push(`${engine}=${selector}`); + parts.push(`${engine}=${selector}`); + } + return parts.join(' '); +} + +function combineScores(tokens: SelectorToken[]): number { + let score = 0; + for (let i = 0; i < tokens.length; i++) + score += tokens[i].score * (tokens.length - i); + return score; +} + +function chooseFirstSelector(injectedScript: InjectedScript, scope: Element | Document, targetElement: Element, selectors: SelectorToken[][], allowNthMatch: boolean): SelectorToken[] | null { + const joined = selectors.map(tokens => ({ tokens, score: combineScores(tokens) })); + joined.sort((a, b) => a.score - b.score); + let bestWithIndex: SelectorToken[] | null = null; + for (const { tokens } of joined) { + const parsedSelector = injectedScript.parseSelector(joinTokens(tokens)); + const result = injectedScript.querySelectorAll(parsedSelector, scope); + const index = result.indexOf(targetElement); + if (index === 0) + return tokens; + if (!allowNthMatch || bestWithIndex || index === -1 || result.length > 5) + continue; + const allCss = tokens.map(token => { + if (token.engine !== 'text') + return token; + if (token.selector.startsWith('/') && token.selector.endsWith('/')) + return { engine: 'css', selector: `:text-matches("${token.selector.substring(1, token.selector.length - 1)}")`, score: token.score }; + return { engine: 'css', selector: `:text("${token.selector}")`, score: token.score }; + }); + const combined = joinTokens(allCss); + bestWithIndex = [{ engine: 'css', selector: `:nth-match(${combined}, ${index + 1})`, score: combineScores(allCss) + 1000 }]; } - return tokens.join(' '); + return bestWithIndex; } function isGuidLike(id: string): boolean { @@ -216,7 +318,7 @@ function isGuidLike(id: string): boolean { continue; if (c >= 'a' && c <= 'z') characterType = 'lower'; - else if (c >= 'A' && c <= 'Z') + else if (c >= 'A' && c <= 'Z') characterType = 'upper'; else if (c >= '0' && c <= '9') characterType = 'digit'; @@ -234,130 +336,3 @@ function isGuidLike(id: string): boolean { } return transitionCount >= id.length / 4; } - -function createXPath(root: Node, targetElement: Element): string | undefined { - const maxTextLength = 80; - const minMeaningfulSelectorLegth = 100; - - const maybeDocument = root instanceof Document ? root : root.ownerDocument; - if (!maybeDocument) - return; - const document = maybeDocument; - - const xpathCache = new Map(); - const tokens: string[] = []; - - function evaluateXPath(expression: string): Element[] { - let nodes: Element[] | undefined = xpathCache.get(expression); - if (!nodes) { - nodes = []; - try { - const result = document.evaluate(expression, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE); - for (let node = result.iterateNext(); node; node = result.iterateNext()) { - if (node.nodeType === Node.ELEMENT_NODE) - nodes.push(node as Element); - } - } catch (e) { - } - xpathCache.set(expression, nodes); - } - return nodes; - } - - function uniqueXPathSelector(prefix?: string): string | undefined { - const path = tokens.slice(); - if (prefix) - path.unshift(prefix); - let selector = '//' + path.join('/'); - while (selector.includes('///')) - selector = selector.replace('///', '//'); - if (selector.endsWith('/')) - selector = selector.substring(0, selector.length - 1); - const nodes: Element[] = evaluateXPath(selector); - if (nodes[0] === targetElement) - return selector; - - // If we are looking at a small set of elements with long selector, fall back to ordinal. - if (nodes.length < 5 && selector.length > minMeaningfulSelectorLegth) { - const index = nodes.indexOf(targetElement); - if (index !== -1) - return `(${selector})[${index + 1}]`; - } - return undefined; - } - - function escapeAndCap(text: string) { - text = text.substring(0, maxTextLength); - // XPath 1.0 does not support quote escaping. - // 1. If there are no single quotes - use them. - if (text.indexOf(`'`) === -1) - return `'${text}'`; - // 2. If there are no double quotes - use them to enclose text. - if (text.indexOf(`"`) === -1) - return `"${text}"`; - // 3. Otherwise, use popular |concat| trick. - const Q = `'`; - return `concat(${text.split(Q).map(token => Q + token + Q).join(`, "'", `)})`; - } - - const defaultAttributes = new Set([ 'title', 'aria-label', 'disabled', 'role' ]); - const importantAttributes = new Map([ - [ 'form', [ 'action' ] ], - [ 'img', [ 'alt' ] ], - [ 'input', [ 'placeholder', 'type', 'name' ] ], - [ 'textarea', [ 'placeholder', 'type', 'name' ] ], - ]); - - let usedTextConditions = false; - for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) { - const nodeName = element.nodeName.toLowerCase(); - const tag = nodeName === 'svg' ? '*' : nodeName; - - const tagConditions = []; - if (nodeName === 'svg') - tagConditions.push('local-name()="svg"'); - - const attrConditions: string[] = []; - const importantAttrs = [ ...defaultAttributes, ...(importantAttributes.get(tag) || []) ]; - for (const attr of importantAttrs) { - const value = element.getAttribute(attr); - if (value && value.length < maxTextLength) - attrConditions.push(`normalize-space(@${attr})=${escapeAndCap(value)}`); - else if (value) - attrConditions.push(`starts-with(normalize-space(@${attr}), ${escapeAndCap(value)})`); - } - - const text = document.evaluate('normalize-space(.)', element).stringValue; - const textConditions = []; - if (tag !== 'select' && text.length && !usedTextConditions) { - if (text.length < maxTextLength) - textConditions.push(`normalize-space(.)=${escapeAndCap(text)}`); - else - textConditions.push(`starts-with(normalize-space(.), ${escapeAndCap(text)})`); - usedTextConditions = true; - } - - // Always retain the last tag. - const conditions = [ ...tagConditions, ...textConditions, ...attrConditions ]; - const token = conditions.length ? `${tag}[${conditions.join(' and ')}]` : (tokens.length ? '' : tag); - const selector = uniqueXPathSelector(token); - if (selector) - return selector; - - const parent = element.parentElement; - let ordinal = -1; - if (parent) { - const siblings = Array.from(parent.children); - const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName); - if (sameTagSiblings.length > 1) - ordinal = sameTagSiblings.indexOf(element); - } - - // Do not include text into this token, only tag / attributes. - // Topmost node will get all the text. - const conditionsString = conditions.length ? `[${conditions.join(' and ')}]` : ''; - const ordinalString = ordinal >= 0 ? `[${ordinal + 1}]` : ''; - tokens.unshift(`${tag}${ordinalString}${conditionsString}`); - } - return uniqueXPathSelector(); -} diff --git a/test/cli/cli-codegen.spec.ts b/test/cli/cli-codegen.spec.ts index 950cb98c424e0..384714ac69ffd 100644 --- a/test/cli/cli-codegen.spec.ts +++ b/test/cli/cli-codegen.spec.ts @@ -27,7 +27,7 @@ describe('cli codegen', (test, { browserName, headful }) => { await recorder.setContentAndWait(``); const selector = await recorder.hoverOverElement('button'); - expect(selector).toBe('text="Submit"'); + expect(selector).toBe('text=Submit'); const [message] = await Promise.all([ page.waitForEvent('console'), @@ -35,8 +35,8 @@ describe('cli codegen', (test, { browserName, headful }) => { page.dispatchEvent('button', 'click', { detail: 1 }) ]); expect(recorder.output()).toContain(` - // Click text="Submit" - await page.click('text="Submit"');`); + // Click text=Submit + await page.click('text=Submit');`); expect(message.text()).toBe('click'); }); @@ -55,7 +55,7 @@ describe('cli codegen', (test, { browserName, headful }) => { await page.waitForTimeout(1000); const selector = await recorder.hoverOverElement('button'); - expect(selector).toBe('text="Submit"'); + expect(selector).toBe('text=Submit'); const [message] = await Promise.all([ page.waitForEvent('console'), @@ -63,8 +63,8 @@ describe('cli codegen', (test, { browserName, headful }) => { page.dispatchEvent('button', 'click', { detail: 1 }) ]); expect(recorder.output()).toContain(` - // Click text="Submit" - await page.click('text="Submit"');`); + // Click text=Submit + await page.click('text=Submit');`); expect(message.text()).toBe('click'); }); @@ -83,7 +83,7 @@ describe('cli codegen', (test, { browserName, headful }) => { }); const selector = await recorder.hoverOverElement('div'); - expect(selector).toBe('text=/.*Some long text here.*/'); + expect(selector).toBe('text=Some long text here'); // Sanity check that selector does not match our highlight. const divContents = await page.$eval(selector, div => div.outerHTML); @@ -95,8 +95,8 @@ describe('cli codegen', (test, { browserName, headful }) => { page.dispatchEvent('div', 'click', { detail: 1 }) ]); expect(recorder.output()).toContain(` - // Click text=/.*Some long text here.*/ - await page.click('text=/.*Some long text here.*/');`); + // Click text=Some long text here + await page.click('text=Some long text here');`); expect(message.text()).toBe('click'); }); @@ -279,7 +279,7 @@ describe('cli codegen', (test, { browserName, headful }) => { await recorder.setContentAndWait(''); const selector = await recorder.hoverOverElement('select'); - expect(selector).toBe('select[id="age"]'); + expect(selector).toBe('select'); const [message] = await Promise.all([ page.waitForEvent('console'), @@ -288,7 +288,7 @@ describe('cli codegen', (test, { browserName, headful }) => { ]); expect(recorder.output()).toContain(` // Select 2 - await page.selectOption('select[id="age"]', '2');`); + await page.selectOption('select', '2');`); expect(message.text()).toBe('2'); }); @@ -298,7 +298,7 @@ describe('cli codegen', (test, { browserName, headful }) => { await recorder.setContentAndWait('link'); const selector = await recorder.hoverOverElement('a'); - expect(selector).toBe('text="link"'); + expect(selector).toBe('text=link'); const [popup] = await Promise.all([ page.context().waitForEvent('page'), @@ -306,10 +306,10 @@ describe('cli codegen', (test, { browserName, headful }) => { page.dispatchEvent('a', 'click', { detail: 1 }) ]); expect(recorder.output()).toContain(` - // Click text="link" + // Click text=link const [page1] = await Promise.all([ page.waitForEvent('popup'), - page.click('text="link"') + page.click('text=link') ]);`); expect(popup.url()).toBe('about:blank'); }); @@ -318,15 +318,15 @@ describe('cli codegen', (test, { browserName, headful }) => { await recorder.setContentAndWait(`link`); const selector = await recorder.hoverOverElement('a'); - expect(selector).toBe('text="link"'); + expect(selector).toBe('text=link'); await Promise.all([ page.waitForNavigation(), recorder.waitForOutput('assert'), page.dispatchEvent('a', 'click', { detail: 1 }) ]); expect(recorder.output()).toContain(` - // Click text="link" - await page.click('text="link"'); + // Click text=link + await page.click('text=link'); // assert.equal(page.url(), 'about:blank#foo');`); expect(page.url()).toContain('about:blank#foo'); }); @@ -336,7 +336,7 @@ describe('cli codegen', (test, { browserName, headful }) => { await recorder.setContentAndWait(`link`); const selector = await recorder.hoverOverElement('a'); - expect(selector).toBe('text="link"'); + expect(selector).toBe('text=link'); await Promise.all([ page.waitForNavigation(), @@ -344,10 +344,10 @@ describe('cli codegen', (test, { browserName, headful }) => { page.dispatchEvent('a', 'click', { detail: 1 }) ]); expect(recorder.output()).toContain(` - // Click text="link" + // Click text=link await Promise.all([ page.waitForNavigation(/*{ url: 'about:blank#foo' }*/), - page.click('text="link"') + page.click('text=link') ]);`); expect(page.url()).toContain('about:blank#foo'); }); @@ -371,14 +371,14 @@ describe('cli codegen', (test, { browserName, headful }) => { await recorder.waitForOutput('page.close();'); }); - it('should not lead to an error if /html gets clicked', async ({ contextWrapper, recorder }) => { + it('should not lead to an error if html gets clicked', async ({ contextWrapper, recorder }) => { await recorder.setContentAndWait(''); await contextWrapper.context.newPage(); const errors: any[] = []; recorder.page.on('pageerror', e => errors.push(e)); await recorder.page.evaluate(() => document.querySelector('body').remove()); const selector = await recorder.hoverOverElement('html'); - expect(selector).toBe('/html'); + expect(selector).toBe('html'); await recorder.page.close(); await recorder.waitForOutput('page.close();'); expect(errors.length).toBe(0); @@ -455,10 +455,10 @@ describe('cli codegen', (test, { browserName, headful }) => { page.click('text=Download') ]); await recorder.waitForOutput(` - // Click text="Download" + // Click text=Download const [download] = await Promise.all([ page.waitForEvent('download'), - page.click('text="Download"') + page.click('text=Download') ]);`); }); @@ -470,14 +470,14 @@ describe('cli codegen', (test, { browserName, headful }) => { page.once('dialog', async dialog => { await dialog.dismiss(); }); - await page.click('text="click me"'); + await page.click('text=click me'); await recorder.waitForOutput(` - // Click text="click me" + // Click text=click me page.once('dialog', dialog => { console.log(\`Dialog message: $\{dialog.message()}\`); dialog.dismiss().catch(() => {}); }); - await page.click('text="click me"')`); + await page.click('text=click me')`); }); it('should handle history.postData', async ({ page, recorder, httpServer }) => { @@ -504,7 +504,7 @@ describe('cli codegen', (test, { browserName, headful }) => { await recorder.setContentAndWait(`link`); const selector = await recorder.hoverOverElement('a'); - expect(selector).toBe('text="link"'); + expect(selector).toBe('text=link'); await page.click('a', { modifiers: [ platform === 'darwin' ? 'Meta' : 'Control'] }); await recorder.waitForOutput('page1'); @@ -515,10 +515,10 @@ describe('cli codegen', (test, { browserName, headful }) => { page1.goto('about:blank?foo');`); } else if (browserName === 'firefox') { expect(recorder.output()).toContain(` - // Click text="link" + // Click text=link const [page1] = await Promise.all([ page.waitForEvent('popup'), - page.click('text="link"', { + page.click('text=link', { modifiers: ['${platform === 'darwin' ? 'Meta' : 'Control'}'] }) ]);`); @@ -546,8 +546,8 @@ describe('cli codegen', (test, { browserName, headful }) => { await popup2.type('input', 'TextB'); await recorder.waitForOutput('TextB'); - expect(recorder.output()).toContain(`await page1.fill('input[id="name"]', 'TextA');`); - expect(recorder.output()).toContain(`await page2.fill('input[id="name"]', 'TextB');`); + expect(recorder.output()).toContain(`await page1.fill('input', 'TextA');`); + expect(recorder.output()).toContain(`await page2.fill('input', 'TextB');`); }); it('click should emit events in order', async ({ page, recorder }) => { @@ -613,30 +613,30 @@ describe('cli codegen', (test, { browserName, headful }) => { frameOne.click('div'), ]); expect(recorder.output()).toContain(` - // Click text="Hi, I'm frame" + // Click text=Hi, I'm frame await page.frame({ name: 'one' - }).click('text="Hi, I\\'m frame"');`); + }).click('text=Hi, I\\'m frame');`); await Promise.all([ recorder.waitForOutput('two'), frameTwo.click('div'), ]); expect(recorder.output()).toContain(` - // Click text="Hi, I'm frame" + // Click text=Hi, I'm frame await page.frame({ name: 'two' - }).click('text="Hi, I\\'m frame"');`); + }).click('text=Hi, I\\'m frame');`); await Promise.all([ recorder.waitForOutput('url: \''), otherFrame.click('div'), ]); expect(recorder.output()).toContain(` - // Click text="Hi, I'm frame" + // Click text=Hi, I'm frame await page.frame({ url: '${otherFrame.url()}' - }).click('text="Hi, I\\'m frame"');`); + }).click('text=Hi, I\\'m frame');`); }); it('should record navigations after identical pushState', async ({ page, recorder, httpServer }) => { diff --git a/test/selector-generator.spec.ts b/test/selector-generator.spec.ts index 5c010d601529d..e7117cda16625 100644 --- a/test/selector-generator.spec.ts +++ b/test/selector-generator.spec.ts @@ -31,19 +31,62 @@ async function generate(pageOrFrame: Page | Frame, target: string): Promise { suite.skip(mode !== 'default'); }, () => { - it('should generate for text', async ({ page }) => { - await page.setContent(`
Text
`); - expect(await generate(page, 'div')).toBe('text="Text"'); + it('should prefer button over inner span', async ({ page }) => { + await page.setContent(``); + expect(await generate(page, 'button')).toBe('#clickme'); + }); + + it('should prefer role=button over inner span', async ({ page }) => { + await page.setContent(`
`); + expect(await generate(page, 'div')).toBe('div[role="button"]'); + }); + + it('should generate text and normalize whitespace', async ({ page }) => { + await page.setContent(`
Text some\n\n\n more \t text
`); + expect(await generate(page, 'div')).toBe('text=Text some more text'); + }); + + it('should generate text for ', async ({ page }) => { + await page.setContent(``); + expect(await generate(page, 'input')).toBe('text=Click me'); + }); + + it('should trim text', async ({ page }) => { + await page.setContent(`
Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789
`); + expect(await generate(page, 'div')).toBe('text=Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text012345'); + }); + + it('should escape text with >>', async ({ page }) => { + await page.setContent(`
text>>text
`); + expect(await generate(page, 'div')).toBe('text=/.*text\\>\\>text.*/'); + }); + + it('should escape text with quote', async ({ page }) => { + await page.setContent(`
text"text
`); + expect(await generate(page, 'div')).toBe('text=/.*text"text.*/'); + }); + + it('should escape text with slash', async ({ page }) => { + await page.setContent(`
/text
`); + expect(await generate(page, 'div')).toBe('text=/.*\/text.*/'); + }); + + it('should not use text for select', async ({ page }) => { + await page.setContent(` + + + `); + expect(await generate(page, '[mark="1"]')).toBe(':nth-match(select, 2)'); }); it('should use ordinal for identical nodes', async ({ page }) => { await page.setContent(`
Text
Text
Text
Text
`); - expect(await generate(page, 'div[mark="1"]')).toBe('//div[3][normalize-space(.)=\'Text\']'); + expect(await generate(page, 'div[mark="1"]')).toBe(`:nth-match(:text("Text"), 3)`); }); it('should prefer data-testid', async ({ page }) => { await page.setContent(`
Text
Text
Text
Text
`); - expect(await generate(page, 'div[data-testid="a"]')).toBe('div[data-testid="a"]'); + expect(await generate(page, '[data-testid="a"]')).toBe('[data-testid="a"]'); }); it('should handle first non-unique data-testid', async ({ page }) => { @@ -54,7 +97,7 @@ describe('selector generator', (suite, { mode }) => {
Text
`); - expect(await generate(page, 'div[mark="1"]')).toBe('div[data-testid="a"]'); + expect(await generate(page, 'div[mark="1"]')).toBe('[data-testid="a"]'); }); it('should handle second non-unique data-testid', async ({ page }) => { @@ -65,7 +108,7 @@ describe('selector generator', (suite, { mode }) => {
Text
`); - expect(await generate(page, 'div[mark="1"]')).toBe('//div[2][normalize-space(.)=\'Text\']'); + expect(await generate(page, 'div[mark="1"]')).toBe(`:nth-match([data-testid="a"], 2)`); }); it('should use readable id', async ({ page }) => { @@ -73,7 +116,7 @@ describe('selector generator', (suite, { mode }) => {
`); - expect(await generate(page, 'div[mark="1"]')).toBe('div[id="first-item"]'); + expect(await generate(page, 'div[mark="1"]')).toBe('#first-item'); }); it('should not use generated id', async ({ page }) => { @@ -81,7 +124,31 @@ describe('selector generator', (suite, { mode }) => {
`); - expect(await generate(page, 'div[mark="1"]')).toBe('//div[2]'); + expect(await generate(page, 'div[mark="1"]')).toBe(`:nth-match(div, 2)`); + }); + + it('should use has-text', async ({ page }) => { + await page.setContent(` +
Hello world
+ Hello world + `); + expect(await generate(page, 'a')).toBe(`a:has-text("Hello world")`); + }); + + it('should chain text after parent', async ({ page }) => { + await page.setContent(` +
Hello world
+ Hello world + `); + expect(await generate(page, '[mark="1"]')).toBe(`a >> text=world`); + }); + + it('should use parent text', async ({ page }) => { + await page.setContent(` +
Hello world
+
Goodbye world
+ `); + expect(await generate(page, '[mark="1"]')).toBe(`text=Goodbye world >> span`); }); it('should separate selectors by >>', async ({ page }) => { @@ -93,7 +160,7 @@ describe('selector generator', (suite, { mode }) => {
Text
`); - expect(await generate(page, '#id > div')).toBe('div[id=\"id\"] >> text=\"Text\"'); + expect(await generate(page, '#id > div')).toBe('#id >> text=Text'); }); it('should trim long text', async ({ page }) => { @@ -105,12 +172,12 @@ describe('selector generator', (suite, { mode }) => {
Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on
`); - expect(await generate(page, '#id > div')).toBe('div[id=\"id\"] >> text=/.*Text that goes on and on and o.*/'); + expect(await generate(page, '#id > div')).toBe(`#id >> text=Text that goes on and on and on and on and on and on and on and on and on and on`); }); it('should use nested ordinals', async ({ page }) => { await page.setContent(` - + @@ -122,7 +189,7 @@ describe('selector generator', (suite, { mode }) => { `); - expect(await generate(page, 'c[mark="1"]')).toBe('//b[2]/c'); + expect(await generate(page, 'c[mark="1"]')).toBe('b:nth-child(2) c'); }); it('should not use input[value]', async ({ page }) => { @@ -131,7 +198,7 @@ describe('selector generator', (suite, { mode }) => { `); - expect(await generate(page, 'input[mark="1"]')).toBe('//input[2]'); + expect(await generate(page, 'input[mark="1"]')).toBe(':nth-match(input, 2)'); }); describe('should prioritise input element attributes correctly', () => { @@ -141,7 +208,7 @@ describe('selector generator', (suite, { mode }) => { }); it('placeholder', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'input')).toBe('input[placeholder="foobar"]'); + expect(await generate(page, 'input')).toBe('[placeholder="foobar"]'); }); it('type', async ({ page }) => { await page.setContent(``); @@ -157,10 +224,10 @@ describe('selector generator', (suite, { mode }) => { span.textContent = 'Target'; shadowRoot.appendChild(span); }); - expect(await generate(page, 'span')).toBe('text="Target"'); + expect(await generate(page, 'span')).toBe('text=Target'); }); - it('should fallback to css in shadow dom', async ({ page }) => { + it('should match in shadow dom', async ({ page }) => { await page.setContent(`
`); await page.$eval('div', div => { const shadowRoot = div.attachShadow({ mode: 'open' }); @@ -170,7 +237,7 @@ describe('selector generator', (suite, { mode }) => { expect(await generate(page, 'input')).toBe('input'); }); - it('should fallback to css in deep shadow dom', async ({ page }) => { + it('should match in deep shadow dom', async ({ page }) => { await page.setContent(`
`); await page.$eval('div', div1 => { const shadowRoot1 = div1.attachShadow({ mode: 'open' }); @@ -185,7 +252,7 @@ describe('selector generator', (suite, { mode }) => { input2.setAttribute('value', 'foo'); shadowRoot2.appendChild(input2); }); - expect(await generate(page, 'input[value=foo]')).toBe('div div:nth-child(3) input'); + expect(await generate(page, 'input[value=foo]')).toBe(':nth-match(input, 3)'); }); it('should work in dynamic iframes without navigation', async ({ page }) => { @@ -203,6 +270,6 @@ describe('selector generator', (suite, { mode }) => { }); }), ]); - expect(await generate(frame, 'div')).toBe('text="Target"'); + expect(await generate(frame, 'div')).toBe('text=Target'); }); });