Skip to content
Closed
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
9 changes: 9 additions & 0 deletions docs/webapi/dontSeeButtonIsPressed.mustache
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions docs/webapi/seeButtonIsPressed.mustache
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions docs/webapi/toggleButton.mustache
Original file line number Diff line number Diff line change
@@ -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
122 changes: 118 additions & 4 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}

Expand All @@ -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()
}

Expand All @@ -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 }}
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
87 changes: 78 additions & 9 deletions lib/helper/Puppeteer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
*/
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}

Expand Down Expand Up @@ -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()))
Expand Down
Loading