mirror of
https://github.com/microsoft/playwright.git
synced 2024-10-27 05:46:28 +03:00
feat: support props matching in react and vue selectors (#8104)
This commit is contained in:
parent
b800c1d35c
commit
792986c92d
@ -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.
|
||||
:::
|
||||
|
202
src/server/common/componentUtils.ts
Normal file
202
src/server/common/componentUtils.ts
Normal 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;
|
||||
}
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
@ -61,10 +84,11 @@ class App extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
return e(React.Fragment, null,
|
||||
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),
|
||||
e(ButtonGrid, null, null),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -7,3 +7,7 @@
|
||||
body {
|
||||
font-family: var(--non-monospace);
|
||||
}
|
||||
|
||||
.red { background-color: #ffcdd2; }
|
||||
.green { background-color: #a5d6a7; }
|
||||
.blue { background-color: #64b5f6; }
|
||||
|
@ -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>
|
||||
`,
|
||||
|
||||
|
@ -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>
|
||||
|
132
tests/component-parser.spec.ts
Normal file
132
tests/component-parser.spec.ts
Normal 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="\\"]');
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user