feat: checkOption finds checkables by ARIA role - #5704
Merged
Conversation
`checkOption('Accept terms')` failed on every headless component library.
Radix renders a checkbox as `<button role="checkbox" aria-checked>`, Base UI
as `<span role="checkbox">`; neither is an `<input>`, and all three strategies
in `Locator.checkable` hard-code `.//input[@type='checkbox' or @type='radio']`.
Playwright's `findCheckable` now runs a `getByRole('checkbox'|'radio'|'switch',
{ name })` pass — exact across the three roles first, then substring — mirroring
what `findClickable` already does for `button`/`link`. Native inputs expose
those roles too, so the pass is a superset of the XPath strategies it precedes.
`seeCheckboxIsChecked` / `dontSeeCheckboxIsChecked` route through the same
lookup and are fixed by it.
WebDriver and Puppeteer already had an ARIA fallback but ran it *after* the
label XPath, which is too late. Base UI renders a hidden mirror `<input>`, moves
the author's id onto it and points `<label for>` at it, so the label XPath
succeeds and resolves a 1x1 `aria-hidden` input at x:-1,y:-1 — the click is then
intercepted or reported outside the viewport. `aria-hidden` keeps that input out
of the accessibility tree, so the ARIA lookup lands on the visible control
instead. Both fallbacks now run before the label XPath.
Adds Radix and Base UI fixtures under /form/checkable and a shared spec block
asserting `aria-checked` flips on the visible element. Radix checkables stay
skipped on WebDriver: webdriverio's `aria/` selector resolves `<label for>` to
input/textarea only, and a Radix `<button role=checkbox>` has no accessible name
of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
Moving the accessible-name lookup above the label XPath fixed Base UI but widened the net: `aria/…` and `::-p-aria(…)` match on name alone, so a heading sharing a checkbox label's text now won on document order where `byText` previously reached the input. Measured on the new collision fixture, both returned `[H2, INPUT#terms-box]`. Puppeteer loops the three checkable roles as `::-p-aria([name][role])`, following the buildRoleSelector convention already in the file. `::-p-aria` matches names exactly and case-sensitively, so one pass per role is enough; a name that cannot be parsed falls through to the XPath as before. WebDriver has no attribute filter on `aria/`, so its results are post-filtered to `input[type=checkbox|radio]` and `[role=checkbox|radio|switch]` in a single `browser.execute` round trip regardless of match count, using the `execute(fn, ...elements)` form already used in the file. Both now resolve `[INPUT#terms-box]`, matching Playwright's role-scoped pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
DavertMik
added a commit
that referenced
this pull request
Sep 9, 2026
#5708) Locator.checkable already knows how to find a control by its label. It just hard-coded the tag. Headless component libraries express the same semantics with a role, so generalise the tag test to a tag-or-role test and skip aria-hidden elements. self::input[@type='checkbox' or @type='radio'] or @ROLE='checkbox' or @ROLE='radio' or @ROLE='switch' This replaces the three per-helper implementations added in #5704 — a getByRole loop in Playwright, a role-scoped ::-p-aria loop in Puppeteer, and a hoisted aria/ lookup plus keepCheckable filter in WebDriver — with one XPath predicate that all three helpers inherit, since they all call Locator.checkable.byText. Puppeteer's and WebDriver's ARIA fallbacks return to their original position after the XPath. An XPath predicate is role-scoped by construction, so there is no precision trade-off to manage: a heading sharing the label text cannot match, and the per-helper filtering that guarded against it is no longer needed. The aria-hidden guard is what skips the hidden mirror input that libraries render and point <label for> at, so the visible control is resolved instead. Behaviour is unchanged for native controls: on existing fixtures the generated XPath returns an identical node set. Verified against real radix-ui@1.6.7 and @base-ui/react@1.8.0 components: checkOption and seeCheckboxIsChecked work for Checkbox, Switch, Radio Group and Checkbox Group on both libraries, standalone and inside a form. Playwright 53 passing, Puppeteer 49 passing, WebDriver 41 passing (3 pending), unit 815 + 81 locator, lint clean. Net -25 lines of library code. Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I.checkOption('Accept terms')fails on every headless component library. Radix renders a checkbox as<button role="checkbox" aria-checked>, Base UI as<span role="checkbox">— neither is an<input>, and all three strategies inLocator.checkablehard-code.//input[@type='checkbox' or @type='radio']. The user gets "Checkbox or radio … was not found by text|CSS|XPath".This is a lookup gap, not an action gap:
checkOption({ role: 'checkbox', name: 'Accept terms' })already works today, because a strict role locator goes throughhandleRoleLocatorand Playwright'scheck()/isChecked()fully supportrole=checkbox|radio|switchwitharia-checked. Only the fuzzy-string path was missing.All three helpers now look up checkables through the accessibility tree before the label XPath, and all three restrict that lookup to the checkable roles.
Playwright — add the ARIA pass
findCheckablenow triesgetByRole('checkbox'|'radio'|'switch', { name })before the XPath strategies, mirroring whatfindClickablealready does forbutton/link. Native<input type=checkbox|radio>expose those roles too, so the pass is a superset of the strategies it precedes.Exact across all three roles first, then substring across all three. Playwright's
nameoption defaults to case-insensitive substring, and exact-first keeps the precision of the XPath path being replaced. Two passes rather than exact-then-substring per role, so acheckboxsubstring hit can't beat aradioexact hit.seeCheckboxIsChecked/dontSeeCheckboxIsCheckedroute through the same lookup (proceedIsChecked→findCheckable) and are fixed by it. (On WebDriver they go throughfindFieldsinstead, so this PR does not change them there.)WebDriver / Puppeteer — the ordering is the point
Both helpers already had an ARIA fallback, but ran it after the label XPath. That is too late.
Base UI always renders a hidden mirror
<input>, moves the author'sidonto it, and points<label for>at it, giving the visible control a generated id plusaria-labelledby:So the label XPath succeeds — and resolves the mirror, which measures 1×1 px at
x:-1, y:-1. Measured on this branch with the ARIA lookup disabled, Playwright fails as:and WebDriver, with its
aria/lookup moved back after the XPath:Because
aria-hidden="true"removes that input from the accessibility tree, an ARIA lookup lands on the visible control instead. Hence the ARIA strategy must run before the label XPath, not as a fallback after it — in all three helpers.Keeping the reorder from costing precision
Hoisting an accessible-name lookup above the XPath widens the net, because
aria/…and::-p-aria(…)match on name alone and are not role-scoped. On a page where a heading shares the checkbox label's text, the non-control wins on document order wherebyTextpreviously reached the input. Measured on the newcollisionfixture:So both hoisted lookups are role-restricted as well as reordered:
lib/helper/Puppeteer.js— loops the three checkable roles as::-p-aria([name="…"][role="…"]), following thebuildRoleSelectorconvention already in the file.::-p-arianame matching is exact and case-sensitive (::-p-aria(Accept)matches nothing on a control named "Accept terms"), so there is no exact/substring distinction to make — one pass per role is the whole story. Falls through to the XPath on any parse failure, so a name containing a quote behaves exactly as it did before.lib/helper/WebDriver.js— webdriverio'saria/takes no attribute filter, so the returned elements are post-filtered toinput[type=checkbox|radio]or[role=checkbox|radio|switch]. This is onebrowser.executeround trip regardless of match count (measured 6–11 ms), not one per element, using theexecute(fn, ...elements)form already used elsewhere in the file.After the restriction both return
[ INPUT#terms-box ], matching Playwright's role-scoped behaviour. The reorder now costs no precision on any helper.Tests
Fixtures render the real components via importmap + esm.sh, following the convention from #5701:
test/data/app/view/form/checkable/radix.php—radix-ui@1.6.7test/data/app/view/form/checkable/baseui.php—@base-ui/react@1.8.0test/data/app/view/form/checkable/collision.php— plain HTML: an<h2>sharing the checkbox label's accessible nameEach component page has a Checkbox, a Switch and a Radio Group with visible labels; the Base UI page reproduces the
<label for>→ hidden mirror input wiring, which is the case that proves the ordering. Each control carries a.ctl-*class so assertions can target the visible element without depending on library-generated ids.test/helper/webapi.jsgains a#checkOption - ARIA rolesblock (shared across all three helpers, no parallel spec) that asserts observable state —aria-checkedflipping on the visible element — forcheckOption,uncheckOption,seeCheckboxIsCheckedanddontSeeCheckboxIsChecked, plus two regression cases: a plain<input type=checkbox>+<label>, and the collision fixture (asserted through a CSS locator so the assertion path cannot be fooled by the lookup under test).Two existing tests in
aria selectors without role locatorswere guarded to WebDriver only because Playwright could not reach a<span role=checkbox aria-label=…>; the guards are removed and they now pass on all three helpers.Known limitation, kept as a
this.skip()with the reason in the test: Radix checkables are unreachable by label on WebDriver regardless of ordering. webdriverio'saria/selector resolves<label for>toinput/textareaonly, and Radix's<button role=checkbox>has noaria-label, noaria-labelledbyand no text of its own — so neither the ARIA union norLocator.checkablecan find it. Fixing that needs a new WebDriver strategy, which is more than a reorder.Verification
Each change was checked by reverting it and watching the matching test fail:
element click interceptedon the mirror input<h2>is resolved and clickedexpected checkable field "#terms-box" to be checkedLocal suites (run serially against a private fixture server and
selenium/standalone-chrome:4.27): Playwright, Puppeteer and WebDriver helper suites green apart from environment-dependent cases (network-traffic tests needing livecodecept.io, cookie tests, one puppeteer sandbox launch) — all of which pass in CI.npm run test:unit770 passing,npm run lintclean.No JSDoc on a public action changed, so no
npm run defregeneration.Out of scope
aria-pressedtoggles androle=menuitemcheckbox(Playwright'scheck()refuses both),Locator.field,dragSlider/moveCursorTo,proceedSelect— separate follow-ups.🤖 Generated with Claude Code
https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi