diff --git a/src/server/injected/attributeSelectorEngine.ts b/src/server/injected/attributeSelectorEngine.ts deleted file mode 100644 index 4965d81acf..0000000000 --- a/src/server/injected/attributeSelectorEngine.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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 { SelectorEngine, SelectorRoot } from './selectorEngine'; - -export function createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine { - const engine: SelectorEngine = { - query(root: SelectorRoot, selector: string): Element | undefined { - if (!shadow) - return root.querySelector(`[${attribute}=${JSON.stringify(selector)}]`) || undefined; - return queryShadowInternal(root, attribute, selector); - }, - - queryAll(root: SelectorRoot, selector: string): Element[] { - if (!shadow) - return Array.from(root.querySelectorAll(`[${attribute}=${JSON.stringify(selector)}]`)); - const result: Element[] = []; - queryShadowAllInternal(root, attribute, selector, result); - return result; - } - }; - return engine; -} - -function queryShadowInternal(root: SelectorRoot, attribute: string, value: string): Element | undefined { - const single = root.querySelector(`[${attribute}=${JSON.stringify(value)}]`); - if (single) - return single; - const all = root.querySelectorAll('*'); - for (let i = 0; i < all.length; i++) { - const shadowRoot = all[i].shadowRoot; - if (shadowRoot) { - const single = queryShadowInternal(shadowRoot, attribute, value); - if (single) - return single; - } - } -} - -function queryShadowAllInternal(root: SelectorRoot, attribute: string, value: string, result: Element[]) { - const document = root instanceof Document ? root : root.ownerDocument; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - const shadowRoots = []; - while (walker.nextNode()) { - const element = walker.currentNode as Element; - if (element.getAttribute(attribute) === value) - result.push(element); - if (element.shadowRoot) - shadowRoots.push(element.shadowRoot); - } - for (const shadowRoot of shadowRoots) - queryShadowAllInternal(shadowRoot, attribute, value, result); -} diff --git a/src/server/injected/cssSelectorEngine.ts b/src/server/injected/cssSelectorEngine.ts deleted file mode 100644 index 4002ac62e8..0000000000 --- a/src/server/injected/cssSelectorEngine.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * 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 { SelectorEngine, SelectorRoot } from './selectorEngine'; - -export function createCSSEngine(shadow: boolean): SelectorEngine { - const engine: SelectorEngine = { - query(root: SelectorRoot, selector: string): Element | undefined { - // TODO: uncomment for performance. - // const simple = root.querySelector(selector); - // if (simple) - // return simple; - // if (!shadow) - // return; - const selectors = split(selector); - // Note: we do not just merge results produced by each selector, as that - // will not return them in the tree traversal order, but rather in the selectors - // matching order. - if (!selectors.length) - return; - return queryShadowInternal(root, root, selectors, shadow); - }, - - queryAll(root: SelectorRoot, selector: string): Element[] { - // TODO: uncomment for performance. - // if (!shadow) - // return Array.from(root.querySelectorAll(selector)); - const result: Element[] = []; - const selectors = split(selector); - // Note: we do not just merge results produced by each selector, as that - // will not return them in the tree traversal order, but rather in the selectors - // matching order. - if (selectors.length) - queryShadowAllInternal(root, root, selectors, shadow, result); - return result; - } - }; - (engine as any)._test = () => test(engine); - return engine; -} - -function queryShadowInternal(boundary: SelectorRoot, root: SelectorRoot, selectors: string[][], shadow: boolean): Element | undefined { - let elements: NodeListOf | undefined; - if (selectors.length === 1) { - // Fast path for a single selector - query only matching elements, not all. - const parts = selectors[0]; - const matching = root.querySelectorAll(parts[0]); - for (const element of matching) { - // If there is a single part, there are no ancestors to match. - if (parts.length === 1 || ancestorsMatch(element, parts, boundary)) - return element; - } - } else { - // Multiple selectors: visit each element in tree-traversal order and check whether it matches. - elements = root.querySelectorAll('*'); - for (const element of elements) { - for (const parts of selectors) { - if (!element.matches(parts[0])) - continue; - // If there is a single part, there are no ancestors to match. - if (parts.length === 1 || ancestorsMatch(element, parts, boundary)) - return element; - } - } - } - - // Visit shadow dom after the light dom to preserve the tree-traversal order. - if (!shadow) - return; - if ((root as Element).shadowRoot) { - const child = queryShadowInternal(boundary, (root as Element).shadowRoot!, selectors, shadow); - if (child) - return child; - } - if (!elements) - elements = root.querySelectorAll('*'); - for (const element of elements) { - if (element.shadowRoot) { - const child = queryShadowInternal(boundary, element.shadowRoot, selectors, shadow); - if (child) - return child; - } - } -} - -function queryShadowAllInternal(boundary: SelectorRoot, root: SelectorRoot, selectors: string[][], shadow: boolean, result: Element[]) { - let elements: NodeListOf | undefined; - if (selectors.length === 1) { - // Fast path for a single selector - query only matching elements, not all. - const parts = selectors[0]; - const matching = root.querySelectorAll(parts[0]); - for (const element of matching) { - // If there is a single part, there are no ancestors to match. - if (parts.length === 1 || ancestorsMatch(element, parts, boundary)) - result.push(element); - } - } else { - // Multiple selectors: visit each element in tree-traversal order and check whether it matches. - elements = root.querySelectorAll('*'); - for (const element of elements) { - for (const parts of selectors) { - if (!element.matches(parts[0])) - continue; - // If there is a single part, there are no ancestors to match. - if (parts.length === 1 || ancestorsMatch(element, parts, boundary)) - result.push(element); - } - } - } - - // Visit shadow dom after the light dom to preserve the tree-traversal order. - if (!shadow) - return; - if ((root as Element).shadowRoot) - queryShadowAllInternal(boundary, (root as Element).shadowRoot!, selectors, shadow, result); - if (!elements) - elements = root.querySelectorAll('*'); - for (const element of elements) { - if (element.shadowRoot) - queryShadowAllInternal(boundary, element.shadowRoot, selectors, shadow, result); - } -} - -function ancestorsMatch(element: Element | undefined, parts: string[], boundary: SelectorRoot): boolean { - let i = 1; - while (i < parts.length && (element = parentElementOrShadowHost(element!)) && element !== boundary) { - if (element.matches(parts[i])) - i++; - } - return i === parts.length; -} - -function parentElementOrShadowHost(element: Element): Element | undefined { - if (element.parentElement) - return element.parentElement; - if (!element.parentNode) - return; - if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host) - return (element.parentNode as ShadowRoot).host; -} - -// Splits the string into separate selectors by comma, and then each selector by the descendant combinator (space). -// Parts of each selector are reversed, so that the first one matches the target element. -function split(selector: string): string[][] { - let index = 0; - let quote: string | undefined; - let insideAttr = false; - let start = 0; - const result: string[][] = []; - let current: string[] = []; - const appendToCurrent = () => { - const part = selector.substring(start, index).trim(); - if (part.length) - current.push(part); - }; - const appendToResult = () => { - appendToCurrent(); - result.push(current); - current = []; - }; - const isCombinator = (char: string) => { - return char === '>' || char === '+' || char === '~'; - }; - const peekForward = () => { - return selector.substring(index).trim()[0]; - }; - const peekBackward = () => { - const s = selector.substring(0, index).trim(); - return s[s.length - 1]; - }; - while (index < selector.length) { - const c = selector[index]; - if (!quote && !insideAttr && c === ' ' && !isCombinator(peekForward()) && !isCombinator(peekBackward())) { - appendToCurrent(); - start = index; - index++; - } else { - if (c === '\\' && index + 1 < selector.length) { - index += 2; - } else if (c === quote) { - quote = undefined; - index++; - } else if (!quote && (c === '\'' || c === '"')) { - quote = c; - index++; - } else if (!quote && c === '[') { - insideAttr = true; - index++; - } else if (!quote && insideAttr && c === ']') { - insideAttr = false; - index++; - } else if (!quote && !insideAttr && c === ',') { - appendToResult(); - index++; - start = index; - } else { - index++; - } - } - } - appendToResult(); - return result.filter(parts => !!parts.length).map(parts => parts.reverse()); -} - -function test(engine: SelectorEngine) { - let id = 0; - - function createShadow(level: number): Element { - const root = document.createElement('div'); - root.id = 'id' + id; - root.textContent = 'root #id' + id; - id++; - const shadow = root.attachShadow({ mode: 'open' }); - for (let i = 0; i < 9; i++) { - const div = document.createElement('div'); - div.id = 'id' + id; - div.textContent = '#id' + id; - id++; - shadow.appendChild(div); - } - if (level) { - shadow.appendChild(createShadow(level - 1)); - shadow.appendChild(createShadow(level - 1)); - } - return root; - } - - const {query, queryAll} = engine; - - document.body.textContent = ''; - document.body.appendChild(createShadow(10)); - console.time('found'); - for (let i = 0; i < id; i += 17) { - const e = query(document, `div #id${i}`); - if (!e || e.id !== 'id' + i) - console.log(`div #id${i}`); // eslint-disable-line no-console - } - console.timeEnd('found'); - console.time('not found'); - for (let i = 0; i < id; i += 17) { - const e = query(document, `div div div div div #d${i}`); - if (e) - console.log(`div div div div div #d${i}`); // eslint-disable-line no-console - } - console.timeEnd('not found'); - console.log(query(document, '#id543 + #id544')); // eslint-disable-line no-console - console.log(query(document, '#id542 ~ #id545')); // eslint-disable-line no-console - console.time('all'); - queryAll(document, 'div div div + div'); - console.timeEnd('all'); -} diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index ee30eb4941..aeac733c96 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -14,14 +14,13 @@ * limitations under the License. */ -import { createAttributeEngine } from './attributeSelectorEngine'; import { SelectorEngine, SelectorRoot } from './selectorEngine'; import { createTextSelector } from './textSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser'; import { FatalDOMError } from '../common/domErrors'; import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost } from './selectorEvaluator'; -import { createCSSEngine } from './cssSelectorEngine'; +import { CSSComplexSelectorList } from '../common/cssParser'; type Predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol; @@ -46,20 +45,18 @@ export class InjectedScript { constructor(customEngines: { name: string, engine: SelectorEngine}[]) { this._enginesV1 = new Map(); - this._enginesV1.set('css', createCSSEngine(true)); - this._enginesV1.set('css:light', createCSSEngine(false)); this._enginesV1.set('xpath', XPathEngine); this._enginesV1.set('xpath:light', XPathEngine); this._enginesV1.set('text', createTextSelector(true)); this._enginesV1.set('text:light', createTextSelector(false)); - this._enginesV1.set('id', createAttributeEngine('id', true)); - this._enginesV1.set('id:light', createAttributeEngine('id', false)); - this._enginesV1.set('data-testid', createAttributeEngine('data-testid', true)); - this._enginesV1.set('data-testid:light', createAttributeEngine('data-testid', false)); - this._enginesV1.set('data-test-id', createAttributeEngine('data-test-id', true)); - this._enginesV1.set('data-test-id:light', createAttributeEngine('data-test-id', false)); - this._enginesV1.set('data-test', createAttributeEngine('data-test', true)); - this._enginesV1.set('data-test:light', createAttributeEngine('data-test', false)); + this._enginesV1.set('id', this._createAttributeEngine('id', true)); + this._enginesV1.set('id:light', this._createAttributeEngine('id', false)); + this._enginesV1.set('data-testid', this._createAttributeEngine('data-testid', true)); + this._enginesV1.set('data-testid:light', this._createAttributeEngine('data-testid', false)); + this._enginesV1.set('data-test-id', this._createAttributeEngine('data-test-id', true)); + this._enginesV1.set('data-test-id:light', this._createAttributeEngine('data-test-id', false)); + this._enginesV1.set('data-test', this._createAttributeEngine('data-test', true)); + this._enginesV1.set('data-test:light', this._createAttributeEngine('data-test', false)); for (const { name, engine } of customEngines) this._enginesV1.set(name, engine); @@ -133,6 +130,21 @@ export class InjectedScript { return this._enginesV1.get(part.name)!.queryAll(root, part.body); } + private _createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine { + const toCSS = (selector: string): CSSComplexSelectorList => { + const css = `[${attribute}=${JSON.stringify(selector)}]`; + return [{ simples: [{ selector: { css, functions: [] }, combinator: '' }] }]; + }; + return { + query: (root: SelectorRoot, selector: string): Element | undefined => { + return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector))[0]; + }, + queryAll: (root: SelectorRoot, selector: string): Element[] => { + return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector)); + } + }; + } + extend(source: string, params: any): any { const constrFunction = global.eval(source); return new constrFunction(this, params); diff --git a/src/server/selectors.ts b/src/server/selectors.ts index df105e0a04..f241ff00a8 100644 --- a/src/server/selectors.ts +++ b/src/server/selectors.ts @@ -31,7 +31,7 @@ export class Selectors { readonly _engines: Map; constructor() { - // Note: keep in sync with SelectorEvaluator class. + // Note: keep in sync with InjectedScript class. this._builtinEngines = new Set([ 'css', 'css:light', 'xpath', 'xpath:light',