Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions lib/locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,9 @@ Locator.clickable = {
},
}

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 = {
/**
* @param {string} literal
Expand All @@ -627,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')]`,
`.//*[@aria-label = ${literal}]`,
`.//*[@title = ${literal}]`,
`.//*[@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]`,
]),

/**
Expand Down
26 changes: 26 additions & 0 deletions test/data/app/view/form/field_containers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<title>Labelled containers around fields</title>
</head>
<body>
<h1>Labelled containers</h1>

<form action="/form/complex" method="POST">
<label id="volume-label">Volume</label>
<div role="group" aria-labelledby="volume-label" class="slider-root">
<input type="range" name="vol" min="0" max="100" value="30" aria-labelledby="volume-label" />
</div>

<label id="nickname-label">Nickname</label>
<div role="textbox" contenteditable="true" aria-labelledby="nickname-label" id="nickname">Bob</div>

<input type="submit" value="Submit" />
</form>

<ul role="tablist" aria-label="Settings tabs">
<li role="tab" aria-selected="true">Profile</li>
<li role="tab" aria-selected="false">Password</li>
</ul>
</body>
</html>
30 changes: 30 additions & 0 deletions test/helper/webapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,25 @@ export function tests() {
await I.see('tags: review,later', '#result')
})
})

it('should not resolve a labelled tablist container as a field', async function () {
// Puppeteer's findFields falls back to `::-p-aria(<name>)`, 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')

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, '<select>')
})
})

describe('#selectOption - radiogroups', function () {
Expand Down Expand Up @@ -1303,6 +1322,17 @@ export function tests() {
await I.seeInField("//input[@name='txtName'][2]", 'emma')
await I.seeInField("input[name='txtName']:nth-child(2)", 'emma')
})

it('should skip a labelled wrapper and read the field it wraps', async () => {
await I.amOnPage('/form/field_containers')
await I.seeInField('Volume', '30')
await I.dontSeeInField('Volume', '70')
})

it('should still reach a custom widget labelled by aria-labelledby', async () => {
await I.amOnPage('/form/field_containers')
await I.seeInField('Nickname', 'Bob')
})
})

describe('#grabTextFromAll, #grabHTMLFromAll, #grabValueFromAll, #grabAttributeFromAll', () => {
Expand Down
106 changes: 106 additions & 0 deletions test/unit/locator_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,112 @@ describe('Locator', () => {
})
})

describe('Locator.field.labelContains', () => {
const parse = markup => new DOMParser().parseFromString(markup, 'text/xml')

it('skips a labelled wrapper and resolves the input it wraps', () => {
const sliderDoc = parse(`<root>
<label id="volume-label">Volume</label>
<div role="group" aria-labelledby="volume-label">
<input type="range" name="vol" value="30" aria-labelledby="volume-label"/>
</div>
</root>`)
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(`<root>
<ul role="tablist" aria-label="Settings tabs">
<li role="tab">Profile</li>
<li role="tab">Password</li>
</ul>
</root>`)
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('<root><div role="group" title="Volume"><span>0</span></div></root>')
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('<root><div role="textbox" contenteditable="true" aria-label="Nickname" id="nick"/></root>')
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 custom checkbox widget by aria-label', () => {
const boxDoc = parse('<root><span role="checkbox" aria-checked="false" aria-label="I agree" id="agree"/></root>')
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('<root><input type="text" aria-label="My Address" name="my-form-address"/></root>')
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(`<root>
<label id="color-label">Favorite Color</label>
<div role="combobox" aria-labelledby="color-label" id="color-trigger"/>
<div role="listbox" aria-labelledby="color-label" id="color-listbox"/>
</root>`)
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'])
})

it('still matches a radiogroup labelled by aria-labelledby without its heading', () => {
const groupDoc = parse(`<root>
<h3 id="theme-label">Theme</h3>
<div role="radiogroup" aria-labelledby="theme-label" id="theme"/>
</root>`)
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('<root><div role="group" aria-labelledby="theme-label" id="wrapper"/><h3 id="theme-label">Theme</h3></root>')
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', () => {
const select = (xml, literal) => {
const doc = new DOMParser().parseFromString(xml, 'text/xml')
Expand Down
Loading