diff --git a/docs/webapi/dontSeeButtonIsPressed.mustache b/docs/webapi/dontSeeButtonIsPressed.mustache new file mode 100644 index 000000000..0a19d0823 --- /dev/null +++ b/docs/webapi/dontSeeButtonIsPressed.mustache @@ -0,0 +1,9 @@ +Verifies that a toggle button is not pressed, by reading its `aria-pressed` attribute. + +```js +I.dontSeeButtonIsPressed('Bold'); +I.dontSeeButtonIsPressed('#bold'); +``` + +@param {CodeceptJS.LocatorOrString} locator button located by text|CSS|XPath|strict locator. +@returns {void} automatically synchronized promise through #recorder diff --git a/docs/webapi/seeButtonIsPressed.mustache b/docs/webapi/seeButtonIsPressed.mustache new file mode 100644 index 000000000..1f4b358e9 --- /dev/null +++ b/docs/webapi/seeButtonIsPressed.mustache @@ -0,0 +1,9 @@ +Verifies that a toggle button is pressed, by reading its `aria-pressed` attribute. + +```js +I.seeButtonIsPressed('Bold'); +I.seeButtonIsPressed('#bold'); +``` + +@param {CodeceptJS.LocatorOrString} locator button located by text|CSS|XPath|strict locator. +@returns {void} automatically synchronized promise through #recorder diff --git a/docs/webapi/toggleButton.mustache b/docs/webapi/toggleButton.mustache new file mode 100644 index 000000000..efced45ba --- /dev/null +++ b/docs/webapi/toggleButton.mustache @@ -0,0 +1,13 @@ +Toggles a button that reports its state with `aria-pressed`, like a Toggle or a Toggle Group item. + +Such a button is not a checkbox, so `checkOption` does not apply to it: this action flips it and +waits until `aria-pressed` has changed. + +```js +I.toggleButton('Bold'); +I.toggleButton('#bold'); +I.toggleButton({ css: '[aria-label=Bold]' }); +``` + +@param {CodeceptJS.LocatorOrString} locator button located by text|CSS|XPath|strict locator. +@returns {void} automatically synchronized promise through #recorder diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 939988c9a..2bc0291e5 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -50,6 +50,8 @@ let defaultSelectorEnginesInitialized = false const popupStore = new Popup() const consoleLogStore = new Console() const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron'] +const ariaCheckableRoles = ['menuitemcheckbox', 'menuitemradio'] +const checkableRoles = ['checkbox', 'radio', 'switch', ...ariaCheckableRoles] import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js' import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js' @@ -1563,7 +1565,14 @@ class Playwright extends Helper { if (supportedTypes.includes(type)) { return el.isChecked(options) } - throw new Error(`Element is not a ${supportedTypes.join(' or ')} input`) + + for (const attribute of ['aria-checked', 'aria-pressed']) { + const state = await el.getAttribute(attribute) + if (state !== null) return state === 'true' + } + + const role = await el.getAttribute('role') + throw new Error(`Element is not a ${supportedTypes.join(' or ')} input and has no aria-checked or aria-pressed state (role: ${role || 'none'})`) } /** * Return the disabled status of given element. @@ -2162,7 +2171,7 @@ class Playwright extends Helper { */ async checkOption(field, context = null, options = { force: true }) { const elm = await this._locateCheckable(field, context) - await elm.check(options) + await setCheckableState.call(this, elm, true, field, options) return this._waitForAction() } @@ -2182,7 +2191,7 @@ class Playwright extends Helper { */ async uncheckOption(field, context = null, options = { force: true }) { const elm = await this._locateCheckable(field, context) - await elm.uncheck(options) + await setCheckableState.call(this, elm, false, field, options) return this._waitForAction() } @@ -2200,6 +2209,39 @@ class Playwright extends Helper { return proceedIsChecked.call(this, 'negate', field) } + /** + * {{> toggleButton }} + */ + async toggleButton(locator, options = {}) { + const els = await this._locateClickable(locator) + assertElementExists(els, locator, 'Toggle button') + const el = selectElement(els, locator, this) + const pressed = await el.getAttribute('aria-pressed') + if (pressed === null) { + throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`) + } + + await highlightActiveElement.call(this, el) + await el.click(options) + await waitForAriaState(el, 'aria-pressed', pressed !== 'true', locator) + + return this._waitForAction() + } + + /** + * {{> seeButtonIsPressed }} + */ + async seeButtonIsPressed(locator) { + return proceedIsPressed.call(this, 'assert', locator) + } + + /** + * {{> dontSeeButtonIsPressed }} + */ + async dontSeeButtonIsPressed(locator) { + return proceedIsPressed.call(this, 'negate', locator) + } + /** * {{> pressKeyDown }} */ @@ -4385,6 +4427,17 @@ 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) { @@ -4400,11 +4453,72 @@ async function findCheckable(locator, context) { async function proceedIsChecked(assertType, option) { let els = await findCheckable.call(this, option) assertElementExists(els, option, 'Checkable') - els = await Promise.all(els.map(el => el.isChecked())) + els = await Promise.all(els.map(el => isElementChecked(el, option))) const selected = els.reduce((prev, cur) => prev || cur) return truth(`checkable ${option}`, 'to be checked')[assertType](selected) } +async function isElementChecked(el, locator) { + try { + return await el.isChecked() + } catch (err) { + const checked = await el.getAttribute('aria-checked') + if (checked !== null) return checked === 'true' + + const pressed = await el.getAttribute('aria-pressed') + if (pressed !== null) { + throw new Error(`Element ${new Locator(locator)} is a toggle button with aria-pressed="${pressed}", use seeButtonIsPressed to assert its state`) + } + throw err + } +} + +async function setCheckableState(el, expected, locator, options = {}) { + const role = await el.getAttribute('role') + if (!ariaCheckableRoles.includes(role)) { + return expected ? el.check(options) : el.uncheck(options) + } + + const current = await el.getAttribute('aria-checked') + if (current === null) { + throw new Error(`Element ${new Locator(locator)} with role "${role}" has no aria-checked state`) + } + if ((current === 'true') === expected) return + + await el.click(options) + await waitForAriaState(el, 'aria-checked', expected, locator) +} + +async function waitForAriaState(el, attribute, expected, locator) { + const deadline = Date.now() + 2000 + + while (Date.now() < deadline) { + if (!(await el.isVisible().catch(() => false))) return + + const state = await el.getAttribute(attribute, { timeout: 1000 }).catch(() => null) + if (state === null) return + if ((state === 'true') === expected) return + + await new Promise(resolve => setTimeout(resolve, 50)) + } + + throw new Error(`Element ${new Locator(locator)} was clicked but its ${attribute} did not become "${expected}"`) +} + +async function proceedIsPressed(assertType, locator) { + const matcher = await this._getContext() + const els = await findClickable.call(this, matcher, locator) + assertElementExists(els, locator, 'Toggle button') + + const states = await Promise.all(els.map(el => el.getAttribute('aria-pressed'))) + if (states.every(state => state === null)) { + throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`) + } + + const pressed = states.some(state => state === 'true') + return truth(`toggle button ${locator}`, 'to be pressed')[assertType](pressed) +} + async function findFields(locator, context = null) { let contextEl if (context) { diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index ff00f6dd8..ee713eee1 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -64,6 +64,7 @@ function wrapError(e) { let perfTiming const popupStore = new Popup() const consoleLogStore = new Console() +const checkableRoles = ['checkbox', 'radio', 'switch', 'menuitemcheckbox', 'menuitemradio'] /** * ## Configuration @@ -1515,6 +1516,40 @@ class Puppeteer extends Helper { return proceedIsChecked.call(this, 'negate', field) } + /** + * {{> toggleButton }} + */ + async toggleButton(locator) { + const els = await this._locateClickable(locator) + assertElementExists(els, locator, 'Toggle button') + const el = selectElement(els, locator, this) + const pressed = await grabAriaState(el, 'aria-pressed') + if (pressed === null) { + throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`) + } + + highlightActiveElement.call(this, el, await this._getContext()) + + await el.click() + await waitForAriaState(el, 'aria-pressed', pressed !== 'true', locator) + + return this._waitForAction() + } + + /** + * {{> seeButtonIsPressed }} + */ + async seeButtonIsPressed(locator) { + return proceedIsPressed.call(this, 'assert', locator) + } + + /** + * {{> dontSeeButtonIsPressed }} + */ + async dontSeeButtonIsPressed(locator) { + return proceedIsPressed.call(this, 'negate', locator) + } + /** * {{> pressKeyDown }} */ @@ -3192,8 +3227,19 @@ 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) - let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) + els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) if (els.length) { return els } @@ -3202,14 +3248,6 @@ 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) } @@ -3237,6 +3275,37 @@ async function proceedIsChecked(assertType, option) { return truth(`checkable ${option}`, 'to be checked')[assertType](selected) } +async function grabAriaState(el, attribute) { + return el.evaluate((node, name) => node.getAttribute(name), attribute).catch(() => null) +} + +async function waitForAriaState(el, attribute, expected, locator) { + const deadline = Date.now() + 2000 + + while (Date.now() < deadline) { + const state = await grabAriaState(el, attribute) + if (state === null) return + if ((state === 'true') === expected) return + + await new Promise(resolve => setTimeout(resolve, 50)) + } + + throw new Error(`Element ${new Locator(locator)} was clicked but its ${attribute} did not become "${expected}"`) +} + +async function proceedIsPressed(assertType, locator) { + const els = await this._locateClickable(locator) + assertElementExists(els, locator, 'Toggle button') + + const states = await Promise.all(els.map(el => grabAriaState(el, 'aria-pressed'))) + if (states.every(state => state === null)) { + throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`) + } + + const pressed = states.some(state => state === 'true') + return truth(`toggle button ${locator}`, 'to be pressed')[assertType](pressed) +} + async function findVisibleFields(locator, context = null) { const els = await findFields.call(this, locator, context) const visible = await Promise.all(els.map(el => el.boundingBox())) diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 62c0b4dc2..722d9a573 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -1635,6 +1635,41 @@ class WebDriver extends Helper { return proceedSeeCheckbox.call(this, 'negate', field) } + /** + * {{> toggleButton }} + */ + async toggleButton(locator) { + const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick' + const locateFn = prepareLocateFn.call(this) + + const res = await findClickable.call(this, locator, locateFn) + assertElementExists(res, locator, 'Toggle button') + const elem = selectElement(res, locator, this) + const elementId = getElementId(elem) + const pressed = await this.browser.getElementAttribute(elementId, 'aria-pressed') + if (pressed === null) { + throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`) + } + highlightActiveElement.call(this, elem) + + await this.browser[clickMethod](elementId) + return waitForAriaState.call(this, elementId, 'aria-pressed', pressed !== 'true', locator) + } + + /** + * {{> seeButtonIsPressed }} + */ + async seeButtonIsPressed(locator) { + return proceedIsPressed.call(this, 'assert', locator) + } + + /** + * {{> dontSeeButtonIsPressed }} + */ + async dontSeeButtonIsPressed(locator) { + return proceedIsPressed.call(this, 'negate', locator) + } + /** * {{> seeElement }} * @@ -3189,7 +3224,10 @@ function toArray(item) { } async function proceedSeeCheckbox(assertType, field) { - const res = await findFields.call(this, field) + let res = await findFields.call(this, field) + if (!res.length) { + res = await findCheckable.call(this, field, prepareLocateFn.call(this)) + } assertElementExists(res, field, 'Field') const selected = await forEachAsync(res, async el => { @@ -3224,6 +3262,36 @@ async function getElementTextAttributes(element) { return [ariaLabel, placeholder, innerText, labelText] } +async function waitForAriaState(elementId, attribute, expected, locator) { + const deadline = Date.now() + 2000 + + while (Date.now() < deadline) { + const state = await this.browser.getElementAttribute(elementId, attribute).catch(() => null) + if (state === null) return + if ((state === 'true') === expected) return + + await new Promise(resolve => setTimeout(resolve, 50)) + } + + throw new Error(`Element ${new Locator(locator)} was clicked but its ${attribute} did not become "${expected}"`) +} + +async function proceedIsPressed(assertType, locator) { + const res = await findClickable.call(this, locator, prepareLocateFn.call(this)) + assertElementExists(res, locator, 'Toggle button') + + const states = [] + for (const el of res) { + states.push(await this.browser.getElementAttribute(getElementId(el), 'aria-pressed')) + } + if (states.every(state => state === null)) { + throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`) + } + + const pressed = states.some(state => state === 'true') + return truth(`toggle button ${locator}`, 'to be pressed')[assertType](pressed) +} + async function isElementChecked(browser, elementId) { let isChecked = await browser.isElementSelected(elementId) if (!isChecked) { @@ -3245,24 +3313,39 @@ async function findCheckable(locator, locateFn) { if (locator.isRole()) return locateFn(locator, true) if (!locator.isFuzzy()) return locateFn(locator, true) - const literal = xpathLocator.literal(locator.value) - els = await locateFn(Locator.checkable.byText(literal)) - if (els.length) return els - // Try ARIA selector for accessible name try { - els = await locateFn(`aria/${locator.value}`) + 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 + els = await locateFn(Locator.checkable.byName(literal)) if (els.length) return els 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', 'menuitemcheckbox', 'menuitemradio'].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) return locator.simplify() diff --git a/test/data/app/view/form/checkable/baseui.php b/test/data/app/view/form/checkable/baseui.php new file mode 100644 index 000000000..c06ad5258 --- /dev/null +++ b/test/data/app/view/form/checkable/baseui.php @@ -0,0 +1,58 @@ + + +
+ +