feat: introduce react selectors (#8069)

This patch adds support for the `react` selector engine that allows
selecting DOM elements based on the component name.

> **NOTE**: in case of multi-root components (React.Fragment), `react`
engine will select all root DOM elements.

> **NOTE**: `react` engine supports react v15+.

References #7189
This commit is contained in:
Andrey Lushnikov 2021-08-08 02:51:39 +03:00 committed by GitHub
parent 40fb9d85e0
commit f3ba2b54ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 81751 additions and 0 deletions

View file

@ -179,6 +179,25 @@ methods accept [`param: selector`] as their first argument.
await page.ClickAsync("xpath=//button"); await page.ClickAsync("xpath=//button");
``` ```
Learn more about [XPath selector][xpath]. Learn more about [XPath selector][xpath].
- React selector
```js
await page.click('react=DatePickerComponent');
```
```java
page.click("react=DatePickerComponent");
```
```python async
await page.click("react=DatePickerComponent")
```
```python sync
page.click("react=DatePickerComponent")
```
```csharp
await page.ClickAsync("react=DatePickerComponent");
```
Learn more about [React selector][react].
## Text selector ## Text selector
@ -625,6 +644,23 @@ converts `'//html/body'` to `'xpath=//html/body'`.
`xpath` does not pierce shadow roots `xpath` does not pierce shadow roots
::: :::
## React selectors
React selectors allow selecting elements by its component name.
To find React element names in a tree use [React DevTools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi).
Example: `react=MyComponent`
:::note
React selectors support React 15 and above.
:::
:::note
React selectors, as well as [React DevTools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi), only work against **unminified** application builds.
:::
## id, data-testid, data-test-id, data-test selectors ## id, data-testid, data-test-id, data-test selectors
Playwright supports a shorthand for selecting elements using certain attributes. Currently, only Playwright supports a shorthand for selecting elements using certain attributes. Currently, only
@ -966,4 +1002,5 @@ await page.ClickAsync("//*[@id='tsf']/div[2]/div[1]/div[1]/div/div[2]/input");
[text]: #text-selector [text]: #text-selector
[css]: #css-selector [css]: #css-selector
[xpath]: #xpath-selectors [xpath]: #xpath-selectors
[react]: #react-selectors
[id]: #id-data-testid-data-test-id-data-test-selectors [id]: #id-data-testid-data-test-id-data-test-selectors

View file

@ -16,6 +16,7 @@
import { SelectorEngine, SelectorRoot } from './selectorEngine'; import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { XPathEngine } from './xpathSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine';
import { ReactEngine } from './reactSelectorEngine';
import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser'; import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser';
import { FatalDOMError } from '../common/domErrors'; import { FatalDOMError } from '../common/domErrors';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator'; import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
@ -62,6 +63,7 @@ export class InjectedScript {
this._engines = new Map(); this._engines = new Map();
this._engines.set('xpath', XPathEngine); this._engines.set('xpath', XPathEngine);
this._engines.set('xpath:light', XPathEngine); this._engines.set('xpath:light', XPathEngine);
this._engines.set('react', ReactEngine);
this._engines.set('text', this._createTextEngine(true)); this._engines.set('text', this._createTextEngine(true));
this._engines.set('text:light', this._createTextEngine(false)); this._engines.set('text:light', this._createTextEngine(false));
this._engines.set('id', this._createAttributeEngine('id', true)); this._engines.set('id', this._createAttributeEngine('id', true));

View file

@ -0,0 +1,152 @@
/**
* 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';
type ComponentNode = {
name: string,
children: ComponentNode[],
rootElements: Element[],
};
type ReactVNode = {
// React 16+
type: any,
child?: ReactVNode,
sibling?: ReactVNode,
stateNode?: Node,
// React 15
_hostNode?: any,
_currentElement?: any,
_renderedComponent?: any,
_renderedChildren?: any[],
};
function getComponentName(reactElement: ReactVNode): string {
// React 16+
// @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L16
if (typeof reactElement.type === 'function')
return reactElement.type.displayName || reactElement.type.name || 'Anonymous';
if (typeof reactElement.type === 'string')
return reactElement.type;
// React 15
// @see https://github.com/facebook/react/blob/2edf449803378b5c58168727d4f123de3ba5d37f/packages/react-devtools-shared/src/backend/legacy/renderer.js#L59
if (reactElement._currentElement) {
const elementType = reactElement._currentElement.type;
if (typeof elementType === 'string')
return elementType;
if (typeof elementType === 'function')
return elementType.displayName || elementType.name || 'Anonymous';
}
return '';
}
function getChildren(reactElement: ReactVNode): ReactVNode[] {
// React 16+
// @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L192
if (reactElement.child) {
const children: ReactVNode[] = [];
for (let child: ReactVNode|undefined = reactElement.child; child; child = child.sibling)
children.push(child);
return children;
}
// React 15
// @see https://github.com/facebook/react/blob/2edf449803378b5c58168727d4f123de3ba5d37f/packages/react-devtools-shared/src/backend/legacy/renderer.js#L101
if (!reactElement._currentElement)
return [];
const isKnownElement = (reactElement: ReactVNode) => {
const elementType = reactElement._currentElement?.type;
return typeof elementType === 'function' || typeof elementType === 'string';
};
if (reactElement._renderedComponent) {
const child = reactElement._renderedComponent;
return isKnownElement(child) ? [child] : [];
}
if (reactElement._renderedChildren)
return [...Object.values(reactElement._renderedChildren)].filter(isKnownElement);
return [];
}
function buildComponentsTree(reactElement: ReactVNode): ComponentNode {
const treeNode: ComponentNode = {
name: getComponentName(reactElement),
children: getChildren(reactElement).map(buildComponentsTree),
rootElements: [],
};
const rootElement =
// React 16+
// @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L29
reactElement.stateNode ||
// React 15
reactElement._hostNode || reactElement._renderedComponent?._hostNode;
if (rootElement instanceof Element) {
treeNode.rootElements.push(rootElement);
} else {
for (const child of treeNode.children)
treeNode.rootElements.push(...child.rootElements);
}
return treeNode;
}
function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: ComponentNode) => boolean, result: ComponentNode[] = []) {
if (searchFn(treeNode))
result.push(treeNode);
for (const child of treeNode.children)
filterComponentsTree(child, searchFn, result);
return result;
}
function findReactRoot(): ReactVNode | undefined {
const walker = document.createTreeWalker(document);
while (walker.nextNode()) {
// @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L329
if (walker.currentNode.hasOwnProperty('_reactRootContainer'))
return (walker.currentNode as any)._reactRootContainer._internalRoot.current;
for (const key of Object.keys(walker.currentNode)) {
// @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L334
if (key.startsWith('__reactInternalInstance') || key.startsWith('__reactFiber'))
return (walker.currentNode as any)[key];
}
}
return undefined;
}
export const ReactEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, name: string): Element[] {
const reactRoot = findReactRoot();
if (!reactRoot)
return [];
const tree = buildComponentsTree(reactRoot);
const treeNodes = filterComponentsTree(tree, treeNode => {
if (treeNode.name !== name)
return false;
if (treeNode.rootElements.some(domNode => !scope.contains(domNode)))
return false;
return true;
});
const allRootElements: Set<Element> = new Set();
for (const treeNode of treeNodes) {
for (const domNode of treeNode.rootElements)
allRootElements.add(domNode);
}
return [...allRootElements];
}
};

View file

@ -30,6 +30,7 @@ export type SelectorInfo = {
export class Selectors { export class Selectors {
readonly _builtinEngines: Set<string>; readonly _builtinEngines: Set<string>;
readonly _builtinEnginesInMainWorld: Set<string>;
readonly _engines: Map<string, { source: string, contentScript: boolean }>; readonly _engines: Map<string, { source: string, contentScript: boolean }>;
readonly guid = `selectors@${createGuid()}`; readonly guid = `selectors@${createGuid()}`;
@ -38,6 +39,7 @@ export class Selectors {
this._builtinEngines = new Set([ this._builtinEngines = new Set([
'css', 'css:light', 'css', 'css:light',
'xpath', 'xpath:light', 'xpath', 'xpath:light',
'react',
'text', 'text:light', 'text', 'text:light',
'id', 'id:light', 'id', 'id:light',
'data-testid', 'data-testid:light', 'data-testid', 'data-testid:light',
@ -45,6 +47,9 @@ export class Selectors {
'data-test', 'data-test:light', 'data-test', 'data-test:light',
'_visible', '_nth' '_visible', '_nth'
]); ]);
this._builtinEnginesInMainWorld = new Set([
'react',
]);
this._engines = new Map(); this._engines = new Map();
} }
@ -131,6 +136,8 @@ export class Selectors {
throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`); throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`);
if (custom && !custom.contentScript) if (custom && !custom.contentScript)
needsMainWorld = true; needsMainWorld = true;
if (this._builtinEnginesInMainWorld.has(part.name))
needsMainWorld = true;
} }
return { return {
parsed, parsed,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
<link rel=stylesheet href='./style.css'>
<script src="./react_15.7.0.js"></script>
<script src="./react-dom_15.7.0.js"></script>
<div id=root></div>
<script>
const e = React.createElement;
class AppHeader extends React.Component {
render() {
return e('div', null,
e('h1', null, `reactjs@${React.version}`),
e('h3', null, `Reading List: ${this.props.bookCount}`),
);
}
}
class NewBook extends React.Component {
constructor(props) {
super(props);
this.state = {
text: '',
};
}
onInput(event) {
this.state.text = event.target.value;
}
render() {
return e('div', null,
e('input', {onInput: this.onInput.bind(this)}, null),
e('button', {
onClick: () => this.props.onNewBook(this.state.text),
}, `new book`),
);
}
}
class BookItem extends React.Component {
render() {
return e('div', null, this.props.name);
}
}
class BookList extends React.Component {
render() {
return e('ol', null, this.props.books.map(book => e('li', {key: book.name}, e(BookItem, { name: book.name }))));
}
}
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
books: [
{name: 'Pride and Prejudice' },
{name: 'To Kill a Mockingbird' },
{name: 'The Great Gatsby' },
],
};
}
render() {
return e('div', null,
e(AppHeader, {bookCount: this.state.books.length}, null),
e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null),
e(BookList, {books: this.state.books}, null),
);
}
onNewBook(bookName) {
this.setState({
books: [...this.state.books, {name: bookName}],
});
}
}
ReactDOM.render(
e(App, null, null),
document.getElementById('root'),
);
</script>

View file

@ -0,0 +1,83 @@
<link rel=stylesheet href='./style.css'>
<script src="./react_16.14.0.js"></script>
<script src="./react-dom_16.14.0.js"></script>
<div id=root></div>
<script>
const e = React.createElement;
class AppHeader extends React.Component {
render() {
return e(React.Fragment, null,
e('h1', null, `reactjs@${React.version}`),
e('h3', null, `Reading List: ${this.props.bookCount}`),
);
}
}
class NewBook extends React.Component {
constructor(props) {
super(props);
this.state = '';
}
onInput(event) {
this.state = event.target.value;
}
render() {
return e(React.Fragment, null,
e('input', {onInput: this.onInput.bind(this)}, null),
e('button', {
onClick: () => this.props.onNewBook(this.state),
}, `new book`),
);
}
}
class BookItem extends React.Component {
render() {
return e('div', null, this.props.name);
}
}
class BookList extends React.Component {
render() {
return e('ol', null, this.props.books.map(book => e('li', {key: book.name}, e(BookItem, { name: book.name }))));
}
}
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
books: [
{name: 'Pride and Prejudice' },
{name: 'To Kill a Mockingbird' },
{name: 'The Great Gatsby' },
],
};
}
render() {
return e(React.Fragment, null,
e(AppHeader, {bookCount: this.state.books.length}, null),
e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null),
e(BookList, {books: this.state.books}, null),
);
}
onNewBook(bookName) {
this.setState({
books: [...this.state.books, {name: bookName}],
});
}
}
ReactDOM.render(
e(App, null, null),
document.getElementById('root'),
);
</script>

View file

@ -0,0 +1,81 @@
<link rel=stylesheet href='./style.css'>
<script src="./react_17.0.2.js"></script>
<script src="./react-dom_17.0.2.js"></script>
<div id=root></div>
<script>
const e = React.createElement;
function AppHeader(props) {
return e(React.Fragment, null,
e('h1', null, `reactjs@${React.version}`),
e('h3', null, `Reading List: ${props.bookCount}`),
);
}
class NewBook extends React.Component {
constructor(props) {
super(props);
this.state = '';
}
onInput(event) {
this.state = event.target.value;
}
render() {
return e(React.Fragment, null,
e('input', {onInput: this.onInput.bind(this)}, null),
e('button', {
onClick: () => this.props.onNewBook(this.state),
}, `new book`),
);
}
}
class BookItem extends React.Component {
render() {
return e('div', null, this.props.name);
}
}
class BookList extends React.Component {
render() {
return e('ol', null, this.props.books.map(book => e('li', {key: book.name}, e(BookItem, { name: book.name }))));
}
}
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
books: [
{name: 'Pride and Prejudice' },
{name: 'To Kill a Mockingbird' },
{name: 'The Great Gatsby' },
],
};
}
render() {
return e(React.Fragment, null,
e(AppHeader, {bookCount: this.state.books.length}, null),
e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null),
e(BookList, {books: this.state.books}, null),
);
}
onNewBook(bookName) {
this.setState({
books: [...this.state.books, {name: bookName}],
});
}
}
ReactDOM.render(
e(App, null, null),
document.getElementById('root'),
);
</script>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
:root {
--non-monospace: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--monospace: Consolas, Menlo, monospace;
font-size: 14px;
}
body {
font-family: var(--non-monospace);
}

View file

@ -0,0 +1,60 @@
/**
* Copyright 2018 Google Inc. All rights reserved.
* Modifications 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 { test as it, expect } from './pageTest';
const reacts = {
'react15': '/reading-list/react15.html',
'react16': '/reading-list/react16.html',
'react17': '/reading-list/react17.html',
};
for (const [name, url] of Object.entries(reacts)) {
it.describe(name, () => {
it.beforeEach(async ({page, server}) => {
await page.goto(server.PREFIX + url);
});
it('should work with single-root elements', async ({page}) => {
expect(await page.$$eval(`react=BookList`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=BookItem`, els => els.length)).toBe(3);
expect(await page.$$eval(`react=BookList >> react=BookItem`, els => els.length)).toBe(3);
expect(await page.$$eval(`react=BookItem >> react=BookList`, els => els.length)).toBe(0);
});
it('should work with multi-root elements (fragments)', async ({page}) => {
it.skip(name === 'react15', 'React 15 does not support fragments');
expect(await page.$$eval(`react=App`, els => els.length)).toBe(5);
expect(await page.$$eval(`react=AppHeader`, els => els.length)).toBe(2);
expect(await page.$$eval(`react=NewBook`, els => els.length)).toBe(2);
});
it('should not crash when there is no match', async ({page}) => {
expect(await page.$$eval(`react=Apps`, els => els.length)).toBe(0);
expect(await page.$$eval(`react=BookLi`, els => els.length)).toBe(0);
});
it('should compose', async ({page}) => {
expect(await page.$eval(`react=NewBook >> react=button`, el => el.textContent)).toBe('new book');
expect(await page.$eval(`react=NewBook >> react=input`, el => el.tagName)).toBe('INPUT');
expect(await page.$eval(`react=BookItem >> text=Gatsby`, el => el.textContent)).toBe('The Great Gatsby');
});
});
}