diff --git a/lib/locator.js b/lib/locator.js index 913fdd2dd..6a054d4fb 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -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 @@ -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]`, ]), /** diff --git a/test/data/app/view/form/field_containers.php b/test/data/app/view/form/field_containers.php new file mode 100644 index 000000000..ab05fa2e9 --- /dev/null +++ b/test/data/app/view/form/field_containers.php @@ -0,0 +1,26 @@ + + + + Labelled containers around fields + + +

Labelled containers

+ +
+ +
+ +
+ + +
Bob
+ + +
+ + + + diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 5d89be690..fb2bf80b1 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -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()`, 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, ' + + `) + 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 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) + 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']) + }) + + 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', () => { const select = (xml, literal) => { const doc = new DOMParser().parseFromString(xml, 'text/xml')