From 6b55a31c755b58fe46754ca364bc2d93d9738250 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 8 Sep 2026 20:46:22 +0300 Subject: [PATCH 1/4] fix(locator): restrict loose field branches to field-like elements `Locator.field.labelContains` ended with three branches matching `.//*` with no tag restriction, so any container carrying `@aria-label`, `@title` or `@aria-labelledby` was returned by `findFields`. `xpathLocator.combine` joins branches with `|`, and a union is evaluated in document order, so a labelled wrapper always preceded the control it wraps. A Base UI slider (`div[role=group][aria-labelledby]` around `input[type=range]` with the same `aria-labelledby`) resolved to the `div`, and `selectOption` on a `ul[role=tablist][aria-label]` reported "Element is not a + + + +
Bob
+ + + + + + + diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 40b51c3c3..497d511ac 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -609,6 +609,23 @@ export function tests() { await I.see('tags: review,later', '#result') }) }) + + it('should not resolve a labelled tablist container as a field', async function () { + if (isHelper('Puppeteer')) this.skip() + + await I.amOnPage('/form/field_containers') + + let err + try { + await I.selectOption('Settings tabs', 'Password') + } catch (e) { + err = e + } + + if (!err) assert.fail('selected an option on a tablist') + assert.include(err.message, 'was not found') + assert.notInclude(err.message, ' + + `) + const root = xpath.select1('//root', sliderDoc) + const xp = Locator.field.labelContains("'Volume'") + const nodes = xpath.select(xp, root) + + expect(nodes).to.have.length(1, xp) + expect(nodes[0].tagName).to.eql('input') + expect(nodes[0].getAttribute('name')).to.eql('vol') + }) + + it('does not match a tablist container labelled by aria-label', () => { + const tabsDoc = parse(` + + `) + const root = xpath.select1('//root', tabsDoc) + const xp = Locator.field.labelContains("'Settings tabs'") + + expect(xpath.select(xp, root)).to.have.length(0, xp) + }) + + it('does not match a group container labelled by title', () => { + const groupDoc = parse('
0
') + const root = xpath.select1('//root', groupDoc) + const xp = Locator.field.labelContains("'Volume'") + + expect(xpath.select(xp, root)).to.have.length(0, xp) + }) + + it('still matches a custom widget with an editable role', () => { + const widgetDoc = parse('
') + const root = xpath.select1('//root', widgetDoc) + const xp = Locator.field.labelContains("'Nickname'") + const nodes = xpath.select(xp, root) + + expect(nodes).to.have.length(1, xp) + expect(nodes[0].getAttribute('id')).to.eql('nick') + }) + + it('still matches a native input by aria-label', () => { + const inputDoc = parse('') + const root = xpath.select1('//root', inputDoc) + const xp = Locator.field.labelContains("'My Address'") + const nodes = xpath.select(xp, root) + + expect(nodes).to.have.length(1, xp) + expect(nodes[0].getAttribute('name')).to.eql('my-form-address') + }) + + it('keeps both a combobox and a listbox sharing one aria-labelledby', () => { + const selectDoc = parse(` + +
+
+ `) + const root = xpath.select1('//root', selectDoc) + const xp = Locator.field.labelContains("'Favorite Color'") + const nodes = xpath.select(xp, root) + + expect(nodes).to.have.length(2, xp) + expect(nodes.map(n => n.getAttribute('id'))).to.eql(['color-trigger', 'color-listbox']) + }) + }) }) From 75f047b3932041e269cb4c235c538601c6f6697e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 8 Sep 2026 21:04:41 +0300 Subject: [PATCH 2/4] fix(locator): keep checkable roles in the field allowlist `span[role=checkbox][aria-label]` widgets are resolved through `findFields` by the WebDriver helper, so the allowlist must cover the checkable ARIA form controls too. Also drop `grabValueFrom` from the new wrapper test: the WebDriver helper resolves that action through `_locate`, not `findFields`, so it never accepted a fuzzy field name. `seeInField` / `dontSeeInField` assert the same thing across all three helpers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi --- lib/locator.js | 3 +++ test/helper/webapi.js | 5 +++-- test/unit/locator_test.js | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/locator.js b/lib/locator.js index 8572fd9a7..3fec5d77f 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -618,6 +618,9 @@ const fieldLike = [ "@role = 'spinbutton'", "@role = 'slider'", "@role = 'listbox'", + "@role = 'checkbox'", + "@role = 'radio'", + "@role = 'switch'", "@contenteditable = 'true'", ].join(' or ') diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 497d511ac..1d6185c88 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -611,6 +611,8 @@ export function tests() { }) it('should not resolve a labelled tablist container as a field', async function () { + // Puppeteer's findFields falls back to `::-p-aria()`, which resolves the tablist + // by accessible name regardless of the XPath strategies asserted here. if (isHelper('Puppeteer')) this.skip() await I.amOnPage('/form/field_containers') @@ -1060,8 +1062,7 @@ export function tests() { it('should skip a labelled wrapper and read the field it wraps', async () => { await I.amOnPage('/form/field_containers') await I.seeInField('Volume', '30') - const value = await I.grabValueFrom('Volume') - assert.equal(value, '30') + await I.dontSeeInField('Volume', '70') }) it('should still reach a custom widget labelled by aria-labelledby', async () => { diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index e43c4ab04..9d2cb4a63 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -859,6 +859,16 @@ describe('Locator', () => { expect(nodes[0].getAttribute('id')).to.eql('nick') }) + it('still matches a custom checkbox widget by aria-label', () => { + const boxDoc = parse('') + const root = xpath.select1('//root', boxDoc) + const xp = Locator.field.labelContains("'I agree'") + const nodes = xpath.select(xp, root) + + expect(nodes).to.have.length(1, xp) + expect(nodes[0].getAttribute('id')).to.eql('agree') + }) + it('still matches a native input by aria-label', () => { const inputDoc = parse('') const root = xpath.select1('//root', inputDoc) From 50df7b86820e203b4016add6d65f4b55cb52382c Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 10 Sep 2026 03:06:22 +0300 Subject: [PATCH 3/4] refactor(locator): compact field-like guard in labelContains --- lib/locator.js | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/lib/locator.js b/lib/locator.js index 3fec5d77f..f8908fdc1 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -608,21 +608,8 @@ Locator.clickable = { }, } -const fieldLike = [ - 'self::input', - 'self::textarea', - 'self::select', - "@role = 'textbox'", - "@role = 'searchbox'", - "@role = 'combobox'", - "@role = 'spinbutton'", - "@role = 'slider'", - "@role = 'listbox'", - "@role = 'checkbox'", - "@role = 'radio'", - "@role = 'switch'", - "@contenteditable = 'true'", -].join(' or ') +const fieldRoles = '|textbox|searchbox|combobox|spinbutton|slider|listbox|checkbox|radio|switch|' +const fieldLike = `(self::input | self::textarea | self::select) or @contenteditable = 'true' or contains('${fieldRoles}', concat('|', normalize-space(@role), '|'))` Locator.field = { /** @@ -643,9 +630,7 @@ Locator.field = { xpathLocator.combine([ `.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`, `.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`, - `.//*[${fieldLike}][@aria-label = ${literal}]`, - `.//*[${fieldLike}][@title = ${literal}]`, - `.//*[${fieldLike}][@aria-labelledby][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`, + `.//*[${fieldLike}][@aria-label = ${literal} or @title = ${literal} or @aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`, ]), /** From 04ff7b1c4616700146c11c6705b161918740dbb5 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 10 Sep 2026 03:48:10 +0300 Subject: [PATCH 4/4] fix(locator): keep radiogroup in field-like allowlist for selectOption --- lib/locator.js | 2 +- test/unit/locator_test.js | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/locator.js b/lib/locator.js index 479ebc8e3..6a054d4fb 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -608,7 +608,7 @@ Locator.clickable = { }, } -const fieldRoles = '|textbox|searchbox|combobox|spinbutton|slider|listbox|checkbox|radio|switch|' +const fieldRoles = '|textbox|searchbox|combobox|spinbutton|slider|listbox|checkbox|radio|switch|radiogroup|' const fieldLike = `(self::input | self::textarea | self::select) or @contenteditable = 'true' or contains('${fieldRoles}', concat('|', normalize-space(@role), '|'))` Locator.field = { diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index d386a6403..f05c6f0b3 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -892,6 +892,27 @@ describe('Locator', () => { expect(nodes).to.have.length(2, xp) expect(nodes.map(n => n.getAttribute('id'))).to.eql(['color-trigger', 'color-listbox']) }) + + it('still matches a radiogroup labelled by aria-labelledby without its heading', () => { + const groupDoc = parse(` +

Theme

+
+ `) + const root = xpath.select1('//root', groupDoc) + const xp = Locator.field.labelContains("'Theme'") + const nodes = xpath.select(xp, root) + + expect(nodes).to.have.length(1, xp) + expect(nodes[0].getAttribute('id')).to.eql('theme') + }) + + it('does not match a role=group container through the radiogroup role', () => { + const wrapperDoc = parse('

Theme

') + const root = xpath.select1('//root', wrapperDoc) + const xp = Locator.field.labelContains("'Theme'") + + expect(xpath.select(xp, root)).to.have.length(0, xp) + }) }) describe('Locator.checkable.byText', () => {