feat: support props matching in react and vue selectors (#8104)

This commit is contained in:
Andrey Lushnikov 2021-08-11 03:10:14 +03:00 committed by GitHub
parent b800c1d35c
commit 792986c92d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 656 additions and 25 deletions

View File

@ -181,36 +181,36 @@ methods accept [`param: selector`] as their first argument.
Learn more about [XPath selector][xpath].
- React selector
```js
await page.click('react=DatePickerComponent');
await page.click('react=ListItem[text *= "milk" i]');
```
```java
page.click("react=DatePickerComponent");
page.click("react=ListItem[text *= 'milk' i]");
```
```python async
await page.click("react=DatePickerComponent")
await page.click("react=ListItem[text *= 'milk' i]")
```
```python sync
page.click("react=DatePickerComponent")
page.click("react=ListItem[text *= 'milk' i]")
```
```csharp
await page.ClickAsync("react=DatePickerComponent");
await page.ClickAsync("react=ListItem[text *= 'milk' i]");
```
Learn more about [React selector][react].
- Vue selector
```js
await page.click('vue=DatePickerComponent');
await page.click('vue=list-item[text *= "milk" i]');
```
```java
page.click("vue=DatePickerComponent");
page.click("vue=list-item[text *= 'milk' i]");
```
```python async
await page.click("vue=DatePickerComponent")
await page.click("vue=list-item[text *= "milk" i]")
```
```python sync
page.click("vue=DatePickerComponent")
page.click("vue=list-item[text *= 'milk' i]")
```
```csharp
await page.ClickAsync("vue=DatePickerComponent");
await page.ClickAsync("vue=list-item[text *= 'milk' i]");
```
Learn more about [Vue selector][vue].
@ -663,11 +663,27 @@ converts `'//html/body'` to `'xpath=//html/body'`.
## React selectors
React selectors allow selecting elements by its component name.
React selectors allow selecting elements by its component name and property values. The syntax is very similar to [attribute selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) and supports all attribute selector operators.
In react selectors, component names are transcribed with **CamelCase**.
Selector examples:
- match by **component**: `react=BookItem`
- match by component and **exact property value**, case-sensetive: `react=BookItem[author = "Steven King"]`
- match by property value only, **case-insensetive**: `react=[author = "steven king" i]`
- match by component and **truthy property value**: `react=MyButton[enabled]`
- match by component and **boolean value**: `react=MyButton[enabled = false]`
- match by property **value substring**: `react=[author *= "King"]`
- match by component and **multiple properties**: `react=BookItem[author *= "king" i][year = 1990]`
- match by **nested** property value: `react=[some.nested.value = 12]`
- match by component and property value **prefix**: `react=BookItem[author ^= "Steven"]`
- match by component and property value **suffix**: `react=BookItem[author $= "Steven"]`
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.
@ -679,12 +695,25 @@ React selectors, as well as [React DevTools](https://chrome.google.com/webstore/
## Vue selectors
Vue selectors allow selecting elements by its component name.
Vue selectors allow selecting elements by its component name and property values. The syntax is very similar to [attribute selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) and supports all attribute selector operators.
In vue selectors, component names are transcribed with **kebab-case**.
Selector examples:
- match by **component**: `vue=book-item`
- match by component and **exact property value**, case-sensetive: `vue=book-item[author = "Steven King"]`
- match by property value only, **case-insensetive**: `vue=[author = "steven king" i]`
- match by component and **truthy property value**: `vue=my-button[enabled]`
- match by component and **boolean value**: `vue=my-button[enabled = false]`
- match by property **value substring**: `vue=[author *= "King"]`
- match by component and **multiple properties**: `vue=book-item[author *= "king" i][year = 1990]`
- match by **nested** property value: `vue=[some.nested.value = 12]`
- match by component and property value **prefix**: `vue=book-item[author ^= "Steven"]`
- match by component and property value **suffix**: `vue=book-item[author $= "Steven"]`
To find Vue element names in a tree use [Vue DevTools](https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd?hl=en).
Example: `vue=MyComponent`
:::note
Vue selectors support Vue2 and above.
:::

View File

@ -0,0 +1,202 @@
/**
* 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.
*/
type Operator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
export type ParsedComponentAttribute = {
jsonPath: string[],
op: Operator,
value: any,
caseSensetive: boolean,
};
export type ParsedComponentSelector = {
name: string,
attributes: ParsedComponentAttribute[],
};
export function checkComponentAttribute(obj: any, attr: ParsedComponentAttribute) {
for (const token of attr.jsonPath) {
if (obj !== undefined && obj !== null)
obj = obj[token];
}
const objValue = typeof obj === 'string' && !attr.caseSensetive ? obj.toUpperCase() : obj;
const attrValue = typeof attr.value === 'string' && !attr.caseSensetive ? attr.value.toUpperCase() : attr.value;
if (attr.op === '<truthy>')
return !!objValue;
if (attr.op === '=')
return objValue === attrValue;
if (typeof objValue !== 'string' || typeof attrValue !== 'string')
return false;
if (attr.op === '*=')
return objValue.includes(attrValue);
if (attr.op === '^=')
return objValue.startsWith(attrValue);
if (attr.op === '$=')
return objValue.endsWith(attrValue);
if (attr.op === '|=')
return objValue === attrValue || objValue.startsWith(attrValue + '-');
if (attr.op === '~=')
return objValue.split(' ').includes(attrValue);
return false;
}
export function parseComponentSelector(selector: string): ParsedComponentSelector {
let wp = 0;
let EOL = selector.length === 0;
const next = () => selector[wp] || '';
const eat1 = () => {
const result = next();
++wp;
EOL = wp >= selector.length;
return result;
};
const syntaxError = (stage: string|undefined) => {
if (EOL)
throw new Error(`Unexpected end of selector while parsing selector \`${selector}\``);
throw new Error(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : ''));
};
function skipSpaces() {
while (!EOL && /\s/.test(next()))
eat1();
}
function readIdentifier() {
let result = '';
skipSpaces();
while (!EOL && /[-$0-9A-Z_]/i.test(next()))
result += eat1();
return result;
}
function readQuotedString(quote: string) {
let result = eat1();
if (result !== quote)
syntaxError('parsing quoted string');
while (!EOL && next() !== quote) {
if (next() === '\\')
eat1();
result += eat1();
}
if (next() !== quote)
syntaxError('parsing quoted string');
result += eat1();
return result;
}
function readAttributeToken() {
let token = '';
skipSpaces();
if (next() === `'` || next() === `"`)
token = readQuotedString(next()).slice(1, -1);
else
token = readIdentifier();
if (!token)
syntaxError('parsing property path');
return token;
}
function readOperator(): Operator {
skipSpaces();
let op = '';
if (!EOL)
op += eat1();
if (!EOL && (op !== '='))
op += eat1();
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
syntaxError('parsing operator');
return (op as Operator);
}
function readAttribute(): ParsedComponentAttribute {
// skip leading [
eat1();
// read attribute name:
// foo.bar
// 'foo' . "ba zz"
const jsonPath = [];
jsonPath.push(readAttributeToken());
skipSpaces();
while (next() === '.') {
eat1();
jsonPath.push(readAttributeToken());
skipSpaces();
}
// check property is truthy: [enabled]
if (next() === ']') {
eat1();
return {jsonPath, op: '<truthy>', value: null, caseSensetive: false};
}
const operator = readOperator();
let value = undefined;
let caseSensetive = true;
skipSpaces();
if (next() === `'` || next() === `"`) {
value = readQuotedString(next()).slice(1, -1);
skipSpaces();
if (next() === 'i' || next() === 'I') {
caseSensetive = false;
eat1();
} else if (next() === 's' || next() === 'S') {
caseSensetive = true;
eat1();
}
} else {
value = '';
while (!EOL && !/\s/.test(next()) && next() !== ']')
value += eat1();
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else {
value = +value;
if (isNaN(value))
syntaxError('parsing attribute value');
}
}
skipSpaces();
if (next() !== ']')
syntaxError('parsing attribute value');
eat1();
if (operator !== '=' && typeof value !== 'string')
throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`);
return {jsonPath, op: operator, value, caseSensetive};
}
const result: ParsedComponentSelector = {
name: '',
attributes: [],
};
result.name = readIdentifier();
skipSpaces();
while (next() === '[') {
result.attributes.push(readAttribute());
skipSpaces();
}
if (!EOL)
syntaxError(undefined);
if (!result.name && !result.attributes.length)
throw new Error(`Error while parsing selector \`${selector}\` - selector cannot be empty`);
return result;
}

View File

@ -15,11 +15,13 @@
*/
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils';
type ComponentNode = {
name: string,
children: ComponentNode[],
rootElements: Element[],
props: any,
};
type ReactVNode = {
@ -28,6 +30,7 @@ type ReactVNode = {
child?: ReactVNode,
sibling?: ReactVNode,
stateNode?: Node,
memoizedProps?: any,
// React 15
_hostNode?: any,
@ -84,11 +87,26 @@ function getChildren(reactElement: ReactVNode): ReactVNode[] {
return [];
}
function getProps(reactElement: ReactVNode) {
const props =
// React 16+
reactElement.memoizedProps ||
// React 15
reactElement._currentElement?.props;
if (!props || typeof props === 'string')
return props;
const result = { ...props };
delete result.children;
return result;
}
function buildComponentsTree(reactElement: ReactVNode): ComponentNode {
const treeNode: ComponentNode = {
name: getComponentName(reactElement),
children: getChildren(reactElement).map(buildComponentsTree),
rootElements: [],
props: getProps(reactElement),
};
const rootElement =
@ -130,16 +148,22 @@ function findReactRoot(): ReactVNode | undefined {
}
export const ReactEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, name: string): Element[] {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const {name, attributes} = parseComponentSelector(selector);
const reactRoot = findReactRoot();
if (!reactRoot)
return [];
const tree = buildComponentsTree(reactRoot);
const treeNodes = filterComponentsTree(tree, treeNode => {
if (treeNode.name !== name)
if (name && treeNode.name !== name)
return false;
if (treeNode.rootElements.some(domNode => !scope.contains(domNode)))
return false;
for (const attr of attributes) {
if (!checkComponentAttribute(treeNode.props, attr))
return false;
}
return true;
});
const allRootElements: Set<Element> = new Set();

View File

@ -15,11 +15,13 @@
*/
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils';
type ComponentNode = {
name: string,
children: ComponentNode[],
rootElements: Element[],
props: any,
};
type VueVNode = {
@ -31,6 +33,7 @@ type VueVNode = {
_isBeingDestroyed?: any,
isUnmounted?: any,
subTree: any,
props: any,
// Vue2
$children?: VueVNode[],
@ -38,6 +41,7 @@ type VueVNode = {
$options?: any,
$root?: VueVNode,
$el?: Element,
_props: any,
};
// @see https://github.com/vuejs/devtools/blob/14085e25313bcf8ffcb55f9092a40bc0fe3ac11c/packages/shared-utils/src/util.ts#L295
@ -145,6 +149,7 @@ function buildComponentsTreeVue3(instance: VueVNode): ComponentNode {
name: getInstanceName(instance),
children: getInternalInstanceChildren(instance.subTree).map(buildComponentsTree),
rootElements: getRootElementsFromComponentInstance(instance),
props: instance.props,
};
}
@ -184,6 +189,7 @@ function buildComponentsTreeVue2(instance: VueVNode): ComponentNode {
name: getInstanceName(instance),
children: getInternalInstanceChildren(instance).map(buildComponentsTree),
rootElements: [instance.$el!],
props: instance._props,
};
}
@ -212,16 +218,22 @@ function findVueRoot(): undefined|{version: number, root: VueVNode} {
}
export const VueEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, name: string): Element[] {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const {name, attributes} = parseComponentSelector(selector);
const vueRoot = findVueRoot();
if (!vueRoot)
return [];
const tree = vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root);
const treeNodes = filterComponentsTree(tree, treeNode => {
if (treeNode.name !== name)
if (name && treeNode.name !== name)
return false;
if (treeNode.rootElements.some(rootElement => !scope.contains(rootElement)))
return false;
for (const attr of attributes) {
if (!checkComponentAttribute(treeNode.props, attr))
return false;
}
return true;
});
const allRootElements: Set<Element> = new Set();

View File

@ -16,6 +16,29 @@ class AppHeader extends React.Component {
}
}
class ColorButton extends React.Component {
render() {
return e('button', {className: this.props.color, disabled: !this.props.enabled}, 'button ' + this.props.nested.index);
}
}
class ButtonGrid extends React.Component {
render() {
const buttons = [];
for (let i = 0; i < 9; ++i) {
buttons.push(e(ColorButton, {
color: ['red', 'green', 'blue'][i % 3],
enabled: i % 2 === 0,
nested: {
index: i,
value: i + 0.1,
}
}, null));
};
return e('div', null, ...buttons);
}
}
class NewBook extends React.Component {
constructor(props) {
super(props);
@ -67,6 +90,7 @@ class App extends React.Component {
e(AppHeader, {bookCount: this.state.books.length}, null),
e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null),
e(BookList, {books: this.state.books}, null),
e(ButtonGrid, null, null),
);
}

View File

@ -16,6 +16,29 @@ class AppHeader extends React.Component {
}
}
class ColorButton extends React.Component {
render() {
return e('button', {className: this.props.color, disabled: !this.props.enabled}, 'button ' + this.props.nested.index);
}
}
class ButtonGrid extends React.Component {
render() {
const buttons = [];
for (let i = 0; i < 9; ++i) {
buttons.push(e(ColorButton, {
color: ['red', 'green', 'blue'][i % 3],
enabled: i % 2 === 0,
nested: {
index: i,
value: i + 0.1,
}
}, null));
};
return e(React.Fragment, null, ...buttons);
}
}
class NewBook extends React.Component {
constructor(props) {
super(props);
@ -65,6 +88,7 @@ class App extends React.Component {
e(AppHeader, {bookCount: this.state.books.length}, null),
e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null),
e(BookList, {books: this.state.books}, null),
e(ButtonGrid, null, null),
);
}

View File

@ -34,6 +34,25 @@ class NewBook extends React.Component {
}
}
function ColorButton (props) {
return e('button', {className: props.color, disabled: !props.enabled}, 'button ' + props.nested.index);
}
function ButtonGrid() {
const buttons = [];
for (let i = 0; i < 9; ++i) {
buttons.push(e(ColorButton, {
color: ['red', 'green', 'blue'][i % 3],
enabled: i % 2 === 0,
nested: {
index: i,
value: i + 0.1,
}
}, null));
};
return e(React.Fragment, null, ...buttons);
}
class BookItem extends React.Component {
render() {
return e('div', null, this.props.name);
@ -63,6 +82,7 @@ class App extends React.Component {
e(AppHeader, {bookCount: this.state.books.length}, null),
e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null),
e(BookList, {books: this.state.books}, null),
e(ButtonGrid, null, null),
);
}

View File

@ -7,3 +7,7 @@
body {
font-family: var(--non-monospace);
}
.red { background-color: #ffcdd2; }
.green { background-color: #a5d6a7; }
.blue { background-color: #64b5f6; }

View File

@ -36,9 +36,7 @@ Vue.component('new-book', {
});
Vue.component('book-item', {
template: `
<div>
{{ name }}
</div>
<div>{{ name }}</div>
`,
props: ['name'],
});
@ -54,6 +52,36 @@ Vue.component('book-list', {
`,
});
Vue.component('color-button', {
props: {
color: String,
enabled: Boolean,
nested: Object,
},
template: `
<button :disabled='enabled' :class='color'>button {{nested.index}}</button>
`,
});
Vue.component('button-grid', {
render(createElement) {
const buttons = [];
for (let i = 0; i < 9; ++i) {
buttons.push(createElement('color-button', {
props: {
color: ['red', 'green', 'blue'][i % 3],
enabled: i % 2 === 0,
nested: {
index: i,
value: i + 0.1,
}
}
}, null));
};
return createElement('div', null, buttons);
}
});
new Vue({
el: '#root',
@ -63,6 +91,7 @@ new Vue({
<app-header :bookCount='books.length'></app-header>
<new-book @newbook='addNewBook'></new-book>
<book-list :books='books'></book-list>
<button-grid></button-grid>
</div>
`,

View File

@ -10,6 +10,7 @@ const app = Vue.createApp({
<app-header :bookCount='books.length'></app-header>
<new-book @newbook='addNewBook'></new-book>
<book-list :books='books'></book-list>
<button-grid></button-grid>
`,
data() {
@ -74,6 +75,38 @@ app.component('book-list', {
`,
});
app.component('color-button', {
props: {
color: String,
enabled: Boolean,
nested: {
index: Number,
value: Number,
},
},
template: `
<button :disabled='enabled' :class='color'>button {{nested.index}}</button>
`,
});
app.component('button-grid', {
render() {
const buttons = [];
const ColorButton = Vue.resolveComponent('color-button');
for (let i = 0; i < 9; ++i) {
buttons.push(Vue.h(ColorButton, {
color: ['red', 'green', 'blue'][i % 3],
enabled: i % 2 === 0,
nested: {
index: i,
value: i + 0.1,
}
}, null));
};
return buttons;
}
});
app.mount('#root');
</script>

View File

@ -0,0 +1,132 @@
/**
* 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 { playwrightTest as it, expect } from './config/browserTest';
import { parseComponentSelector, ParsedComponentSelector } from '../src/server/common/componentUtils';
const parse = parseComponentSelector;
const serialize = (parsed: ParsedComponentSelector) => {
return parsed.name + parsed.attributes.map(attr => {
const path = attr.jsonPath.map(token => /^[a-zA-Z0-9]+$/i.test(token) ? token : JSON.stringify(token)).join('.');
if (attr.op === '<truthy>')
return '[' + path + ']';
return '[' + path + ' ' + attr.op + ' ' + JSON.stringify(attr.value) + (attr.caseSensetive ? ']' : ' i]');
}).join('');
};
function expectError(selector: string) {
let error = { message: '' };
try {
parse(selector);
} catch (e) {
error = e;
}
expect(error.message).toContain(`while parsing selector \`${selector}\``);
}
it('should parse', async () => {
expect(serialize(parse('[foo="]"]'))).toBe('[foo = "]"]');
expect(serialize(parse('[foo="10"s]'))).toBe('[foo = "10"]');
expect(serialize(parse('[foo="10" s]'))).toBe('[foo = "10"]');
expect(serialize(parse('[foo="true"]'))).toBe('[foo = "true"]');
expect(serialize(parse('[foo=""]'))).toBe('[foo = ""]');
expect(serialize(parse('[foo="=="]'))).toBe('[foo = "=="]');
});
it('should parse short attributes', async () => {
expect(serialize(parse(`BookItem [ name ]`))).toBe('BookItem[name]');
expect(serialize(parse(`BookItem ['name' ] [ foo."bar".baz ]`))).toBe('BookItem[name][foo.bar.baz]');
expect(serialize(parse(`BookItem ['na me' ]`))).toBe('BookItem["na me"]');
});
it('should parse all operators', async () => {
expect(serialize(parse(`BookItem[name = 10]`))).toBe('BookItem[name = 10]');
expect(serialize(parse(`BookItem[name = 'foo']`))).toBe(`BookItem[name = "foo"]`);
expect(serialize(parse(`BookItem[name *= 'foo']`))).toBe(`BookItem[name *= "foo"]`);
expect(serialize(parse(`BookItem[name ^= 'foo']`))).toBe(`BookItem[name ^= "foo"]`);
expect(serialize(parse(`BookItem[name $= 'foo']`))).toBe(`BookItem[name $= "foo"]`);
expect(serialize(parse(`BookItem[name ~= 'foo']`))).toBe(`BookItem[name ~= "foo"]`);
expect(serialize(parse(`BookItem[name |= 'foo']`))).toBe(`BookItem[name |= "foo"]`);
});
it('should tolerate spacing', async () => {
expect(serialize(parse(` BookItem[ name = "Foo " ]`))).toBe('BookItem[name = "Foo "]');
expect(serialize(parse(` BookItem [ name = "Foo" ] `))).toBe('BookItem[name = "Foo"]');
expect(serialize(parse(` [ name = "Foo"]`))).toBe('[name = "Foo"]');
expect(serialize(parse(` BookItem ["name" = "Foo" i] `))).toBe('BookItem[name = "Foo" i]');
expect(serialize(parse(`BookItem [ 'name' = 'Foo'i ] `))).toBe('BookItem[name = "Foo" i]');
});
it('should escape', async () => {
expect(serialize(parse(`BookItem['jake\\'s' = 10]`))).toBe(`BookItem["jake's" = 10]`);
expect(serialize(parse(`BookItem['jake"s' = 10]`))).toBe(`BookItem["jake\\"s" = 10]`);
expect(serialize(parse(`BookItem["jake\\"s" = 10]`))).toBe(`BookItem["jake\\"s" = 10]`);
expect(serialize(parse(`BookItem[name = 'foo\\'bar']`))).toBe(`BookItem[name = "foo'bar"]`);
expect(serialize(parse(`BookItem[name = "foo'bar"]`))).toBe(`BookItem[name = "foo'bar"]`);
expect(serialize(parse(`BookItem[name = "foo\\"bar"]`))).toBe(`BookItem[name = "foo\\"bar"]`);
});
it('should parse int values', async () => {
expect(serialize(parse(`ColorButton[value = 10]`))).toBe('ColorButton[value = 10]');
expect(serialize(parse(`ColorButton[value = +10]`))).toBe('ColorButton[value = 10]');
expect(serialize(parse(`ColorButton[value = -10]`))).toBe('ColorButton[value = -10]');
expect(serialize(parse(`ColorButton [ "nested". "index" = 0 ] `))).toBe('ColorButton[nested.index = 0]');
});
it('should parse float values', async () => {
expect(serialize(parse(`ColorButton[value = -12.3]`))).toBe('ColorButton[value = -12.3]');
expect(serialize(parse(`ColorButton ['nested'.value = 4.1]`))).toBe('ColorButton[nested.value = 4.1]');
expect(serialize(parse(`ColorButton [ 'nested' .value =4.1]`))).toBe('ColorButton[nested.value = 4.1]');
});
it('shoulud parse bool', async () => {
expect(serialize(parse(`ColorButton[enabled= false] `))).toBe('ColorButton[enabled = false]');
expect(serialize(parse(`ColorButton[enabled =true] `))).toBe('ColorButton[enabled = true]');
expect(serialize(parse(`ColorButton[enabled =true][ color = "red"]`))).toBe('ColorButton[enabled = true][color = "red"]');
expect(serialize(parse(`ColorButton[ enabled =true][ color = "red"i][nested.index = 6]`))).toBe('ColorButton[enabled = true][color = "red" i][nested.index = 6]');
});
it('should throw on malformed selector', async () => {
expectError('foo[');
expectError('foo[');
expectError('foo["asd');
expectError('foo["asd"');
expectError('foo["asd"');
expectError('foo[.bar=10]');
expectError('foo[bar **= 10]');
expectError('foo[bar == 10]');
expectError('foo[bar = 10 [baz=20]');
expectError('foo[bar = 10 i[baz=20]');
expectError('foo[bar *= #%s]');
expectError('foo[bar *= 10]');
expectError('');
expectError('[foo=10 s]');
expectError('[foo=10 p]');
expectError('foo.bar');
expectError('foo[]');
expectError('["a\"b"=foo]');
expectError('[foo=10"bar"]');
expectError('[foo= ==]');
expectError('[foo===]');
expectError('[foo="\"]"[]');
expectError('[foo=abc S]');
expectError('[foo=abc \s]');
expectError('[foo=abc"\s"]');
expectError('[foo="\\"]');
});

View File

@ -39,7 +39,7 @@ for (const [name, url] of Object.entries(reacts)) {
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=App`, els => els.length)).toBe(14);
expect(await page.$$eval(`react=AppHeader`, els => els.length)).toBe(2);
expect(await page.$$eval(`react=NewBook`, els => els.length)).toBe(2);
});
@ -55,6 +55,55 @@ for (const [name, url] of Object.entries(reacts)) {
expect(await page.$eval(`react=BookItem >> text=Gatsby`, el => el.textContent)).toBe('The Great Gatsby');
});
it('should query by props combinations', async ({page}) => {
expect(await page.$$eval(`react=BookItem[name="The Great Gatsby"]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=BookItem[name="the great gatsby" i]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=ColorButton[nested.index = 0]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=ColorButton[nested.nonexisting.index = 0]`, els => els.length)).toBe(0);
expect(await page.$$eval(`react=ColorButton[nested.index.nonexisting = 0]`, els => els.length)).toBe(0);
expect(await page.$$eval(`react=ColorButton[nested.index.nonexisting = 1]`, els => els.length)).toBe(0);
expect(await page.$$eval(`react=ColorButton[nested.value = 4.1]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=ColorButton[enabled = false]`, els => els.length)).toBe(4);
expect(await page.$$eval(`react=ColorButton[enabled = true] `, els => els.length)).toBe(5);
expect(await page.$$eval(`react=ColorButton[enabled = true][color = "red"]`, els => els.length)).toBe(2);
expect(await page.$$eval(`react=ColorButton[enabled = true][color = "red"i][nested.index = 6]`, els => els.length)).toBe(1);
});
it('should exact match by props', async ({page}) => {
expect(await page.$eval(`react=BookItem[name = "The Great Gatsby"]`, el => el.textContent)).toBe('The Great Gatsby');
expect(await page.$$eval(`react=BookItem[name = "The Great Gatsby"]`, els => els.length)).toBe(1);
// case sensetive by default
expect(await page.$$eval(`react=BookItem[name = "the great gatsby"]`, els => els.length)).toBe(0);
expect(await page.$$eval(`react=BookItem[name = "the great gatsby" s]`, els => els.length)).toBe(0);
expect(await page.$$eval(`react=BookItem[name = "the great gatsby" S]`, els => els.length)).toBe(0);
// case insensetive with flag
expect(await page.$$eval(`react=BookItem[name = "the great gatsby" i]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=BookItem[name = "the great gatsby" I]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=BookItem[name = " The Great Gatsby "]`, els => els.length)).toBe(0);
});
it('should partially match by props', async ({page}) => {
// Check partial matching
expect(await page.$eval(`react=BookItem[name *= "Gatsby"]`, el => el.textContent)).toBe('The Great Gatsby');
expect(await page.$$eval(`react=BookItem[name *= "Gatsby"]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=[name *= "Gatsby"]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=BookItem[name = "Gatsby"]`, els => els.length)).toBe(0);
});
it('should support all string operators', async ({page}) => {
expect(await page.$$eval(`react=ColorButton[color = "red"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`react=ColorButton[color |= "red"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`react=ColorButton[color $= "ed"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`react=ColorButton[color ^= "gr"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`react=ColorButton[color ~= "e"]`, els => els.length)).toBe(0);
expect(await page.$$eval(`react=BookItem[name ~= "gatsby" i]`, els => els.length)).toBe(1);
expect(await page.$$eval(`react=BookItem[name *= " gatsby" i]`, els => els.length)).toBe(1);
});
it('should support truthy querying', async ({page}) => {
expect(await page.$$eval(`react=ColorButton[enabled]`, els => els.length)).toBe(5);
});
});
}

View File

@ -38,7 +38,7 @@ for (const [name, url] of Object.entries(vues)) {
it('should work with multi-root elements (fragments)', async ({page}) => {
it.skip(name === 'vue2', 'vue2 does not support fragments');
expect(await page.$$eval(`vue=Root`, els => els.length)).toBe(5);
expect(await page.$$eval(`vue=Root`, els => els.length)).toBe(14);
expect(await page.$$eval(`vue=app-header`, els => els.length)).toBe(2);
expect(await page.$$eval(`vue=new-book`, els => els.length)).toBe(2);
});
@ -52,6 +52,55 @@ for (const [name, url] of Object.entries(vues)) {
expect(await page.$eval(`vue=book-item >> text=Gatsby`, el => el.textContent.trim())).toBe('The Great Gatsby');
});
it('should query by props combinations', async ({page}) => {
expect(await page.$$eval(`vue=book-item[name="The Great Gatsby"]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=book-item[name="the great gatsby" i]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=color-button[nested.index = 0]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=color-button[nested.nonexisting.index = 0]`, els => els.length)).toBe(0);
expect(await page.$$eval(`vue=color-button[nested.index.nonexisting = 0]`, els => els.length)).toBe(0);
expect(await page.$$eval(`vue=color-button[nested.index.nonexisting = 1]`, els => els.length)).toBe(0);
expect(await page.$$eval(`vue=color-button[nested.value = 4.1]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=color-button[enabled = false]`, els => els.length)).toBe(4);
expect(await page.$$eval(`vue=color-button[enabled = true] `, els => els.length)).toBe(5);
expect(await page.$$eval(`vue=color-button[enabled = true][color = "red"]`, els => els.length)).toBe(2);
expect(await page.$$eval(`vue=color-button[enabled = true][color = "red"i][nested.index = 6]`, els => els.length)).toBe(1);
});
it('should exact match by props', async ({page}) => {
expect(await page.$eval(`vue=book-item[name = "The Great Gatsby"]`, el => el.textContent)).toBe('The Great Gatsby');
expect(await page.$$eval(`vue=book-item[name = "The Great Gatsby"]`, els => els.length)).toBe(1);
// case sensetive by default
expect(await page.$$eval(`vue=book-item[name = "the great gatsby"]`, els => els.length)).toBe(0);
expect(await page.$$eval(`vue=book-item[name = "the great gatsby" s]`, els => els.length)).toBe(0);
expect(await page.$$eval(`vue=book-item[name = "the great gatsby" S]`, els => els.length)).toBe(0);
// case insensetive with flag
expect(await page.$$eval(`vue=book-item[name = "the great gatsby" i]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=book-item[name = "the great gatsby" I]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=book-item[name = " The Great Gatsby "]`, els => els.length)).toBe(0);
});
it('should partially match by props', async ({page}) => {
// Check partial matching
expect(await page.$eval(`vue=book-item[name *= "Gatsby"]`, el => el.textContent)).toBe('The Great Gatsby');
expect(await page.$$eval(`vue=book-item[name *= "Gatsby"]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=[name *= "Gatsby"]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=book-item[name = "Gatsby"]`, els => els.length)).toBe(0);
});
it('should support all string operators', async ({page}) => {
expect(await page.$$eval(`vue=color-button[color = "red"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`vue=color-button[color |= "red"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`vue=color-button[color $= "ed"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`vue=color-button[color ^= "gr"]`, els => els.length)).toBe(3);
expect(await page.$$eval(`vue=color-button[color ~= "e"]`, els => els.length)).toBe(0);
expect(await page.$$eval(`vue=book-item[name ~= "gatsby" i]`, els => els.length)).toBe(1);
expect(await page.$$eval(`vue=book-item[name *= " gatsby" i]`, els => els.length)).toBe(1);
});
it('should support truthy querying', async ({page}) => {
expect(await page.$$eval(`vue=color-button[enabled]`, els => els.length)).toBe(5);
});
});
}