Skip to content

feat(browser): Generate getByRole selectors#877

Merged
allansson merged 10 commits intomainfrom
feat/get-by-role-selector
Nov 6, 2025
Merged

feat(browser): Generate getByRole selectors#877
allansson merged 10 commits intomainfrom
feat/get-by-role-selector

Conversation

@allansson
Copy link
Collaborator

@allansson allansson commented Nov 3, 2025

Description

This PR adds support for recording ARIA-information and using it to emit getByRole selectors.

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:

  1. getByRole
  2. getByTestId
  3. locator

I already have a plan to improve on this: #876

How to Test

  1. Start a recording
  2. Navigate a page
  3. Stop the recording
  4. Generate a script
  5. Run the script

Whenever possible, the script should generate getByRole selectors and execute (reasonably) well.

Checklist

  • I have performed a self-review of my code.
  • I have added tests for my changes.
  • I have commented on my code, particularly in hard-to-understand areas.

Related PR(s)/Issue(s)

Closes #833
Fixes #873

Copy link
Collaborator Author

@allansson allansson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review


export function getAriaDetails(element: Element): AriaDetails {
const roles = [...getElementRoles(element)]
.filter((r) => !ABSTRACT_ROLES.has(r.role))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check for uniqueness of the test id, fixing #873

)
})

it('should emit a getByTestId locator', async ({ expect }) => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't have a test for getByTestId so I added it.

)
})

it('should emit a css locator', async ({ expect }) => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also didn't have it for locator, so added that too.

Comment on lines +54 to +58
return (
getRoleSelector(selector) ??
getTestIdSelector(selector) ??
getCssSelector(selector)
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selects the first non-null selector in order of execution. getCssSelector is guaranteed to always be a selector.

return <TestTubeDiagonalIcon />

case 'role':
return <SpeechIcon />
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestions for a better icon are welcome.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a different icon for each role?

Image

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 />
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed this to BrowserEventTarget to avoid conflicts with EventTarget already available in browsers.

@allansson allansson marked this pull request as ready for review November 3, 2025 16:56
@allansson allansson requested a review from a team as a code owner November 3, 2025 16:56
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/dom for 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.

Comment on lines +42 to +44
return new ExpressionBuilder(page)
.member('getByRole')
.call([role, fromObjectLiteral({ name, exact: true })])
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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 })])

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +48
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
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.

return Array.from(element.labels)
.map((label) => label.textContent)
.filter((label) => label !== null)
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.filter((label) => label !== null)
.filter((label): label is string => label !== null)

Copilot uses AI. Check for mistakes.
@allansson allansson changed the title feat: Generate getByRole selectors feat(browser): Generate getByRole selectors Nov 3, 2025
@allansson allansson requested review from e-fisher and removed request for cristianoventura November 5, 2025 11:31
Copy link
Collaborator

@e-fisher e-fisher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Image

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 />
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a different icon for each role?

Image

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 />
}

@allansson
Copy link
Collaborator Author

Perhaps out of scope of this PR, but should we show role selector in assertion popover as well?

@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.

@allansson allansson requested a review from e-fisher November 6, 2025 11:11
Copy link
Collaborator

@e-fisher e-fisher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 💯

@allansson allansson merged commit 9404fda into main Nov 6, 2025
7 checks passed
@allansson allansson deleted the feat/get-by-role-selector branch November 6, 2025 12:56
This was referenced Nov 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Strict mode violation when recording element with data-testid Add support for emitting getByRole locators

2 participants