diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index 7af0d2d938292..f4898d6bbab23 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -156,24 +156,39 @@ export class InjectedScript { } private _createTextEngine(shadow: boolean): SelectorEngine { + const queryList = (root: SelectorRoot, selector: string, single: boolean): Element[] => { + const { matcher, strict } = createTextMatcher(selector); + const result: Element[] = []; + let lastDidNotMatchSelf: Element | null = null; + + const checkElement = (element: Element) => { + // TODO: replace contains() with something shadow-dom-aware? + if (!strict && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element)) + return false; + const matches = elementMatchesText(this._evaluator, element, matcher); + if (matches === 'none') + lastDidNotMatchSelf = element; + if (matches === 'self') + result.push(element); + return single && result.length > 0; + }; + + if (root.nodeType === Node.ELEMENT_NODE && checkElement(root as Element)) + return result; + const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*'); + for (const element of elements) { + if (checkElement(element)) + return result; + } + return result; + }; + return { query: (root: SelectorRoot, selector: string): Element | undefined => { - const matcher = createTextMatcher(selector); - if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher)) - return root as Element; - const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*'); - for (const element of elements) { - if (elementMatchesText(this._evaluator, element, matcher)) - return element; - } + return queryList(root, selector, true)[0]; }, queryAll: (root: SelectorRoot, selector: string): Element[] => { - const matcher = createTextMatcher(selector); - const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*'); - const result = elements.filter(e => elementMatchesText(this._evaluator, e, matcher)); - if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher)) - result.unshift(root as Element); - return result; + return queryList(root, selector, false); } }; } @@ -823,11 +838,11 @@ function unescape(s: string): string { } type Matcher = (text: string) => boolean; -function createTextMatcher(selector: string): Matcher { +function createTextMatcher(selector: string): { matcher: Matcher, strict: boolean } { if (selector[0] === '/' && selector.lastIndexOf('/') > 0) { const lastSlash = selector.lastIndexOf('/'); const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1)); - return text => re.test(text); + return { matcher: text => re.test(text), strict: true }; } let strict = false; if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') { @@ -841,12 +856,13 @@ function createTextMatcher(selector: string): Matcher { selector = selector.trim().replace(/\s+/g, ' '); if (!strict) selector = selector.toLowerCase(); - return text => { + const matcher = (text: string) => { text = text.trim().replace(/\s+/g, ' '); if (!strict) return text.toLowerCase().includes(selector); return text === selector; }; + return { matcher, strict }; } export default InjectedScript; diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index 129e8691fd7ce..cdbdbe023fda6 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -119,7 +119,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { const selector = this._checkSelector(s); this.begin(); try { - return this._cached(this._cacheMatches, element, [selector, context], () => { + return this._cached(this._cacheMatches, element, [selector, context.scope, context.pierceShadow], () => { if (Array.isArray(selector)) return this._matchesEngine(isEngine, element, selector, context); if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context)) @@ -135,7 +135,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { const selector = this._checkSelector(s); this.begin(); try { - return this._cached(this._cacheQuery, selector, [context], () => { + return this._cached(this._cacheQuery, selector, [context.scope, context.pierceShadow], () => { if (Array.isArray(selector)) return this._queryEngine(isEngine, context, selector); @@ -174,7 +174,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { } private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean { - return this._cached(this._cacheMatchesSimple, element, [simple, context], () => { + return this._cached(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow], () => { const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is'); if (!isPossiblyScopeClause && element === context.scope) return false; @@ -192,7 +192,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { if (!simple.functions.length) return this._queryCSS(context, simple.css || '*'); - return this._cached(this._cacheQuerySimple, simple, [context], () => { + return this._cached(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow], () => { let css = simple.css; const funcs = simple.functions; if (css === '*' && funcs.length) @@ -229,7 +229,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean { if (index < 0) return true; - return this._cached(this._cacheMatchesParents, element, [complex, index, context], () => { + return this._cached(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow], () => { const { selector: simple, combinator } = complex.simples[index]; if (combinator === '>') { const parent = parentElementOrShadowHostInContext(element, context); @@ -303,13 +303,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { } private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean { - return this._cached(this._cacheCallMatches, element, [engine, args, context.scope, context.pierceShadow], () => { + return this._cached(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, ...args], () => { return engine.matches!(element, args, context, this); }); } private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] { - return this._cached(this._cacheCallQuery, args, [engine, context.scope, context.pierceShadow], () => { + return this._cached(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, ...args], () => { return engine.query!(context, args, this); }); } @@ -319,7 +319,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { } _queryCSS(context: QueryContext, css: string): Element[] { - return this._cached(this._cacheQueryCSS, css, [context], () => { + return this._cached(this._cacheQueryCSS, css, [context.scope, context.pierceShadow], () => { let result: Element[] = []; function query(root: Element | ShadowRoot | Document) { result = result.concat([...root.querySelectorAll(css)]); @@ -428,7 +428,7 @@ const textEngine: SelectorEngine = { if (args.length !== 1 || typeof args[0] !== 'string') throw new Error(`"text" engine expects a single string`); const matcher = textMatcher(args[0], true); - return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher); + return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self'; }, }; @@ -437,7 +437,7 @@ const textIsEngine: SelectorEngine = { if (args.length !== 1 || typeof args[0] !== 'string') throw new Error(`"text-is" engine expects a single string`); const matcher = textMatcher(args[0], false); - return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher); + return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self'; }, }; @@ -447,7 +447,7 @@ const textMatchesEngine: SelectorEngine = { throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`); const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined); const matcher = (s: string) => re.test(s); - return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher); + return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self'; }, }; @@ -499,18 +499,18 @@ export function elementText(evaluator: SelectorEvaluatorImpl, root: Element | Sh return value; } -export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: (s: string) => boolean): boolean { +export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: (s: string) => boolean): 'none' | 'self' | 'selfAndChildren' { if (shouldSkipForTextMatching(element)) - return false; + return 'none'; if (!matcher(elementText(evaluator, element))) - return false; + return 'none'; for (let child = element.firstChild; child; child = child.nextSibling) { if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element))) - return false; + return 'selfAndChildren'; } if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot))) - return false; - return true; + return 'selfAndChildren'; + return 'self'; } function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {