Skip to content

Commit 6b55a31

Browse files
DavertMikclaude
andcommitted
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 <select> element" instead of a clean not-found. Scope the three branches to a field tag or an editable ARIA role, which keeps every custom `role=combobox` / `role=textbox` / `role=listbox` widget reachable by accessible name while dropping plain containers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
1 parent c780f9a commit 6b55a31

4 files changed

Lines changed: 146 additions & 3 deletions

File tree

lib/locator.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,19 @@ Locator.clickable = {
608608
},
609609
}
610610

611+
const fieldLike = [
612+
'self::input',
613+
'self::textarea',
614+
'self::select',
615+
"@role = 'textbox'",
616+
"@role = 'searchbox'",
617+
"@role = 'combobox'",
618+
"@role = 'spinbutton'",
619+
"@role = 'slider'",
620+
"@role = 'listbox'",
621+
"@contenteditable = 'true'",
622+
].join(' or ')
623+
611624
Locator.field = {
612625
/**
613626
* @param {string} literal
@@ -627,9 +640,9 @@ Locator.field = {
627640
xpathLocator.combine([
628641
`.//*[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})]`,
629642
`.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
630-
`.//*[@aria-label = ${literal}]`,
631-
`.//*[@title = ${literal}]`,
632-
`.//*[@aria-labelledby][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`,
643+
`.//*[${fieldLike}][@aria-label = ${literal}]`,
644+
`.//*[${fieldLike}][@title = ${literal}]`,
645+
`.//*[${fieldLike}][@aria-labelledby][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`,
633646
]),
634647

635648
/**
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>Labelled containers around fields</title>
5+
</head>
6+
<body>
7+
<h1>Labelled containers</h1>
8+
9+
<form action="/form/complex" method="POST">
10+
<label id="volume-label">Volume</label>
11+
<div role="group" aria-labelledby="volume-label" class="slider-root">
12+
<input type="range" name="vol" min="0" max="100" value="30" aria-labelledby="volume-label" />
13+
</div>
14+
15+
<label id="nickname-label">Nickname</label>
16+
<div role="textbox" contenteditable="true" aria-labelledby="nickname-label" id="nickname">Bob</div>
17+
18+
<input type="submit" value="Submit" />
19+
</form>
20+
21+
<ul role="tablist" aria-label="Settings tabs">
22+
<li role="tab" aria-selected="true">Profile</li>
23+
<li role="tab" aria-selected="false">Password</li>
24+
</ul>
25+
</body>
26+
</html>

test/helper/webapi.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,23 @@ export function tests() {
609609
await I.see('tags: review,later', '#result')
610610
})
611611
})
612+
613+
it('should not resolve a labelled tablist container as a field', async function () {
614+
if (isHelper('Puppeteer')) this.skip()
615+
616+
await I.amOnPage('/form/field_containers')
617+
618+
let err
619+
try {
620+
await I.selectOption('Settings tabs', 'Password')
621+
} catch (e) {
622+
err = e
623+
}
624+
625+
if (!err) assert.fail('selected an option on a tablist')
626+
assert.include(err.message, 'was not found')
627+
assert.notInclude(err.message, '<select>')
628+
})
612629
})
613630

614631
describe('context parameter', () => {
@@ -1039,6 +1056,18 @@ export function tests() {
10391056
await I.seeInField("//input[@name='txtName'][2]", 'emma')
10401057
await I.seeInField("input[name='txtName']:nth-child(2)", 'emma')
10411058
})
1059+
1060+
it('should skip a labelled wrapper and read the field it wraps', async () => {
1061+
await I.amOnPage('/form/field_containers')
1062+
await I.seeInField('Volume', '30')
1063+
const value = await I.grabValueFrom('Volume')
1064+
assert.equal(value, '30')
1065+
})
1066+
1067+
it('should still reach a custom widget labelled by aria-labelledby', async () => {
1068+
await I.amOnPage('/form/field_containers')
1069+
await I.seeInField('Nickname', 'Bob')
1070+
})
10421071
})
10431072

10441073
describe('#grabTextFromAll, #grabHTMLFromAll, #grabValueFromAll, #grabAttributeFromAll', () => {

test/unit/locator_test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,4 +808,79 @@ describe('Locator', () => {
808808
expect(items[0].getAttribute('id')).to.eql('rename')
809809
})
810810
})
811+
812+
describe('Locator.field.labelContains', () => {
813+
const parse = markup => new DOMParser().parseFromString(markup, 'text/xml')
814+
815+
it('skips a labelled wrapper and resolves the input it wraps', () => {
816+
const sliderDoc = parse(`<root>
817+
<label id="volume-label">Volume</label>
818+
<div role="group" aria-labelledby="volume-label">
819+
<input type="range" name="vol" value="30" aria-labelledby="volume-label"/>
820+
</div>
821+
</root>`)
822+
const root = xpath.select1('//root', sliderDoc)
823+
const xp = Locator.field.labelContains("'Volume'")
824+
const nodes = xpath.select(xp, root)
825+
826+
expect(nodes).to.have.length(1, xp)
827+
expect(nodes[0].tagName).to.eql('input')
828+
expect(nodes[0].getAttribute('name')).to.eql('vol')
829+
})
830+
831+
it('does not match a tablist container labelled by aria-label', () => {
832+
const tabsDoc = parse(`<root>
833+
<ul role="tablist" aria-label="Settings tabs">
834+
<li role="tab">Profile</li>
835+
<li role="tab">Password</li>
836+
</ul>
837+
</root>`)
838+
const root = xpath.select1('//root', tabsDoc)
839+
const xp = Locator.field.labelContains("'Settings tabs'")
840+
841+
expect(xpath.select(xp, root)).to.have.length(0, xp)
842+
})
843+
844+
it('does not match a group container labelled by title', () => {
845+
const groupDoc = parse('<root><div role="group" title="Volume"><span>0</span></div></root>')
846+
const root = xpath.select1('//root', groupDoc)
847+
const xp = Locator.field.labelContains("'Volume'")
848+
849+
expect(xpath.select(xp, root)).to.have.length(0, xp)
850+
})
851+
852+
it('still matches a custom widget with an editable role', () => {
853+
const widgetDoc = parse('<root><div role="textbox" contenteditable="true" aria-label="Nickname" id="nick"/></root>')
854+
const root = xpath.select1('//root', widgetDoc)
855+
const xp = Locator.field.labelContains("'Nickname'")
856+
const nodes = xpath.select(xp, root)
857+
858+
expect(nodes).to.have.length(1, xp)
859+
expect(nodes[0].getAttribute('id')).to.eql('nick')
860+
})
861+
862+
it('still matches a native input by aria-label', () => {
863+
const inputDoc = parse('<root><input type="text" aria-label="My Address" name="my-form-address"/></root>')
864+
const root = xpath.select1('//root', inputDoc)
865+
const xp = Locator.field.labelContains("'My Address'")
866+
const nodes = xpath.select(xp, root)
867+
868+
expect(nodes).to.have.length(1, xp)
869+
expect(nodes[0].getAttribute('name')).to.eql('my-form-address')
870+
})
871+
872+
it('keeps both a combobox and a listbox sharing one aria-labelledby', () => {
873+
const selectDoc = parse(`<root>
874+
<label id="color-label">Favorite Color</label>
875+
<div role="combobox" aria-labelledby="color-label" id="color-trigger"/>
876+
<div role="listbox" aria-labelledby="color-label" id="color-listbox"/>
877+
</root>`)
878+
const root = xpath.select1('//root', selectDoc)
879+
const xp = Locator.field.labelContains("'Favorite Color'")
880+
const nodes = xpath.select(xp, root)
881+
882+
expect(nodes).to.have.length(2, xp)
883+
expect(nodes.map(n => n.getAttribute('id'))).to.eql(['color-trigger', 'color-listbox'])
884+
})
885+
})
811886
})

0 commit comments

Comments
 (0)