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: | ||||
| 
 | ||||
| - 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]` | ||||
|  |  | |||
|  | @ -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 === '<truthy>') | ||||
|     return !!objValue; | ||||
|  | @ -142,22 +142,22 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto | |||
|     // check property is truthy: [enabled]
 | ||||
|     if (next() === ']') { | ||||
|       eat1(); | ||||
|       return { jsonPath, op: '<truthy>', value: null, caseSensetive: false }; | ||||
|       return { jsonPath, op: '<truthy>', 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 = { | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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 === '<truthy>') | ||||
|       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(''); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue