Skip to content

Commit

Permalink
feat(selectors): attribute selectors pierce open shadow roots (#1656)
Browse files Browse the repository at this point in the history
References #1375.
  • Loading branch information
dgozman committed Apr 4, 2020
1 parent e9428b6 commit a91304a
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 26 deletions.
6 changes: 4 additions & 2 deletions docs/selectors.md
Expand Up @@ -76,15 +76,17 @@ 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** Text 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** 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 `<input type=button value="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 engines are selecting based on the corresponding atrribute value. For example: `data-test-id=foo` is equivalent 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 `querySelector('*[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.
## Custom selector engines

Expand Down
70 changes: 70 additions & 0 deletions src/injected/attributeSelectorEngine.ts
@@ -0,0 +1,70 @@
/**
* 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 function createAttributeEngine(attribute: string): 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)
return value;
},

query(root: SelectorRoot, selector: string): Element | undefined {
return queryInternal(root, attribute, selector);
},

queryAll(root: SelectorRoot, selector: string): Element[] {
const result: Element[] = [];
queryAllInternal(root, attribute, selector, result);
return result;
}
};
return engine;
}

function queryInternal(root: SelectorRoot, attribute: string, value: string): Element | undefined {
const single = root.querySelector(`[${attribute}=${JSON.stringify(value)}]`);
if (single)
return single;
const all = root.querySelectorAll('*');
for (let i = 0; i < all.length; i++) {
const shadowRoot = all[i].shadowRoot;
if (shadowRoot) {
const single = queryInternal(shadowRoot, attribute, value);
if (single)
return single;
}
}
}

function queryAllInternal(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 = [];
while (walker.nextNode()) {
const element = walker.currentNode as Element;
if (element.getAttribute(attribute) === value)
result.push(element);
if (element.shadowRoot)
shadowRoots.push(element.shadowRoot);
}
for (const shadowRoot of shadowRoots)
queryAllInternal(shadowRoot, attribute, value, result);
}
22 changes: 1 addition & 21 deletions src/injected/selectorEvaluator.ts
Expand Up @@ -20,27 +20,7 @@ import { TextEngine } from './textSelectorEngine';
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import Injected from './injected';
import * as types from '../types';

function createAttributeEngine(attribute: string): SelectorEngine {
const engine: SelectorEngine = {
create(root: SelectorRoot, target: Element): string | undefined {
const value = target.getAttribute(attribute);
if (!value)
return;
if (root.querySelector(`[${attribute}=${value}]`) === target)
return value;
},

query(root: SelectorRoot, selector: string): Element | undefined {
return root.querySelector(`[${attribute}=${selector}]`) || undefined;
},

queryAll(root: SelectorRoot, selector: string): Element[] {
return Array.from(root.querySelectorAll(`[${attribute}=${selector}]`));
}
};
return engine;
}
import { createAttributeEngine } from './attributeSelectorEngine';

class SelectorEvaluator {
readonly engines: Map<string, SelectorEngine>;
Expand Down
4 changes: 4 additions & 0 deletions test/assets/deep-shadow.html
Expand Up @@ -7,20 +7,24 @@
outer.appendChild(root1);
const shadowRoot1 = root1.attachShadow({mode: 'open'});
const span1 = document.createElement('span');
span1.setAttribute('data-testid', 'foo');
span1.textContent = 'Hello from root1';
shadowRoot1.appendChild(span1);

const root2 = document.createElement('div');
shadowRoot1.appendChild(root2);
const shadowRoot2 = root2.attachShadow({mode: 'open'});
const span2 = document.createElement('span');
span2.setAttribute('data-testid', 'foo');
span2.setAttribute('id', 'target');
span2.textContent = 'Hello from root2';
shadowRoot2.appendChild(span2);

const root3 = document.createElement('div');
shadowRoot1.appendChild(root3);
const shadowRoot3 = root3.attachShadow({mode: 'open'});
const span3 = document.createElement('span');
span3.setAttribute('data-testid', 'foo');
span3.textContent = 'Hello from root3';
shadowRoot3.appendChild(span3);
});
Expand Down
15 changes: 12 additions & 3 deletions test/queryselector.spec.js
Expand Up @@ -549,9 +549,18 @@ module.exports.describe = function({testRunner, expect, playwright, FFOX, CHROMI

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>');
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');
});
});

describe('attribute selector', () => {
it('should work for open shadow roots', async({page, server}) => {
await page.goto(server.PREFIX + '/deep-shadow.html');
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);
});
});

Expand Down

0 comments on commit a91304a

Please sign in to comment.