feat(text selector): pierce shadow roots (#1619)
This commit is contained in:
parent
75571e8eb8
commit
a9be3c5191
|
|
@ -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 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.
|
- 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"')`.
|
> **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
|
### id, data-testid, data-test-id, data-test
|
||||||
|
|
|
||||||
|
|
@ -28,41 +28,19 @@ export const TextEngine: SelectorEngine = {
|
||||||
continue;
|
continue;
|
||||||
if (text.match(/^\s*[a-zA-Z0-9]+\s*$/) && TextEngine.query(root, text.trim()) === targetElement)
|
if (text.match(/^\s*[a-zA-Z0-9]+\s*$/) && TextEngine.query(root, text.trim()) === targetElement)
|
||||||
return text.trim();
|
return text.trim();
|
||||||
if (TextEngine.query(root, JSON.stringify(text)) === targetElement)
|
if (queryInternal(root, createMatcher(JSON.stringify(text))) === targetElement)
|
||||||
return JSON.stringify(text);
|
return JSON.stringify(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
query(root: SelectorRoot, selector: string): Element | undefined {
|
query(root: SelectorRoot, selector: string): Element | undefined {
|
||||||
const document = root instanceof Document ? root : root.ownerDocument;
|
return queryInternal(root, createMatcher(selector));
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
queryAll(root: SelectorRoot, selector: string): Element[] {
|
queryAll(root: SelectorRoot, selector: string): Element[] {
|
||||||
const result: Element[] = [];
|
const result: Element[] = [];
|
||||||
const document = root instanceof Document ? root : root.ownerDocument;
|
queryAllInternal(root, createMatcher(selector), result);
|
||||||
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);
|
|
||||||
}
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -81,3 +59,48 @@ function createMatcher(selector: string): Matcher {
|
||||||
selector = selector.trim().toLowerCase();
|
selector = selector.trim().toLowerCase();
|
||||||
return text => text.toLowerCase().includes(selector);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.$eval(`text=with`, e => e.outerHTML)).toBe('<div>textwithsubstring</div>');
|
||||||
expect(await page.$(`text="with"`)).toBe(null);
|
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', () => {
|
describe('selectors.register', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue