From a9be3c51919c87f8f31f53c6ddde2a7e49d64f51 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 2 Apr 2020 18:03:30 -0700 Subject: [PATCH] feat(text selector): pierce shadow roots (#1619) --- docs/selectors.md | 2 + src/injected/textSelectorEngine.ts | 73 ++++++++++++++++++++---------- test/queryselector.spec.js | 7 +++ 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/docs/selectors.md b/docs/selectors.md index 0aa87e2a1738e..4c47b9ffca0a8 100644 --- a/docs/selectors.md +++ b/docs/selectors.md @@ -76,6 +76,8 @@ Text engine finds an element that contains a text node with passed text. Example - 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 searches for elements inside open shadow roots, but not inside closed shadow roots or iframes. + > **NOTE** Malformed selector starting with `"` is automatically transformed to text selector. For example, Playwright converts `page.click('"Login"')` to `page.click('text="Login"')`. ### id, data-testid, data-test-id, data-test diff --git a/src/injected/textSelectorEngine.ts b/src/injected/textSelectorEngine.ts index a2c3863569492..4c7cbe38c4570 100644 --- a/src/injected/textSelectorEngine.ts +++ b/src/injected/textSelectorEngine.ts @@ -28,41 +28,19 @@ export const TextEngine: SelectorEngine = { continue; if (text.match(/^\s*[a-zA-Z0-9]+\s*$/) && TextEngine.query(root, text.trim()) === targetElement) return text.trim(); - if (TextEngine.query(root, JSON.stringify(text)) === targetElement) + if (queryInternal(root, createMatcher(JSON.stringify(text))) === targetElement) return JSON.stringify(text); } } }, query(root: SelectorRoot, selector: string): Element | undefined { - const document = root instanceof Document ? root : root.ownerDocument; - if (!document) - return; - const matcher = createMatcher(selector); - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - while (walker.nextNode()) { - const node = walker.currentNode; - const element = node.parentElement; - const text = node.nodeValue; - if (element && text && matcher(text)) - return element; - } + return queryInternal(root, createMatcher(selector)); }, queryAll(root: SelectorRoot, selector: string): Element[] { const result: Element[] = []; - const document = root instanceof Document ? root : root.ownerDocument; - if (!document) - return result; - const matcher = createMatcher(selector); - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - while (walker.nextNode()) { - const node = walker.currentNode; - const element = node.parentElement; - const text = node.nodeValue; - if (element && text && matcher(text)) - result.push(element); - } + queryAllInternal(root, createMatcher(selector), result); return result; } }; @@ -81,3 +59,48 @@ function createMatcher(selector: string): Matcher { selector = selector.trim().toLowerCase(); return text => text.toLowerCase().includes(selector); } + +function queryInternal(root: SelectorRoot, matcher: Matcher): Element | undefined { + const document = root instanceof Document ? root : root.ownerDocument!; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); + const shadowRoots = []; + while (walker.nextNode()) { + const node = walker.currentNode; + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + if (element.shadowRoot) + shadowRoots.push(element.shadowRoot); + } else { + const element = node.parentElement; + const text = node.nodeValue; + if (element && element.nodeName !== 'SCRIPT' && element.nodeName !== 'STYLE' && text && matcher(text)) + return element; + } + } + for (const shadowRoot of shadowRoots) { + const element = queryInternal(shadowRoot, matcher); + if (element) + return element; + } +} + +function queryAllInternal(root: SelectorRoot, matcher: Matcher, result: Element[]) { + const document = root instanceof Document ? root : root.ownerDocument!; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); + const shadowRoots = []; + while (walker.nextNode()) { + const node = walker.currentNode; + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + if (element.shadowRoot) + shadowRoots.push(element.shadowRoot); + } else { + const element = node.parentElement; + const text = node.nodeValue; + if (element && element.nodeName !== 'SCRIPT' && element.nodeName !== 'STYLE' && text && matcher(text)) + result.push(element); + } + } + for (const shadowRoot of shadowRoots) + queryAllInternal(shadowRoot, matcher, result); +} diff --git a/test/queryselector.spec.js b/test/queryselector.spec.js index e8dc1a908745d..fa92d5275116d 100644 --- a/test/queryselector.spec.js +++ b/test/queryselector.spec.js @@ -540,6 +540,13 @@ module.exports.describe = function({testRunner, expect, playwright, FFOX, CHROMI expect(await page.$eval(`text=with`, e => e.outerHTML)).toBe('
textwithsubstring
'); expect(await page.$(`text="with"`)).toBe(null); }); + + it('should work for open shadow roots', async({page, server}) => { + await page.goto(server.PREFIX + '/deep-shadow.html'); + expect(await page.$eval(`text=root1`, e => e.outerHTML)).toBe('Hello from root1'); + expect(await page.$eval(`text=root2`, e => e.outerHTML)).toBe('Hello from root2'); + expect(await page.$eval(`text=root3`, e => e.outerHTML)).toBe('Hello from root3'); + }); }); describe('selectors.register', () => {