From 792986c92d0163f1a9e7eb12b16cd61f214e3af9 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Wed, 11 Aug 2021 03:10:14 +0300 Subject: [PATCH] feat: support props matching in react and vue selectors (#8104) --- docs/src/selectors.md | 59 ++++-- src/server/common/componentUtils.ts | 202 +++++++++++++++++++++ src/server/injected/reactSelectorEngine.ts | 28 ++- src/server/injected/vueSelectorEngine.ts | 16 +- tests/assets/reading-list/react15.html | 24 +++ tests/assets/reading-list/react16.html | 26 ++- tests/assets/reading-list/react17.html | 20 ++ tests/assets/reading-list/style.css | 4 + tests/assets/reading-list/vue2.html | 35 +++- tests/assets/reading-list/vue3.html | 33 ++++ tests/component-parser.spec.ts | 132 ++++++++++++++ tests/page/selectors-react.spec.ts | 51 +++++- tests/page/selectors-vue.spec.ts | 51 +++++- 13 files changed, 656 insertions(+), 25 deletions(-) create mode 100644 src/server/common/componentUtils.ts create mode 100644 tests/component-parser.spec.ts diff --git a/docs/src/selectors.md b/docs/src/selectors.md index fe25fa8789..d45dcdf5d8 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -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. ::: diff --git a/src/server/common/componentUtils.ts b/src/server/common/componentUtils.ts new file mode 100644 index 0000000000..2a349bb352 --- /dev/null +++ b/src/server/common/componentUtils.ts @@ -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 = ''|'='|'*='|'|='|'^='|'$='|'~='; +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 === '') + 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: '', 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; +} diff --git a/src/server/injected/reactSelectorEngine.ts b/src/server/injected/reactSelectorEngine.ts index c8517d146e..dd3694ba3c 100644 --- a/src/server/injected/reactSelectorEngine.ts +++ b/src/server/injected/reactSelectorEngine.ts @@ -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 = new Set(); diff --git a/src/server/injected/vueSelectorEngine.ts b/src/server/injected/vueSelectorEngine.ts index 8ca41c11f0..20cf822815 100644 --- a/src/server/injected/vueSelectorEngine.ts +++ b/src/server/injected/vueSelectorEngine.ts @@ -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 = new Set(); diff --git a/tests/assets/reading-list/react15.html b/tests/assets/reading-list/react15.html index 80fb606cfe..40fefc9933 100644 --- a/tests/assets/reading-list/react15.html +++ b/tests/assets/reading-list/react15.html @@ -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), ); } diff --git a/tests/assets/reading-list/react16.html b/tests/assets/reading-list/react16.html index d7b130c995..77a15768b6 100644 --- a/tests/assets/reading-list/react16.html +++ b/tests/assets/reading-list/react16.html @@ -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), ); } diff --git a/tests/assets/reading-list/react17.html b/tests/assets/reading-list/react17.html index e5f036a841..ac5463dc40 100644 --- a/tests/assets/reading-list/react17.html +++ b/tests/assets/reading-list/react17.html @@ -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), ); } diff --git a/tests/assets/reading-list/style.css b/tests/assets/reading-list/style.css index 2236a19401..9345aca7fb 100644 --- a/tests/assets/reading-list/style.css +++ b/tests/assets/reading-list/style.css @@ -7,3 +7,7 @@ body { font-family: var(--non-monospace); } + +.red { background-color: #ffcdd2; } +.green { background-color: #a5d6a7; } +.blue { background-color: #64b5f6; } diff --git a/tests/assets/reading-list/vue2.html b/tests/assets/reading-list/vue2.html index ad36cfef6b..4a253e37f5 100644 --- a/tests/assets/reading-list/vue2.html +++ b/tests/assets/reading-list/vue2.html @@ -36,9 +36,7 @@ Vue.component('new-book', { }); Vue.component('book-item', { template: ` -
- {{ name }} -
+
{{ name }}
`, props: ['name'], }); @@ -54,6 +52,36 @@ Vue.component('book-list', { `, }); +Vue.component('color-button', { + props: { + color: String, + enabled: Boolean, + nested: Object, + }, + template: ` + + `, +}); + +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({ + `, diff --git a/tests/assets/reading-list/vue3.html b/tests/assets/reading-list/vue3.html index 6bb282afa1..ffcc22a0cc 100644 --- a/tests/assets/reading-list/vue3.html +++ b/tests/assets/reading-list/vue3.html @@ -10,6 +10,7 @@ const app = Vue.createApp({ + `, data() { @@ -74,6 +75,38 @@ app.component('book-list', { `, }); +app.component('color-button', { + props: { + color: String, + enabled: Boolean, + nested: { + index: Number, + value: Number, + }, + }, + template: ` + + `, +}); + +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'); diff --git a/tests/component-parser.spec.ts b/tests/component-parser.spec.ts new file mode 100644 index 0000000000..82b64240f5 --- /dev/null +++ b/tests/component-parser.spec.ts @@ -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 === '') + 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="\\"]'); +}); diff --git a/tests/page/selectors-react.spec.ts b/tests/page/selectors-react.spec.ts index 1a0c26f4ec..268c0662de 100644 --- a/tests/page/selectors-react.spec.ts +++ b/tests/page/selectors-react.spec.ts @@ -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); + }); }); } diff --git a/tests/page/selectors-vue.spec.ts b/tests/page/selectors-vue.spec.ts index 7261134c87..45cc6d6bd6 100644 --- a/tests/page/selectors-vue.spec.ts +++ b/tests/page/selectors-vue.spec.ts @@ -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); + }); }); }