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 :)
This commit is contained in:
		
							parent
							
								
									439c8e9c40
								
							
						
					
					
						commit
						48cc41f3e7
					
				|  | @ -755,6 +755,7 @@ In react selectors, component names are transcribed with **CamelCase**. | ||||||
| Selector examples: | Selector examples: | ||||||
| 
 | 
 | ||||||
| - match by **component**: `_react=BookItem` | - 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 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 property value only, **case-insensitive**: `_react=[author = "steven king" i]` | ||||||
| - match by component and **truthy property value**: `_react=MyButton[enabled]` | - match by component and **truthy property value**: `_react=MyButton[enabled]` | ||||||
|  |  | ||||||
|  | @ -19,7 +19,7 @@ export type ParsedComponentAttribute = { | ||||||
|   jsonPath: string[], |   jsonPath: string[], | ||||||
|   op: Operator, |   op: Operator, | ||||||
|   value: any, |   value: any, | ||||||
|   caseSensetive: boolean, |   caseSensitive: boolean, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export type ParsedComponentSelector = { | export type ParsedComponentSelector = { | ||||||
|  | @ -32,8 +32,8 @@ export function checkComponentAttribute(obj: any, attr: ParsedComponentAttribute | ||||||
|     if (obj !== undefined && obj !== null) |     if (obj !== undefined && obj !== null) | ||||||
|       obj = obj[token]; |       obj = obj[token]; | ||||||
|   } |   } | ||||||
|   const objValue = typeof obj === 'string' && !attr.caseSensetive ? obj.toUpperCase() : obj; |   const objValue = typeof obj === 'string' && !attr.caseSensitive ? obj.toUpperCase() : obj; | ||||||
|   const attrValue = typeof attr.value === 'string' && !attr.caseSensetive ? attr.value.toUpperCase() : attr.value; |   const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value; | ||||||
| 
 | 
 | ||||||
|   if (attr.op === '<truthy>') |   if (attr.op === '<truthy>') | ||||||
|     return !!objValue; |     return !!objValue; | ||||||
|  | @ -142,22 +142,22 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto | ||||||
|     // check property is truthy: [enabled]
 |     // check property is truthy: [enabled]
 | ||||||
|     if (next() === ']') { |     if (next() === ']') { | ||||||
|       eat1(); |       eat1(); | ||||||
|       return { jsonPath, op: '<truthy>', value: null, caseSensetive: false }; |       return { jsonPath, op: '<truthy>', value: null, caseSensitive: false }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const operator = readOperator(); |     const operator = readOperator(); | ||||||
| 
 | 
 | ||||||
|     let value = undefined; |     let value = undefined; | ||||||
|     let caseSensetive = true; |     let caseSensitive = true; | ||||||
|     skipSpaces(); |     skipSpaces(); | ||||||
|     if (next() === `'` || next() === `"`) { |     if (next() === `'` || next() === `"`) { | ||||||
|       value = readQuotedString(next()).slice(1, -1); |       value = readQuotedString(next()).slice(1, -1); | ||||||
|       skipSpaces(); |       skipSpaces(); | ||||||
|       if (next() === 'i' || next() === 'I') { |       if (next() === 'i' || next() === 'I') { | ||||||
|         caseSensetive = false; |         caseSensitive = false; | ||||||
|         eat1(); |         eat1(); | ||||||
|       } else if (next() === 's' || next() === 'S') { |       } else if (next() === 's' || next() === 'S') { | ||||||
|         caseSensetive = true; |         caseSensitive = true; | ||||||
|         eat1(); |         eat1(); | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
|  | @ -181,7 +181,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto | ||||||
|     eat1(); |     eat1(); | ||||||
|     if (operator !== '=' && typeof value !== 'string') |     if (operator !== '=' && typeof value !== 'string') | ||||||
|       throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`); |       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 = { |   const result: ParsedComponentSelector = { | ||||||
|  |  | ||||||
|  | @ -19,6 +19,7 @@ import { isInsideScope } from './selectorEvaluator'; | ||||||
| import { checkComponentAttribute, parseComponentSelector } from './componentUtils'; | import { checkComponentAttribute, parseComponentSelector } from './componentUtils'; | ||||||
| 
 | 
 | ||||||
| type ComponentNode = { | type ComponentNode = { | ||||||
|  |   key?: any, | ||||||
|   name: string, |   name: string, | ||||||
|   children: ComponentNode[], |   children: ComponentNode[], | ||||||
|   rootElements: Element[], |   rootElements: Element[], | ||||||
|  | @ -26,6 +27,7 @@ type ComponentNode = { | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type ReactVNode = { | type ReactVNode = { | ||||||
|  |   key?: any, | ||||||
|   // React 16+
 |   // React 16+
 | ||||||
|   type: any, |   type: any, | ||||||
|   child?: ReactVNode, |   child?: ReactVNode, | ||||||
|  | @ -60,6 +62,10 @@ function getComponentName(reactElement: ReactVNode): string { | ||||||
|   return ''; |   return ''; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function getComponentKey(reactElement: ReactVNode): any { | ||||||
|  |   return reactElement.key ?? reactElement._currentElement?.key; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| function getChildren(reactElement: ReactVNode): ReactVNode[] { | function getChildren(reactElement: ReactVNode): ReactVNode[] { | ||||||
|   // React 16+
 |   // React 16+
 | ||||||
|   // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L192
 |   // @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 { | function buildComponentsTree(reactElement: ReactVNode): ComponentNode { | ||||||
|   const treeNode: ComponentNode = { |   const treeNode: ComponentNode = { | ||||||
|  |     key: getComponentKey(reactElement), | ||||||
|     name: getComponentName(reactElement), |     name: getComponentName(reactElement), | ||||||
|     children: getChildren(reactElement).map(buildComponentsTree), |     children: getChildren(reactElement).map(buildComponentsTree), | ||||||
|     rootElements: [], |     rootElements: [], | ||||||
|  | @ -165,12 +172,17 @@ export const ReactEngine: SelectorEngine = { | ||||||
|     const reactRoots = findReactRoots(document); |     const reactRoots = findReactRoots(document); | ||||||
|     const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot)); |     const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot)); | ||||||
|     const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { |     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) |       if (name && treeNode.name !== name) | ||||||
|         return false; |         return false; | ||||||
|       if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode))) |       if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode))) | ||||||
|         return false; |         return false; | ||||||
|       for (const attr of attributes) { |       for (const attr of attributes) { | ||||||
|         if (!checkComponentAttribute(treeNode.props, attr)) |         if (!checkComponentAttribute(props, attr)) | ||||||
|           return false; |           return false; | ||||||
|       } |       } | ||||||
|       return true; |       return true; | ||||||
|  |  | ||||||
|  | @ -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('.'); |     const path = attr.jsonPath.map(token => /^[a-zA-Z0-9]+$/i.test(token) ? token : JSON.stringify(token)).join('.'); | ||||||
|     if (attr.op === '<truthy>') |     if (attr.op === '<truthy>') | ||||||
|       return '[' + path + ']'; |       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(''); |   }).join(''); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -58,6 +58,7 @@ for (const [name, url] of Object.entries(reacts)) { | ||||||
|     it('should query by props combinations', async ({ page }) => { |     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"]`, 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" 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.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.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 = 0]`, els => els.length)).toBe(0); | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue