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:
parent
40fb9d85e0
commit
f3ba2b54ff
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
152
src/server/injected/reactSelectorEngine.ts
Normal file
152
src/server/injected/reactSelectorEngine.ts
Normal 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];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
18853
tests/assets/reading-list/react-dom_15.7.0.js
Normal file
18853
tests/assets/reading-list/react-dom_15.7.0.js
Normal file
File diff suppressed because it is too large
Load diff
25147
tests/assets/reading-list/react-dom_16.14.0.js
Normal file
25147
tests/assets/reading-list/react-dom_16.14.0.js
Normal file
File diff suppressed because it is too large
Load diff
26292
tests/assets/reading-list/react-dom_17.0.2.js
Normal file
26292
tests/assets/reading-list/react-dom_17.0.2.js
Normal file
File diff suppressed because it is too large
Load diff
85
tests/assets/reading-list/react15.html
Normal file
85
tests/assets/reading-list/react15.html
Normal 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>
|
||||||
83
tests/assets/reading-list/react16.html
Normal file
83
tests/assets/reading-list/react16.html
Normal 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>
|
||||||
81
tests/assets/reading-list/react17.html
Normal file
81
tests/assets/reading-list/react17.html
Normal 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>
|
||||||
4268
tests/assets/reading-list/react_15.7.0.js
Normal file
4268
tests/assets/reading-list/react_15.7.0.js
Normal file
File diff suppressed because it is too large
Load diff
3318
tests/assets/reading-list/react_16.14.0.js
Normal file
3318
tests/assets/reading-list/react_16.14.0.js
Normal file
File diff suppressed because it is too large
Load diff
3357
tests/assets/reading-list/react_17.0.2.js
Normal file
3357
tests/assets/reading-list/react_17.0.2.js
Normal file
File diff suppressed because it is too large
Load diff
9
tests/assets/reading-list/style.css
Normal file
9
tests/assets/reading-list/style.css
Normal 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);
|
||||||
|
}
|
||||||
60
tests/page/selectors-react.spec.ts
Normal file
60
tests/page/selectors-react.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in a new issue