Skip to content

Commit

Permalink
feat(text selector): pierce shadow roots (#1619)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozman authored Apr 3, 2020
1 parent 75571e8 commit a9be3c5
Show file tree
Hide file tree
Showing 3 changed files with 57 additions and 25 deletions.
2 changes: 2 additions & 0 deletions docs/selectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>Login </button>` 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 `<button> loGIN</button>` 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
Expand Down
73 changes: 48 additions & 25 deletions src/injected/textSelectorEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};
Expand All @@ -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);
}
7 changes: 7 additions & 0 deletions test/queryselector.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,13 @@ module.exports.describe = function({testRunner, expect, playwright, FFOX, CHROMI
expect(await page.$eval(`text=with`, e => e.outerHTML)).toBe('<div>textwithsubstring</div>');
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('<span>Hello from root1</span>');
expect(await page.$eval(`text=root2`, e => e.outerHTML)).toBe('<span>Hello from root2</span>');
expect(await page.$eval(`text=root3`, e => e.outerHTML)).toBe('<span>Hello from root3</span>');
});
});

describe('selectors.register', () => {
Expand Down

0 comments on commit a9be3c5

Please sign in to comment.