feat(browser): Generate getByRole selectors#877
Conversation
|
|
||
| export function getAriaDetails(element: Element): AriaDetails { | ||
| const roles = [...getElementRoles(element)] | ||
| .filter((r) => !ABSTRACT_ROLES.has(r.role)) |
There was a problem hiding this comment.
Some roles in ARIA are abstract, e.g. input which is implemented by checkbox, radio and textbox, which means that they aren't specific enough to be used for querying the DOM.
| return undefined | ||
| } | ||
|
|
||
| const matches = queryAllByTestId(document.body, testId) |
There was a problem hiding this comment.
Check for uniqueness of the test id, fixing #873
| ) | ||
| }) | ||
|
|
||
| it('should emit a getByTestId locator', async ({ expect }) => { |
There was a problem hiding this comment.
We didn't have a test for getByTestId so I added it.
| ) | ||
| }) | ||
|
|
||
| it('should emit a css locator', async ({ expect }) => { |
There was a problem hiding this comment.
Also didn't have it for locator, so added that too.
| return ( | ||
| getRoleSelector(selector) ?? | ||
| getTestIdSelector(selector) ?? | ||
| getCssSelector(selector) | ||
| ) |
There was a problem hiding this comment.
Selects the first non-null selector in order of execution. getCssSelector is guaranteed to always be a selector.
| return <TestTubeDiagonalIcon /> | ||
|
|
||
| case 'role': | ||
| return <SpeechIcon /> |
There was a problem hiding this comment.
Suggestions for a better icon are welcome.
There was a problem hiding this comment.
Perhaps a different icon for each role?
Here are the icons I've used in the screenshot, role is typed as string so not sure if this covers all the cases
switch (selector.role) {
case 'button':
return <SquareMousePointer />
case 'textbox':
return <TextCursorInput />
case 'link':
return <Link2Icon />
default:
return <SpeechIcon />
}There was a problem hiding this comment.
Wow, that looks awesome! 😍 There are tons of roles, but I think we can cover the most common ones and then fallback to a generic for all the other ones.
| }) | ||
|
|
||
| const EventTargetSchema = z.object({ | ||
| const BrowserEventTargetSchema = z.object({ |
There was a problem hiding this comment.
Renamed this to BrowserEventTarget to avoid conflicts with EventTarget already available in browsers.
There was a problem hiding this comment.
Pull Request Overview
This PR adds support for role-based selectors in browser event recording and code generation, enabling the system to generate more semantic and accessible element selectors using ARIA roles. The implementation prioritizes role-based selectors over test IDs and CSS selectors when applicable.
- Added ARIA role selector support alongside existing CSS and test-id selectors
- Integrated
@testing-library/domfor role-based element queries - Updated code generation to emit
getByRole()locator calls - Enhanced UI to display role-based selectors with appropriate iconography
Reviewed Changes
Copilot reviewed 31 out of 33 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/aria-query.d.ts | Added export for roles map from aria-query module |
| src/schemas/recording/browser/v2/index.ts | Extended schema with ARIA details and role selector types |
| src/codegen/browser/selectors.ts | New file defining selector priority logic (role > test-id > css) |
| src/codegen/browser/types.ts | Updated LocatorNode to use new NodeSelector type |
| src/codegen/browser/test.ts | Updated locator generation to use role selectors and equality checking |
| src/codegen/browser/intermediate/index.ts | Added role locator expression handling with quote escaping |
| src/codegen/browser/intermediate/ast.ts | Added NewRoleLocatorExpression type |
| src/codegen/browser/intermediate/variables.ts | Added substitution handling for role expressions |
| src/codegen/browser/code/scenario.ts | Implemented getByRole code generation with exact matching |
| src/codegen/browser/code/options.ts | Registered role expression in browser scenario detection |
| src/codegen/browser/codegen.test.ts | Added comprehensive test coverage for all selector types |
| src/components/BrowserEventList/EventDescription/Selector.tsx | Refactored UI to display role selectors with SpeechIcon |
| extension/src/utils/aria.ts | Added ARIA details extraction with abstract role filtering |
| extension/src/target.ts | New file implementing role selector generation with uniqueness validation |
| extension/src/selectors.ts | Removed - functionality moved to target.ts |
| package.json | Updated @types/k6 to v1.3.1 and added accessibility libraries |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return new ExpressionBuilder(page) | ||
| .member('getByRole') | ||
| .call([role, fromObjectLiteral({ name, exact: true })]) |
There was a problem hiding this comment.
[nitpick] The exact: true option is hardcoded here. Consider whether this should be configurable, as there may be cases where fuzzy matching is desired for role-based selectors, or add a comment explaining why exact matching is always preferred.
| return new ExpressionBuilder(page) | |
| .member('getByRole') | |
| .call([role, fromObjectLiteral({ name, exact: true })]) | |
| // The 'exact' option is configurable via 'expression.exact'. Default to true if not specified. | |
| return new ExpressionBuilder(page) | |
| .member('getByRole') | |
| .call([role, fromObjectLiteral({ name, exact: expression.exact ?? true })]) |
| const [selector] = applicableRoles.flatMap((role) => { | ||
| const matches = queryAllByRole(document.body, role, { name }) | ||
|
|
||
| if (!matches.includes(element)) { | ||
| return [] | ||
| } | ||
|
|
||
| if (matches.length > 1) { | ||
| return [] | ||
| } | ||
|
|
||
| return { | ||
| role, | ||
| name, | ||
| } | ||
| }) | ||
|
|
||
| return selector |
There was a problem hiding this comment.
This implementation performs a query for each applicable role even after finding a valid selector. Consider returning early once a valid unique selector is found to avoid unnecessary DOM queries.
| const [selector] = applicableRoles.flatMap((role) => { | |
| const matches = queryAllByRole(document.body, role, { name }) | |
| if (!matches.includes(element)) { | |
| return [] | |
| } | |
| if (matches.length > 1) { | |
| return [] | |
| } | |
| return { | |
| role, | |
| name, | |
| } | |
| }) | |
| return selector | |
| for (const role of applicableRoles) { | |
| const matches = queryAllByRole(document.body, role, { name }); | |
| if (!matches.includes(element)) { | |
| continue; | |
| } | |
| if (matches.length > 1) { | |
| continue; | |
| } | |
| return { | |
| role, | |
| name, | |
| }; | |
| } | |
| return undefined; |
|
|
||
| return Array.from(element.labels) | ||
| .map((label) => label.textContent) | ||
| .filter((label) => label !== null) |
There was a problem hiding this comment.
The filter removes null values but TypeScript still infers the return type as (string | null)[]. Consider using a type guard or type assertion to ensure the return type is string[], or handle the null case more explicitly.
| .filter((label) => label !== null) | |
| .filter((label): label is string => label !== null) |
e-fisher
left a comment
There was a problem hiding this comment.
This looks great, makes tests way easier to read! 🙌
Left a suggestions on role icons.
Perhaps out of scope of this PR, but should we show role selector in assertion popover as well?
This become role[button] event once recording is finished.
You have some conflicts in the PR, once resolved I'll test it with CDP
| return <TestTubeDiagonalIcon /> | ||
|
|
||
| case 'role': | ||
| return <SpeechIcon /> |
There was a problem hiding this comment.
Perhaps a different icon for each role?
Here are the icons I've used in the screenshot, role is typed as string so not sure if this covers all the cases
switch (selector.role) {
case 'button':
return <SquareMousePointer />
case 'textbox':
return <TextCursorInput />
case 'link':
return <Link2Icon />
default:
return <SpeechIcon />
}
@e-fisher I've created an issue to handle both this and the fact that hovering a selector in the event list always uses the CSS selector and not the selector we render: #880 Fixed the merge conflicts and added as many per-role icons as I could think of. |
Description
This PR adds support for recording ARIA-information and using it to emit
getByRoleselectors.The recorder will take the role and accessible name of the target element and query the DOM to see if they are unique. If so, a role selector will be recorded.
And since I added a uniqueness check for getByRole I took the opportunity to do the same for
getByTestId, fixing #873 in the process.Caveat
In some cases a unique selector can't be found, e.g. because the page has multiple buttons with the same text ('Get started', "Create account'). In this case no role selector will be recorded and the app will fallback to one of the other selectors.
The precedence of selectors is currently:
getByRolegetByTestIdlocatorI already have a plan to improve on this: #876
How to Test
Whenever possible, the script should generate
getByRoleselectors and execute (reasonably) well.Checklist
Related PR(s)/Issue(s)
Closes #833
Fixes #873