diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index c4cabf115..91f90bffd 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -50,7 +50,6 @@ let defaultSelectorEnginesInitialized = false const popupStore = new Popup() const consoleLogStore = new Console() const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron'] -const checkableRoles = ['checkbox', 'radio', 'switch'] import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js' import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js' @@ -4389,17 +4388,6 @@ async function findCheckable(locator, context) { return findElements.call(this, contextEl, matchedLocator) } - for (const exact of [true, false]) { - for (const role of checkableRoles) { - try { - const roleEls = await contextEl.getByRole(role, { name: matchedLocator.value, exact }).all() - if (roleEls.length) return roleEls - } catch (err) { - // getByRole not supported or failed - } - } - } - const literal = xpathLocator.literal(matchedLocator.value) let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) if (els.length) { diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 7e147e757..3d1f7a02e 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -64,7 +64,6 @@ function wrapError(e) { let perfTiming const popupStore = new Popup() const consoleLogStore = new Console() -const checkableRoles = ['checkbox', 'radio', 'switch'] /** * ## Configuration @@ -3196,19 +3195,8 @@ async function findCheckable(locator, context) { return findElements.call(this, contextEl, matchedLocator) } - // Try ARIA selector for accessible name - let els - for (const role of checkableRoles) { - try { - els = await contextEl.$$(`::-p-aria([name="${matchedLocator.value}"][role="${role}"])`) - if (els.length) return els - } catch (err) { - // ARIA selector not supported or failed - } - } - const literal = xpathLocator.literal(matchedLocator.value) - els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) + let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) if (els.length) { return els } @@ -3217,6 +3205,14 @@ async function findCheckable(locator, context) { return els } + // Try ARIA selector for accessible name + try { + els = await contextEl.$$(`::-p-aria(${matchedLocator.value})`) + if (els.length) return els + } catch (err) { + // ARIA selector not supported or failed + } + return findElements.call(this, contextEl, matchedLocator.value) } diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index a52d85d9d..1d12b7f41 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -3248,14 +3248,6 @@ async function findCheckable(locator, locateFn) { if (locator.isRole()) return locateFn(locator, true) if (!locator.isFuzzy()) return locateFn(locator, true) - // Try ARIA selector for accessible name - try { - els = await keepCheckable.call(this, await locateFn(`aria/${locator.value}`)) - if (els.length) return els - } catch (e) { - // ARIA selector not supported or failed - } - const literal = xpathLocator.literal(locator.value) els = await locateFn(Locator.checkable.byText(literal)) if (els.length) return els @@ -3263,23 +3255,17 @@ async function findCheckable(locator, locateFn) { els = await locateFn(Locator.checkable.byName(literal)) if (els.length) return els + // Try ARIA selector for accessible name + try { + els = await locateFn(`aria/${locator.value}`) + if (els.length) return els + } catch (e) { + // ARIA selector not supported or failed + } + return await locateFn(locator.value) // by css or xpath } -async function keepCheckable(els) { - if (!els || !els.length) return [] - - const checkable = await this.browser.execute(function () { - return Array.prototype.slice.call(arguments).map(function (el) { - if (!el) return false - const role = el.getAttribute('role') - if (role) return ['checkbox', 'radio', 'switch'].indexOf(role) > -1 - return el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio') - }) - }, ...els) - - return els.filter((el, index) => checkable[index]) -} function withStrictLocator(locator) { locator = new Locator(locator) diff --git a/lib/locator.js b/lib/locator.js index 6a47b71d2..913fdd2dd 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -649,6 +649,9 @@ Locator.field = { ]), } +const checkable = `self::input[@type = 'checkbox' or @type = 'radio'] or @role = 'checkbox' or @role = 'radio' or @role = 'switch'` +const visibleCheckable = `.//*[${checkable}][not(@aria-hidden = 'true')]` + Locator.checkable = { /** * @param {string} literal @@ -656,8 +659,10 @@ Locator.checkable = { */ byText: literal => xpathLocator.combine([ - `.//input[@type = 'checkbox' or @type = 'radio'][(@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or @placeholder = ${literal}]`, - `.//label[contains(normalize-space(string(.)), ${literal})]//input[@type = 'radio' or @type = 'checkbox']`, + `${visibleCheckable}[(@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or @placeholder = ${literal}]`, + `.//label[contains(normalize-space(string(.)), ${literal})]//*[${checkable}][not(@aria-hidden = 'true')]`, + `${visibleCheckable}[@aria-labelledby = //*[@id][contains(normalize-space(string(.)), ${literal})]/@id]`, + `${visibleCheckable}[@aria-label = ${literal}]`, ]), /** diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index 9c59216ae..706ddc4ca 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -808,4 +808,47 @@ describe('Locator', () => { expect(items[0].getAttribute('id')).to.eql('rename') }) }) + + describe('Locator.checkable.byText', () => { + const select = (xml, literal) => { + const doc = new DOMParser().parseFromString(xml, 'text/xml') + return xpath.select(Locator.checkable.byText(literal), xpath.select1('//root', doc)) + } + + it('matches a native input labelled by label[for]', () => { + const nodes = select('', "'I Agree'") + expect(nodes).to.have.length(1) + expect(nodes[0].getAttribute('id')).to.eql('a') + }) + + it('matches a role=checkbox labelled by label[for]', () => { + const nodes = select('', "'Accept terms'") + expect(nodes).to.have.length(1) + expect(nodes[0].tagName).to.eql('button') + }) + + it('matches a role=switch labelled by aria-labelledby', () => { + const nodes = select('', "'Airplane mode'") + expect(nodes).to.have.length(1) + expect(nodes[0].getAttribute('role')).to.eql('switch') + }) + + it('matches a role=radio named by aria-label', () => { + const nodes = select('', "'Compact'") + expect(nodes).to.have.length(1) + expect(nodes[0].getAttribute('role')).to.eql('radio') + }) + + it('resolves the visible control, not the aria-hidden input the label points at', () => { + const xml = + '' + + '' + + '' + + '' + + '' + const nodes = select(xml, "'Accept terms'") + expect(nodes).to.have.length(1) + expect(nodes[0].getAttribute('id')).to.eql('visible') + }) + }) })