diff --git a/docs/selectors.md b/docs/selectors.md index fe469c691f..f8f857b6fb 100644 --- a/docs/selectors.md +++ b/docs/selectors.md @@ -57,34 +57,15 @@ const handle = await divHandle.$('css=span'); ## Built-in selector engines -### css +### css and css:light -CSS engine is equivalent to [`Document.querySelector`](https://developer.mozilla.org/en/docs/Web/API/Document/querySelector). Example: `css=.article > span:nth-child(2) li`. +`css` is a default engine - any malformed selector not starting with `//` nor with `"` is assumed to be a css selector. For example, Playwright converts `page.$('span > button')` to `page.$('css=span > button')`. -> **NOTE** Malformed selector not starting with `//` nor with `"` is automatically transformed to css selector. For example, Playwright converts `page.$('span > button')` to `page.$('css=span > button')`. Selectors starting with `"` are converted to [text](#text). Selectors starting with `//` are converted to [xpath](#xpath). +`css:light` engine is equivalent to [`Document.querySelector`](https://developer.mozilla.org/en/docs/Web/API/Document/querySelector) and behaves according to the CSS spec. However, it does not pierce shadow roots, which may be incovenient when working with [Shadow DOM and Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM). For that reason, `css` engine pierces shadow roots. More specifically, every [Descendant combinator](https://developer.mozilla.org/en-US/docs/Web/CSS/Descendant_combinator) pierces an arbitrary number of open shadow roots, including the implicit descendant combinator at the start of the selector. -### xpath +`css` engine first searches for elements in the light dom in the iteration order, and then recursively inside open shadow roots in the iteration order. It does not search inside closed shadow roots or iframes. -XPath engine is equivalent to [`Document.evaluate`](https://developer.mozilla.org/en/docs/Web/API/Document/evaluate). Example: `xpath=//html/body`. - -> **NOTE** Malformed selector starting with `//` is automatically transformed to xpath selector. For example, Playwright converts `page.$('//html/body')` to `page.$('xpath=//html/body')`. - -### text - -Text engine finds an element that contains a text node with passed text. Example: `text=Login`. -- By default, the match is case-insensitive, ignores leading/trailing whitespace and searches for a substring. This means `text= Login` matches ``. -- Text body can be escaped with double quotes for precise matching, insisting on exact match, including specified whitespace and case. This means `text="Login "` will only match `` with exactly one space after "Login". -- Text body can also be a JavaScript-like regex wrapped in `/` symbols. This means `text=/^\\s*Login$/i` will match `` with any number of spaces before "Login" and no spaces after. - -> **NOTE** Text engine first searches for elements in the light dom in the iteration order, and then recursively inside open shadow roots in the iteration order. It does not search inside closed shadow roots or iframes. - -> **NOTE** Input elements of the type `button` and `submit` are rendered with their value as text, and text engine finds them. For example, `text=Login` matches ``. - -> **NOTE** Malformed selector starting with `"` is automatically transformed to text selector. For example, Playwright converts `page.click('"Login"')` to `page.click('text="Login"')`. - -### deep - -Deep engine is equivalent to CSS, but with every [Descendant combinator](https://developer.mozilla.org/en-US/docs/Web/CSS/Descendant_combinator) piercing open shadow roots, including the implicit descendant combinator at the start of the selector. [See this article](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM) for high-level overview of Shadow DOM. +#### Examples ```html
@@ -106,22 +87,39 @@ Deep engine is equivalent to CSS, but with every [Descendant combinator](https:/ Note that `` is not an html element, but rather a shadow root created with `element.attachShadow({mode: 'open'})`. -- `"deep=article div"` matches the first `
In the light dom
` -- `"deep=article > div"` matches two `div` elements that are direct children of the `article` -- `"deep=article .in-the-shadow"` matches the `
`, piercing the shadow root -- `"deep=article div > span"` matches the ``, piercing the shadow root -- `"deep=article > .in-the-shadow"` does not match anything, because `
` is not a direct child of `article` -- `"deep=article li#target"` matches the `
  • Deep in the shadow
  • `, piercing two shadow roots +- Both `"css=article div"` and `"css:light=article div"` match the first `
    In the light dom
    `. +- Both `"css=article > div"` and `"css:light=article > div"` match two `div` elements that are direct children of the `article`. +- `"css=article .in-the-shadow"` matches the `
    `, piercing the shadow root, while `"css:light=article .in-the-shadow"` does not match anything. +- `"css:light=article div > span"` does not match anything, because both light-dom `div` elements do not contain a `span`. +- `"css=article div > span"` matches the ``, piercing the shadow root. +- `"css=article > .in-the-shadow"` does not match anything, because `
    ` is not a direct child of `article` +- `"css:light=article > .in-the-shadow"` does not match anything. +- `"css=article li#target"` matches the `
  • Deep in the shadow
  • `, piercing two shadow roots. -> **NOTE** Only use deep engine if you need to pierce shadow roots. Otherwise, prefer the more effective CSS engine. +### xpath -> **NOTE** Deep engine first searches for elements in the light dom in the iteration order, and then recursively inside open shadow roots in the iteration order. It does not search inside closed shadow roots or iframes. +XPath engine is equivalent to [`Document.evaluate`](https://developer.mozilla.org/en/docs/Web/API/Document/evaluate). Example: `xpath=//html/body`. -### id, data-testid, data-test-id, data-test +Malformed selector starting with `//` is assumed to be an xpath selector. For example, Playwright converts `page.$('//html/body')` to `page.$('xpath=//html/body')`. -Attribute engines are selecting based on the corresponding atrribute value. For example: `data-test-id=foo` is similar to `deep=[data-test-id="foo"]`. +Note that `xpath` does not pierce shadow roots. -> **NOTE** Attribute engine first searches for elements in the light dom in the iteration order, and then recursively inside open shadow roots in the iteration order. It does not search inside closed shadow roots or iframes. +### text and text:light + +Text engine finds an element that contains a text node with the passed text. For example, `page.click('text=Login')` clicks on a login button, and `page.waitForSelector('"lazy loaded text")` waits for the `"lazy loaded text"` to appear in the page. + +- By default, the match is case-insensitive, ignores leading/trailing whitespace and searches for a substring. This means `text= Login` matches ``. +- Text body can be escaped with double quotes for precise matching, insisting on exact match, including specified whitespace and case. This means `text="Login "` will only match `` with exactly one space after "Login". +- Text body can also be a JavaScript-like regex wrapped in `/` symbols. This means `text=/^\\s*Login$/i` will match `` with any number of spaces before "Login" and no spaces after. +- Input elements of the type `button` and `submit` are rendered with their value as text, and text engine finds them. For example, `text=Login` matches ``. + +Malformed selector starting with `"` is assumed to be a text selector. For example, Playwright converts `page.click('"Login"')` to `page.click('text="Login"')`. + +`text` engine open pierces shadow roots similarly to `css`, while `text:light` does not. Text engine first searches for elements in the light dom in the iteration order, and then recursively inside open shadow roots in the iteration order. It does not search inside closed shadow roots or iframes. + +### id, data-testid, data-test-id, data-test and their :light counterparts + +Attribute engines are selecting based on the corresponding atrribute value. For example: `data-test-id=foo` is equivalent to `css=[data-test-id="foo"]`, and `id:light=foo` is equivalent to `css:light=[id="foo"]`. ## Custom selector engines @@ -129,9 +127,9 @@ Playwright supports custom selector engines, registered with [selectors.register Selector engine should have the following properties: -- `create` Function to create a relative selector from `root` (root is either a `Document`, `ShadowRoot` or `Element`) to a `target` element. -- `query` Function to query first element matching `selector` relative to the `root`. -- `queryAll` Function to query all elements matching `selector` relative to the `root`. +- `create` function to create a relative selector from `root` (root is either a `Document`, `ShadowRoot` or `Element`) to a `target` element. +- `query` function to query first element matching `selector` relative to the `root`. +- `queryAll` function to query all elements matching `selector` relative to the `root`. By default the engine is run directly in the frame's JavaScript context and, for example, can call an application-defined function. To isolate the engine from any JavaScript in the frame, but leave access to the DOM, resgister the engine with `{contentScript: true}` option. Content script engine is safer because it is protected from any tampering with the global objects, for example altering `Node.prototype` methods. All built-in selector engines run as content scripts. Note that running as a content script is not guaranteed when the engine is used together with other custom engines. @@ -162,8 +160,8 @@ await selectors.register('tag', createTagNameEngine); // Now we can use 'tag=' selectors. const button = await page.$('tag=button'); -// We can combine it with other selector engines. -await page.click('tag=div >> text="Click me"'); +// We can combine it with other selector engines using `>>` combinator. +await page.click('tag=div >> span >> "Click me"'); // We can use it in any methods supporting selectors. const buttonCount = await page.$$eval('tag=button', buttons => buttons.length); diff --git a/src/injected/attributeSelectorEngine.ts b/src/injected/attributeSelectorEngine.ts index 8035fbf1fa..c1c7db8b12 100644 --- a/src/injected/attributeSelectorEngine.ts +++ b/src/injected/attributeSelectorEngine.ts @@ -16,30 +16,34 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine'; -export function createAttributeEngine(attribute: string): SelectorEngine { +export function createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine { const engine: SelectorEngine = { create(root: SelectorRoot, target: Element): string | undefined { const value = target.getAttribute(attribute); if (!value) return; - if (queryInternal(root, attribute, value) === target) + if (engine.query(root, value) === target) return value; }, query(root: SelectorRoot, selector: string): Element | undefined { - return queryInternal(root, attribute, selector); + 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[] = []; - queryAllInternal(root, attribute, selector, result); + queryShadowAllInternal(root, attribute, selector, result); return result; } }; return engine; } -function queryInternal(root: SelectorRoot, attribute: string, value: string): Element | undefined { +function queryShadowInternal(root: SelectorRoot, attribute: string, value: string): Element | undefined { const single = root.querySelector(`[${attribute}=${JSON.stringify(value)}]`); if (single) return single; @@ -47,14 +51,14 @@ function queryInternal(root: SelectorRoot, attribute: string, value: string): El for (let i = 0; i < all.length; i++) { const shadowRoot = all[i].shadowRoot; if (shadowRoot) { - const single = queryInternal(shadowRoot, attribute, value); + const single = queryShadowInternal(shadowRoot, attribute, value); if (single) return single; } } } -function queryAllInternal(root: SelectorRoot, attribute: string, value: string, result: Element[]) { +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 = []; @@ -66,5 +70,5 @@ function queryAllInternal(root: SelectorRoot, attribute: string, value: string, shadowRoots.push(element.shadowRoot); } for (const shadowRoot of shadowRoots) - queryAllInternal(shadowRoot, attribute, value, result); + queryShadowAllInternal(shadowRoot, attribute, value, result); } diff --git a/src/injected/cssSelectorEngine.ts b/src/injected/cssSelectorEngine.ts index 0359b7520b..2888b0e505 100644 --- a/src/injected/cssSelectorEngine.ts +++ b/src/injected/cssSelectorEngine.ts @@ -16,72 +16,244 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine'; -export const CSSEngine: SelectorEngine = { - create(root: SelectorRoot, targetElement: Element): string | undefined { - const tokens: string[] = []; +export function createCSSEngine(shadow: boolean): SelectorEngine { + const engine: SelectorEngine = { + create(root: SelectorRoot, targetElement: Element): string | undefined { + if (shadow) + return; + const tokens: string[] = []; - function uniqueCSSSelector(prefix?: string): string | undefined { - const path = tokens.slice(); - if (prefix) - path.unshift(prefix); - const selector = path.join(' > '); - const nodes = Array.from(root.querySelectorAll(selector)); - return nodes[0] === targetElement ? selector : undefined; - } - - for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) { - const nodeName = element.nodeName.toLowerCase(); - - // Element ID is the strongest signal, use it. - let bestTokenForLevel: string = ''; - if (element.id) { - 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; - bestTokenForLevel = token; + function uniqueCSSSelector(prefix?: string): string | undefined { + const path = tokens.slice(); + if (prefix) + path.unshift(prefix); + const selector = path.join(' > '); + const nodes = Array.from(root.querySelectorAll(selector)); + return nodes[0] === targetElement ? selector : undefined; } - const parent = element.parentElement; + for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) { + const nodeName = element.nodeName.toLowerCase(); - // Combine class names until unique. - const classes = Array.from(element.classList); - for (let i = 0; i < classes.length; ++i) { - const token = '.' + classes.slice(0, i + 1).join('.'); - const selector = uniqueCSSSelector(token); - if (selector) - return selector; - // Even if not unique, does this subset of classes uniquely identify node as a child? - if (!bestTokenForLevel && parent) { - const sameClassSiblings = parent.querySelectorAll(token); - if (sameClassSiblings.length === 1) - bestTokenForLevel = token; - } - } - - // Ordinal is the weakest signal. - if (parent) { - const siblings = Array.from(parent.children); - const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName); - const token = sameTagSiblings.length === 1 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`; - const selector = uniqueCSSSelector(token); - if (selector) - return selector; - if (!bestTokenForLevel) + // Element ID is the strongest signal, use it. + let bestTokenForLevel: string = ''; + if (element.id) { + 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; bestTokenForLevel = token; - } else if (!bestTokenForLevel) { - bestTokenForLevel = nodeName; + } + + const parent = element.parentElement; + + // Combine class names until unique. + const classes = Array.from(element.classList); + for (let i = 0; i < classes.length; ++i) { + const token = '.' + classes.slice(0, i + 1).join('.'); + const selector = uniqueCSSSelector(token); + if (selector) + return selector; + // Even if not unique, does this subset of classes uniquely identify node as a child? + if (!bestTokenForLevel && parent) { + const sameClassSiblings = parent.querySelectorAll(token); + if (sameClassSiblings.length === 1) + bestTokenForLevel = token; + } + } + + // Ordinal is the weakest signal. + if (parent) { + const siblings = Array.from(parent.children); + const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName); + const token = sameTagSiblings.length === 1 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`; + const selector = uniqueCSSSelector(token); + if (selector) + return selector; + if (!bestTokenForLevel) + bestTokenForLevel = token; + } else if (!bestTokenForLevel) { + bestTokenForLevel = nodeName; + } + tokens.unshift(bestTokenForLevel); } - tokens.unshift(bestTokenForLevel); + return uniqueCSSSelector(); + }, + + query(root: SelectorRoot, selector: string): Element | undefined { + const simple = root.querySelector(selector); + if (simple) + return simple; + if (!shadow) + return; + const parts = split(selector); + if (!parts.length) + return; + parts.reverse(); + return queryShadowInternal(root, root, parts); + }, + + queryAll(root: SelectorRoot, selector: string): Element[] { + if (!shadow) + return Array.from(root.querySelectorAll(selector)); + const result: Element[] = []; + const parts = split(selector); + if (parts.length) { + parts.reverse(); + queryShadowAllInternal(root, root, parts, result); + } + return result; } - return uniqueCSSSelector(); - }, + }; + (engine as any)._test = () => test(engine); + return engine; +} - query(root: SelectorRoot, selector: string): Element | undefined { - return root.querySelector(selector) || undefined; - }, - - queryAll(root: SelectorRoot, selector: string): Element[] { - return Array.from(root.querySelectorAll(selector)); +function queryShadowInternal(boundary: SelectorRoot, root: SelectorRoot, parts: string[]): Element | undefined { + const matching = root.querySelectorAll(parts[0]); + for (let i = 0; i < matching.length; i++) { + const element = matching[i]; + if (parts.length === 1 || matches(element, parts, boundary)) + return element; } -}; + if ((root as Element).shadowRoot) { + const child = queryShadowInternal(boundary, (root as Element).shadowRoot!, parts); + if (child) + return child; + } + const elements = root.querySelectorAll('*'); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (element.shadowRoot) { + const child = queryShadowInternal(boundary, element.shadowRoot, parts); + if (child) + return child; + } + } +} + +function queryShadowAllInternal(boundary: SelectorRoot, root: SelectorRoot, parts: string[], result: Element[]) { + const matching = root.querySelectorAll(parts[0]); + for (let i = 0; i < matching.length; i++) { + const element = matching[i]; + if (parts.length === 1 || matches(element, parts, boundary)) + result.push(element); + } + if ((root as Element).shadowRoot) + queryShadowAllInternal(boundary, (root as Element).shadowRoot!, parts, result); + const elements = root.querySelectorAll('*'); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (element.shadowRoot) + queryShadowAllInternal(boundary, element.shadowRoot, parts, result); + } +} + +function matches(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; +} + +function split(selector: string): string[] { + let index = 0; + let quote: string | undefined; + let start = 0; + let space: 'none' | 'before' | 'after' = 'none'; + const result: string[] = []; + const append = () => { + const part = selector.substring(start, index).trim(); + if (part.length) + result.push(part); + }; + while (index < selector.length) { + const c = selector[index]; + if (!quote && c === ' ') { + if (space === 'none' || space === 'before') + space = 'before'; + index++; + } else { + if (space === 'before') { + if (c === '>' || c === '+' || c === '~') { + space = 'after'; + } else { + append(); + start = index; + space = 'none'; + } + } else { + space = 'none'; + } + if (c === '\\' && index + 1 < selector.length) { + index += 2; + } else if (c === quote) { + quote = undefined; + index++; + } else { + index++; + } + } + } + append(); + return result; +} + +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/injected/deepSelectorEngine.ts b/src/injected/deepSelectorEngine.ts deleted file mode 100644 index b9c6062659..0000000000 --- a/src/injected/deepSelectorEngine.ts +++ /dev/null @@ -1,193 +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 const DeepEngine: SelectorEngine = { - create(root: SelectorRoot, targetElement: Element): string | undefined { - return; - }, - - query(root: SelectorRoot, selector: string): Element | undefined { - const simple = root.querySelector(selector); - if (simple) - return simple; - const parts = split(selector); - if (!parts.length) - return; - parts.reverse(); - return queryInternal(root, root, parts); - }, - - queryAll(root: SelectorRoot, selector: string): Element[] { - const result: Element[] = []; - const parts = split(selector); - if (parts.length) { - parts.reverse(); - queryAllInternal(root, root, parts, result); - } - return result; - } -}; - -function queryInternal(boundary: SelectorRoot, root: SelectorRoot, parts: string[]): Element | undefined { - const matching = root.querySelectorAll(parts[0]); - for (let i = 0; i < matching.length; i++) { - const element = matching[i]; - if (parts.length === 1 || matches(element, parts, boundary)) - return element; - } - if ((root as Element).shadowRoot) { - const child = queryInternal(boundary, (root as Element).shadowRoot!, parts); - if (child) - return child; - } - const elements = root.querySelectorAll('*'); - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - if (element.shadowRoot) { - const child = queryInternal(boundary, element.shadowRoot, parts); - if (child) - return child; - } - } -} - -function queryAllInternal(boundary: SelectorRoot, root: SelectorRoot, parts: string[], result: Element[]) { - const matching = root.querySelectorAll(parts[0]); - for (let i = 0; i < matching.length; i++) { - const element = matching[i]; - if (parts.length === 1 || matches(element, parts, boundary)) - result.push(element); - } - if ((root as Element).shadowRoot) - queryAllInternal(boundary, (root as Element).shadowRoot!, parts, result); - const elements = root.querySelectorAll('*'); - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - if (element.shadowRoot) - queryAllInternal(boundary, element.shadowRoot, parts, result); - } -} - -function matches(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; -} - -function split(selector: string): string[] { - let index = 0; - let quote: string | undefined; - let start = 0; - let space: 'none' | 'before' | 'after' = 'none'; - const result: string[] = []; - const append = () => { - const part = selector.substring(start, index).trim(); - if (part.length) - result.push(part); - }; - while (index < selector.length) { - const c = selector[index]; - if (!quote && c === ' ') { - if (space === 'none' || space === 'before') - space = 'before'; - index++; - } else { - if (space === 'before') { - if (c === '>' || c === '+' || c === '~') { - space = 'after'; - } else { - append(); - start = index; - space = 'none'; - } - } else { - space = 'none'; - } - if (c === '\\' && index + 1 < selector.length) { - index += 2; - } else if (c === quote) { - quote = undefined; - index++; - } else { - index++; - } - } - } - append(); - return result; -} - -(DeepEngine as any)._test = () => { - 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} = DeepEngine; - - 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/injected/selectorEvaluator.ts b/src/injected/selectorEvaluator.ts index e82855dc9c..e0163c9867 100644 --- a/src/injected/selectorEvaluator.ts +++ b/src/injected/selectorEvaluator.ts @@ -14,14 +14,13 @@ * limitations under the License. */ -import { CSSEngine } from './cssSelectorEngine'; -import { DeepEngine } from './deepSelectorEngine'; -import { XPathEngine } from './xpathSelectorEngine'; -import { TextEngine } from './textSelectorEngine'; -import { SelectorEngine, SelectorRoot } from './selectorEngine'; -import Injected from './injected'; import * as types from '../types'; import { createAttributeEngine } from './attributeSelectorEngine'; +import { createCSSEngine } from './cssSelectorEngine'; +import Injected from './injected'; +import { SelectorEngine, SelectorRoot } from './selectorEngine'; +import { createTextSelector } from './textSelectorEngine'; +import { XPathEngine } from './xpathSelectorEngine'; class SelectorEvaluator { readonly engines: Map; @@ -31,14 +30,20 @@ class SelectorEvaluator { this.injected = new Injected(); this.engines = new Map(); // Note: keep predefined names in sync with Selectors class. - this.engines.set('css', CSSEngine); + this.engines.set('css', createCSSEngine(true)); + this.engines.set('css:light', createCSSEngine(false)); this.engines.set('xpath', XPathEngine); - this.engines.set('text', TextEngine); - this.engines.set('deep', DeepEngine); - this.engines.set('id', createAttributeEngine('id')); - this.engines.set('data-testid', createAttributeEngine('data-testid')); - this.engines.set('data-test-id', createAttributeEngine('data-test-id')); - this.engines.set('data-test', createAttributeEngine('data-test')); + this.engines.set('xpath:light', XPathEngine); + this.engines.set('text', createTextSelector(true)); + this.engines.set('text:light', createTextSelector(false)); + this.engines.set('id', createAttributeEngine('id', true)); + this.engines.set('id:light', createAttributeEngine('id', false)); + this.engines.set('data-testid', createAttributeEngine('data-testid', true)); + this.engines.set('data-testid:light', createAttributeEngine('data-testid', false)); + this.engines.set('data-test-id', createAttributeEngine('data-test-id', true)); + this.engines.set('data-test-id:light', createAttributeEngine('data-test-id', false)); + this.engines.set('data-test', createAttributeEngine('data-test', true)); + this.engines.set('data-test:light', createAttributeEngine('data-test', false)); for (const {name, engine} of customEngines) this.engines.set(name, engine); } diff --git a/src/injected/textSelectorEngine.ts b/src/injected/textSelectorEngine.ts index f9209ffebf..09715eafad 100644 --- a/src/injected/textSelectorEngine.ts +++ b/src/injected/textSelectorEngine.ts @@ -16,34 +16,37 @@ import { SelectorEngine, SelectorType, SelectorRoot } from './selectorEngine'; -export const TextEngine: SelectorEngine = { - create(root: SelectorRoot, targetElement: Element, type: SelectorType): string | undefined { - const document = root instanceof Document ? root : root.ownerDocument; - if (!document) - return; - for (let child = targetElement.firstChild; child; child = child.nextSibling) { - if (child.nodeType === 3 /* Node.TEXT_NODE */) { - const text = child.nodeValue; - if (!text) - continue; - if (text.match(/^\s*[a-zA-Z0-9]+\s*$/) && TextEngine.query(root, text.trim()) === targetElement) - return text.trim(); - if (queryInternal(root, createMatcher(JSON.stringify(text))) === targetElement) - return JSON.stringify(text); +export function createTextSelector(shadow: boolean): SelectorEngine { + const engine: SelectorEngine = { + create(root: SelectorRoot, targetElement: Element, type: SelectorType): string | undefined { + const document = root instanceof Document ? root : root.ownerDocument; + if (!document) + return; + for (let child = targetElement.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 3 /* Node.TEXT_NODE */) { + const text = child.nodeValue; + if (!text) + continue; + if (text.match(/^\s*[a-zA-Z0-9]+\s*$/) && engine.query(root, text.trim()) === targetElement) + return text.trim(); + if (queryInternal(root, createMatcher(JSON.stringify(text)), shadow) === targetElement) + return JSON.stringify(text); + } } + }, + + query(root: SelectorRoot, selector: string): Element | undefined { + return queryInternal(root, createMatcher(selector), shadow); + }, + + queryAll(root: SelectorRoot, selector: string): Element[] { + const result: Element[] = []; + queryAllInternal(root, createMatcher(selector), shadow, result); + return result; } - }, - - query(root: SelectorRoot, selector: string): Element | undefined { - return queryInternal(root, createMatcher(selector)); - }, - - queryAll(root: SelectorRoot, selector: string): Element[] { - const result: Element[] = []; - queryAllInternal(root, createMatcher(selector), result); - return result; - } -}; + }; + return engine; +} type Matcher = (text: string) => boolean; function createMatcher(selector: string): Matcher { @@ -60,17 +63,19 @@ function createMatcher(selector: string): Matcher { return text => text.toLowerCase().includes(selector); } -function queryInternal(root: SelectorRoot, matcher: Matcher): Element | undefined { +function queryInternal(root: SelectorRoot, matcher: Matcher, shadow: boolean): Element | undefined { const document = root instanceof Document ? root : root.ownerDocument!; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); - const shadowRoots = []; + const shadowRoots: ShadowRoot[] = []; + if (shadow && (root as Element).shadowRoot) + shadowRoots.push((root as Element).shadowRoot!); while (walker.nextNode()) { const node = walker.currentNode; if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if ((element instanceof HTMLInputElement) && (element.type === 'submit' || element.type === 'button') && matcher(element.value)) return element; - if (element.shadowRoot) + if (shadow && element.shadowRoot) shadowRoots.push(element.shadowRoot); } else { const element = node.parentElement; @@ -80,23 +85,25 @@ function queryInternal(root: SelectorRoot, matcher: Matcher): Element | undefine } } for (const shadowRoot of shadowRoots) { - const element = queryInternal(shadowRoot, matcher); + const element = queryInternal(shadowRoot, matcher, shadow); if (element) return element; } } -function queryAllInternal(root: SelectorRoot, matcher: Matcher, result: Element[]) { +function queryAllInternal(root: SelectorRoot, matcher: Matcher, shadow: boolean, result: Element[]) { const document = root instanceof Document ? root : root.ownerDocument!; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); - const shadowRoots = []; + const shadowRoots: ShadowRoot[] = []; + if (shadow && (root as Element).shadowRoot) + shadowRoots.push((root as Element).shadowRoot!); while (walker.nextNode()) { const node = walker.currentNode; if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if ((element instanceof HTMLInputElement) && (element.type === 'submit' || element.type === 'button') && matcher(element.value)) result.push(element); - if (element.shadowRoot) + if (shadow && element.shadowRoot) shadowRoots.push(element.shadowRoot); } else { const element = node.parentElement; @@ -106,5 +113,5 @@ function queryAllInternal(root: SelectorRoot, matcher: Matcher, result: Element[ } } for (const shadowRoot of shadowRoots) - queryAllInternal(shadowRoot, matcher, result); + queryAllInternal(shadowRoot, matcher, shadow, result); } diff --git a/src/selectors.ts b/src/selectors.ts index 74b8947afc..ddfd3d08dc 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -35,7 +35,15 @@ export class Selectors { constructor() { // Note: keep in sync with SelectorEvaluator class. - this._builtinEngines = new Set(['css', 'xpath', 'text', 'deep', 'id', 'data-testid', 'data-test-id', 'data-test']); + this._builtinEngines = new Set([ + 'css', 'css:light', + 'xpath', 'xpath:light', + 'text', 'text:light', + 'id', 'id:light', + 'data-testid', 'data-testid:light', + 'data-test-id', 'data-test-id:light', + 'data-test', 'data-test:light' + ]); this._engines = new Map(); } @@ -44,7 +52,7 @@ export class Selectors { if (!name.match(/^[a-zA-Z_0-9-]+$/)) throw new Error('Selector engine name may only contain [a-zA-Z0-9_] characters'); // Note: we keep 'zs' for future use. - if (this._builtinEngines.has(name) || name === 'zs') + if (this._builtinEngines.has(name) || name === 'zs' || name === 'zs:light') throw new Error(`"${name}" is a predefined selector engine`); const source = await helper.evaluationScript(script, undefined, false); if (this._engines.has(name)) @@ -173,7 +181,7 @@ export class Selectors { const eqIndex = part.indexOf('='); let name: string; let body: string; - if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-]+$/)) { + if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:]+$/)) { name = part.substring(0, eqIndex).trim(); body = part.substring(eqIndex + 1); } else if (part.startsWith('"')) { diff --git a/test/queryselector.spec.js b/test/queryselector.spec.js index 96594558b9..c10bb8bd25 100644 --- a/test/queryselector.spec.js +++ b/test/queryselector.spec.js @@ -103,20 +103,20 @@ describe('Page.$eval', function() { }); it('should support spaces with >> syntax', async({page, server}) => { await page.goto(server.PREFIX + '/deep-shadow.html'); - const text = await page.$eval(' css = div >>css=div>>css = span ', e => e.textContent); + const text = await page.$eval(' css:light = div >>css:light=div>>css:light = span ', e => e.textContent); expect(text).toBe('Hello from root2'); }); it('should enter shadow roots with >> syntax', async({page, server}) => { await page.goto(server.PREFIX + '/deep-shadow.html'); - const text1 = await page.$eval('css=div >> css=span', e => e.textContent); + const text1 = await page.$eval('css:light=div >> css:light=span', e => e.textContent); expect(text1).toBe('Hello from root1'); - const text2 = await page.$eval('css=div >> css=*:nth-child(2) >> css=span', e => e.textContent); + const text2 = await page.$eval('css:light=div >> css:light=*:nth-child(2) >> css:light=span', e => e.textContent); expect(text2).toBe('Hello from root2'); - const nonExisting = await page.$('css=div div >> css=span'); + const nonExisting = await page.$('css:light=div div >> css:light=span'); expect(nonExisting).not.toBeTruthy(); - const text3 = await page.$eval('css=section div >> css=span', e => e.textContent); + const text3 = await page.$eval('css:light=section div >> css:light=span', e => e.textContent); expect(text3).toBe('Hello from root1'); - const text4 = await page.$eval('xpath=/html/body/section/div >> css=div >> css=span', e => e.textContent); + const text4 = await page.$eval('xpath=/html/body/section/div >> css:light=div >> css:light=span', e => e.textContent); expect(text4).toBe('Hello from root2'); }); it('should not stop at first failure with >> syntax', async({page, server}) => { @@ -154,7 +154,7 @@ describe('Page.$$eval', function() { }); it('should enter shadow roots with >> syntax', async({page, server}) => { await page.goto(server.PREFIX + '/deep-shadow.html'); - const spansCount = await page.$$eval('css=div >> css=div >> css=span', spans => spans.length); + const spansCount = await page.$$eval('css:light=div >> css:light=div >> css:light=span', spans => spans.length); expect(spansCount).toBe(3); }); }); @@ -545,26 +545,38 @@ describe('text selector', () => { expect(await page.$eval(`text=root1`, e => e.textContent)).toBe('Hello from root1'); expect(await page.$eval(`text=root2`, e => e.textContent)).toBe('Hello from root2'); expect(await page.$eval(`text=root3`, e => e.textContent)).toBe('Hello from root3'); + expect(await page.$(`text:light=root1`)).toBe(null); + expect(await page.$(`text:light=root2`)).toBe(null); + expect(await page.$(`text:light=root3`)).toBe(null); }); }); -describe('deep selector', () => { +describe('css selector', () => { it('should work for open shadow roots', async({page, server}) => { await page.goto(server.PREFIX + '/deep-shadow.html'); - expect(await page.$eval(`deep=span`, e => e.textContent)).toBe('Hello from root1'); - expect(await page.$eval(`deep=[attr="value\\ space"]`, e => e.textContent)).toBe('Hello from root3 #2'); - expect(await page.$eval(`deep=[attr='value\\ \\space']`, e => e.textContent)).toBe('Hello from root3 #2'); - expect(await page.$eval(`deep=div div span`, e => e.textContent)).toBe('Hello from root2'); - expect(await page.$eval(`deep=div span + span`, e => e.textContent)).toBe('Hello from root3 #2'); - expect(await page.$eval(`deep=span + [attr*="value"]`, e => e.textContent)).toBe('Hello from root3 #2'); - expect(await page.$eval(`deep=[data-testid="foo"] + [attr*="value"]`, e => e.textContent)).toBe('Hello from root3 #2'); - expect(await page.$eval(`deep=#target`, e => e.textContent)).toBe('Hello from root2'); - expect(await page.$eval(`deep=div #target`, e => e.textContent)).toBe('Hello from root2'); - expect(await page.$eval(`deep=div div #target`, e => e.textContent)).toBe('Hello from root2'); - expect(await page.$(`deep=div div div #target`)).toBe(null); - expect(await page.$eval(`deep=section > div div span`, e => e.textContent)).toBe('Hello from root2'); - expect(await page.$eval(`deep=section > div div span:nth-child(2)`, e => e.textContent)).toBe('Hello from root3 #2'); - expect(await page.$(`deep=section div div div div`)).toBe(null); + expect(await page.$eval(`css=span`, e => e.textContent)).toBe('Hello from root1'); + expect(await page.$eval(`css=[attr="value\\ space"]`, e => e.textContent)).toBe('Hello from root3 #2'); + expect(await page.$eval(`css=[attr='value\\ \\space']`, e => e.textContent)).toBe('Hello from root3 #2'); + expect(await page.$eval(`css=div div span`, e => e.textContent)).toBe('Hello from root2'); + expect(await page.$eval(`css=div span + span`, e => e.textContent)).toBe('Hello from root3 #2'); + expect(await page.$eval(`css=span + [attr*="value"]`, e => e.textContent)).toBe('Hello from root3 #2'); + expect(await page.$eval(`css=[data-testid="foo"] + [attr*="value"]`, e => e.textContent)).toBe('Hello from root3 #2'); + expect(await page.$eval(`css=#target`, e => e.textContent)).toBe('Hello from root2'); + expect(await page.$eval(`css=div #target`, e => e.textContent)).toBe('Hello from root2'); + expect(await page.$eval(`css=div div #target`, e => e.textContent)).toBe('Hello from root2'); + expect(await page.$(`css=div div div #target`)).toBe(null); + expect(await page.$eval(`css=section > div div span`, e => e.textContent)).toBe('Hello from root2'); + expect(await page.$eval(`css=section > div div span:nth-child(2)`, e => e.textContent)).toBe('Hello from root3 #2'); + expect(await page.$(`css=section div div div div`)).toBe(null); + + const root2 = await page.$(`css=div div`); + expect(await root2.$eval(`css=#target`, e => e.textContent)).toBe('Hello from root2'); + expect(await root2.$eval(`css:light=#target`, e => e.textContent)).toBe('Hello from root2'); + const root3 = (await page.$$(`css=div div`))[1]; + expect(await root3.$eval(`text=root3`, e => e.textContent)).toBe('Hello from root3'); + expect(await root3.$eval(`css=[attr*="value"]`, e => e.textContent)).toBe('Hello from root3 #2'); + // TODO: the following should be null, but we implicitly enter shadow root. + expect(await root3.$(`css:light=[attr*="value"]`)).not.toBe(null); }); }); @@ -574,6 +586,9 @@ describe('attribute selector', () => { expect(await page.$eval(`id=target`, e => e.textContent)).toBe('Hello from root2'); expect(await page.$eval(`data-testid=foo`, e => e.textContent)).toBe('Hello from root1'); expect(await page.$$eval(`data-testid=foo`, els => els.length)).toBe(3); + expect(await page.$(`id:light=target`)).toBe(null); + expect(await page.$(`data-testid:light=foo`)).toBe(null); + expect(await page.$$(`data-testid:light=foo`)).toEqual([]); }); });