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");
|
||||
```
|
||||
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
|
||||
|
||||
|
|
@ -625,6 +644,23 @@ converts `'//html/body'` to `'xpath=//html/body'`.
|
|||
`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
|
||||
|
||||
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
|
||||
[css]: #css-selector
|
||||
[xpath]: #xpath-selectors
|
||||
[react]: #react-selectors
|
||||
[id]: #id-data-testid-data-test-id-data-test-selectors
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
import { XPathEngine } from './xpathSelectorEngine';
|
||||
import { ReactEngine } from './reactSelectorEngine';
|
||||
import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser';
|
||||
import { FatalDOMError } from '../common/domErrors';
|
||||
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
|
||||
|
|
@ -62,6 +63,7 @@ export class InjectedScript {
|
|||
this._engines = new Map();
|
||||
this._engines.set('xpath', XPathEngine);
|
||||
this._engines.set('xpath:light', XPathEngine);
|
||||
this._engines.set('react', ReactEngine);
|
||||
this._engines.set('text', this._createTextEngine(true));
|
||||
this._engines.set('text:light', this._createTextEngine(false));
|
||||
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 {
|
||||
readonly _builtinEngines: Set<string>;
|
||||
readonly _builtinEnginesInMainWorld: Set<string>;
|
||||
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
|
||||
readonly guid = `selectors@${createGuid()}`;
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ export class Selectors {
|
|||
this._builtinEngines = new Set([
|
||||
'css', 'css:light',
|
||||
'xpath', 'xpath:light',
|
||||
'react',
|
||||
'text', 'text:light',
|
||||
'id', 'id:light',
|
||||
'data-testid', 'data-testid:light',
|
||||
|
|
@ -45,6 +47,9 @@ export class Selectors {
|
|||
'data-test', 'data-test:light',
|
||||
'_visible', '_nth'
|
||||
]);
|
||||
this._builtinEnginesInMainWorld = new Set([
|
||||
'react',
|
||||
]);
|
||||
this._engines = new Map();
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +136,8 @@ export class Selectors {
|
|||
throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`);
|
||||
if (custom && !custom.contentScript)
|
||||
needsMainWorld = true;
|
||||
if (this._builtinEnginesInMainWorld.has(part.name))
|
||||
needsMainWorld = true;
|
||||
}
|
||||
return {
|
||||
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