From 48cc41f3e75a4385a3e47e3c955f91e6cff17428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Wed, 9 Feb 2022 16:33:15 -0300 Subject: [PATCH] feat: add key support on react engine (#11970) I've got [this question](https://stackoverflow.com/questions/71050193/react-locator-example/71052432#71052432) on StackOverflow. And although, in that case, the `key` was part of the `props` attributes. That might not always be true. I am bringing this to the tell to see what you think about this. I'm also fixing a typo :) --- docs/src/selectors.md | 1 + .../src/server/injected/componentUtils.ts | 16 ++++++++-------- .../src/server/injected/reactSelectorEngine.ts | 14 +++++++++++++- tests/component-parser.spec.ts | 2 +- tests/page/selectors-react.spec.ts | 1 + 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/src/selectors.md b/docs/src/selectors.md index 0323970387..13b434825f 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -755,6 +755,7 @@ In react selectors, component names are transcribed with **CamelCase**. Selector examples: - match by **component**: `_react=BookItem` +- match by component and **key**: `_react=BookItem[key = '2']` - match by component and **exact property value**, case-sensitive: `_react=BookItem[author = "Steven King"]` - match by property value only, **case-insensitive**: `_react=[author = "steven king" i]` - match by component and **truthy property value**: `_react=MyButton[enabled]` diff --git a/packages/playwright-core/src/server/injected/componentUtils.ts b/packages/playwright-core/src/server/injected/componentUtils.ts index b624d5a1ca..25ad18dc9b 100644 --- a/packages/playwright-core/src/server/injected/componentUtils.ts +++ b/packages/playwright-core/src/server/injected/componentUtils.ts @@ -19,7 +19,7 @@ export type ParsedComponentAttribute = { jsonPath: string[], op: Operator, value: any, - caseSensetive: boolean, + caseSensitive: boolean, }; export type ParsedComponentSelector = { @@ -32,8 +32,8 @@ export function checkComponentAttribute(obj: any, attr: ParsedComponentAttribute 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; + const objValue = typeof obj === 'string' && !attr.caseSensitive ? obj.toUpperCase() : obj; + const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value; if (attr.op === '') return !!objValue; @@ -142,22 +142,22 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto // check property is truthy: [enabled] if (next() === ']') { eat1(); - return { jsonPath, op: '', value: null, caseSensetive: false }; + return { jsonPath, op: '', value: null, caseSensitive: false }; } const operator = readOperator(); let value = undefined; - let caseSensetive = true; + let caseSensitive = true; skipSpaces(); if (next() === `'` || next() === `"`) { value = readQuotedString(next()).slice(1, -1); skipSpaces(); if (next() === 'i' || next() === 'I') { - caseSensetive = false; + caseSensitive = false; eat1(); } else if (next() === 's' || next() === 'S') { - caseSensetive = true; + caseSensitive = true; eat1(); } } else { @@ -181,7 +181,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto 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 }; + return { jsonPath, op: operator, value, caseSensitive }; } const result: ParsedComponentSelector = { diff --git a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts index 31713e58d5..c60c52fe44 100644 --- a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts @@ -19,6 +19,7 @@ import { isInsideScope } from './selectorEvaluator'; import { checkComponentAttribute, parseComponentSelector } from './componentUtils'; type ComponentNode = { + key?: any, name: string, children: ComponentNode[], rootElements: Element[], @@ -26,6 +27,7 @@ type ComponentNode = { }; type ReactVNode = { + key?: any, // React 16+ type: any, child?: ReactVNode, @@ -60,6 +62,10 @@ function getComponentName(reactElement: ReactVNode): string { return ''; } +function getComponentKey(reactElement: ReactVNode): any { + return reactElement.key ?? reactElement._currentElement?.key; +} + function getChildren(reactElement: ReactVNode): ReactVNode[] { // React 16+ // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L192 @@ -104,6 +110,7 @@ function getProps(reactElement: ReactVNode) { function buildComponentsTree(reactElement: ReactVNode): ComponentNode { const treeNode: ComponentNode = { + key: getComponentKey(reactElement), name: getComponentName(reactElement), children: getChildren(reactElement).map(buildComponentsTree), rootElements: [], @@ -165,12 +172,17 @@ export const ReactEngine: SelectorEngine = { const reactRoots = findReactRoots(document); const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot)); const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { + const props = treeNode.props ?? {}; + + if (treeNode.key !== undefined) + props.key = treeNode.key; + if (name && treeNode.name !== name) return false; if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode))) return false; for (const attr of attributes) { - if (!checkComponentAttribute(treeNode.props, attr)) + if (!checkComponentAttribute(props, attr)) return false; } return true; diff --git a/tests/component-parser.spec.ts b/tests/component-parser.spec.ts index 1de3da074d..cbde2fd6a1 100644 --- a/tests/component-parser.spec.ts +++ b/tests/component-parser.spec.ts @@ -23,7 +23,7 @@ const serialize = (parsed: ParsedComponentSelector) => { 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]'); + return '[' + path + ' ' + attr.op + ' ' + JSON.stringify(attr.value) + (attr.caseSensitive ? ']' : ' i]'); }).join(''); }; diff --git a/tests/page/selectors-react.spec.ts b/tests/page/selectors-react.spec.ts index 4267ba7aa2..af1513f2bb 100644 --- a/tests/page/selectors-react.spec.ts +++ b/tests/page/selectors-react.spec.ts @@ -58,6 +58,7 @@ for (const [name, url] of Object.entries(reacts)) { 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=li[key="The Great Gatsby"]`, 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);