`, 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
+
+> **NOTE** Only use deep engine if you need to pierce shadow roots. Otherwise, prefer the more effective CSS engine.
+
+> **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.
+
### id, data-testid, data-test-id, data-test
-Attribute engines are selecting based on the corresponding atrribute value. For example: `data-test-id=foo` is similar to `querySelector('*[data-test-id=foo]')`.
+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** Attribute engine 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** 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.
## Custom selector engines
diff --git a/src/injected/deepSelectorEngine.ts b/src/injected/deepSelectorEngine.ts
new file mode 100644
index 0000000000..b9c6062659
--- /dev/null
+++ b/src/injected/deepSelectorEngine.ts
@@ -0,0 +1,193 @@
+/**
+ * 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 5a39925c0d..e82855dc9c 100644
--- a/src/injected/selectorEvaluator.ts
+++ b/src/injected/selectorEvaluator.ts
@@ -15,6 +15,7 @@
*/
import { CSSEngine } from './cssSelectorEngine';
+import { DeepEngine } from './deepSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { TextEngine } from './textSelectorEngine';
import { SelectorEngine, SelectorRoot } from './selectorEngine';
@@ -33,6 +34,7 @@ class SelectorEvaluator {
this.engines.set('css', CSSEngine);
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'));
diff --git a/src/selectors.ts b/src/selectors.ts
index 4c84e9aa3b..8f5bd946c1 100644
--- a/src/selectors.ts
+++ b/src/selectors.ts
@@ -35,7 +35,7 @@ export class Selectors {
constructor() {
// Note: keep in sync with SelectorEvaluator class.
- this._builtinEngines = new Set(['css', 'xpath', 'text', 'id', 'data-testid', 'data-test-id', 'data-test']);
+ this._builtinEngines = new Set(['css', 'xpath', 'text', 'deep', 'id', 'data-testid', 'data-test-id', 'data-test']);
this._engines = new Map();
}
diff --git a/test/assets/deep-shadow.html b/test/assets/deep-shadow.html
index f612ba74d4..761ecb454a 100644
--- a/test/assets/deep-shadow.html
+++ b/test/assets/deep-shadow.html
@@ -27,5 +27,9 @@ window.addEventListener('DOMContentLoaded', () => {
span3.setAttribute('data-testid', 'foo');
span3.textContent = 'Hello from root3';
shadowRoot3.appendChild(span3);
+ const span4 = document.createElement('span');
+ span4.textContent = 'Hello from root3 #2';
+ span4.setAttribute('attr', 'value space');
+ shadowRoot3.appendChild(span4);
});
diff --git a/test/queryselector.spec.js b/test/queryselector.spec.js
index aff1e9ad75..96594558b9 100644
--- a/test/queryselector.spec.js
+++ b/test/queryselector.spec.js
@@ -155,7 +155,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);
- expect(spansCount).toBe(2);
+ expect(spansCount).toBe(3);
});
});
@@ -548,6 +548,26 @@ describe('text selector', () => {
});
});
+describe('deep 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);
+ });
+});
+
describe('attribute selector', () => {
it('should work for open shadow roots', async({page, server}) => {
await page.goto(server.PREFIX + '/deep-shadow.html');