Skip to content

Commit b3de0d4

Browse files
DavertMikclaude
andcommitted
feat: checkable menu items and pressed-state toggle buttons
`checkOption` now reaches `role=menuitemcheckbox` and `role=menuitemradio`, and pressed-state buttons get their own pair of actions. Menu items are added to the ARIA role pass of all three helpers. Playwright's `check()` clicks such an item but then waits for the state on a node the menu may already have removed (Radix closes its menu on select, Base UI does not), so those two roles take a click-plus-verify path instead: read `aria-checked`, click only on a mismatch so a second `checkOption` cannot toggle the item back off, then wait for the new state and treat a vanished element as applied. Puppeteer and WebDriver already read `aria-checked` and already click only on a mismatch, so only their role lists changed; WebDriver's `seeCheckboxIsChecked` went through `findFields`, which no menu item can satisfy, and now falls back to `findCheckable`. `aria-pressed` is a different semantic from checked-ness, so Toggle and Toggle Group (multiple) get `toggleButton`, `seeButtonIsPressed` and `dontSeeButtonIsPressed` rather than an overloaded `checkOption`. `seeCheckboxIsChecked` on such a button now names the right action instead of surfacing Playwright's "Not a checkbox or radio button". `grabCheckedElementStatus` reads `aria-checked` and `aria-pressed` instead of requiring `type=checkbox|radio`, and names what it found when it still cannot answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
1 parent c4e7fed commit b3de0d4

12 files changed

Lines changed: 615 additions & 10 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Verifies that a toggle button is not pressed, by reading its `aria-pressed` attribute.
2+
3+
```js
4+
I.dontSeeButtonIsPressed('Bold');
5+
I.dontSeeButtonIsPressed('#bold');
6+
```
7+
8+
@param {CodeceptJS.LocatorOrString} locator button located by text|CSS|XPath|strict locator.
9+
@returns {void} automatically synchronized promise through #recorder
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Verifies that a toggle button is pressed, by reading its `aria-pressed` attribute.
2+
3+
```js
4+
I.seeButtonIsPressed('Bold');
5+
I.seeButtonIsPressed('#bold');
6+
```
7+
8+
@param {CodeceptJS.LocatorOrString} locator button located by text|CSS|XPath|strict locator.
9+
@returns {void} automatically synchronized promise through #recorder

‎docs/webapi/toggleButton.mustache‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Toggles a button that reports its state with `aria-pressed`, like a Toggle or a Toggle Group item.
2+
3+
Such a button is not a checkbox, so `checkOption` does not apply to it: this action flips it and
4+
waits until `aria-pressed` has changed.
5+
6+
```js
7+
I.toggleButton('Bold');
8+
I.toggleButton('#bold');
9+
I.toggleButton({ css: '[aria-label=Bold]' });
10+
```
11+
12+
@param {CodeceptJS.LocatorOrString} locator button located by text|CSS|XPath|strict locator.
13+
@returns {void} automatically synchronized promise through #recorder

‎lib/helper/Playwright.js‎

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ let defaultSelectorEnginesInitialized = false
5050
const popupStore = new Popup()
5151
const consoleLogStore = new Console()
5252
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
53-
const checkableRoles = ['checkbox', 'radio', 'switch']
53+
const ariaCheckableRoles = ['menuitemcheckbox', 'menuitemradio']
54+
const checkableRoles = ['checkbox', 'radio', 'switch', ...ariaCheckableRoles]
5455

5556
import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
5657
import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
@@ -1564,7 +1565,14 @@ class Playwright extends Helper {
15641565
if (supportedTypes.includes(type)) {
15651566
return el.isChecked(options)
15661567
}
1567-
throw new Error(`Element is not a ${supportedTypes.join(' or ')} input`)
1568+
1569+
for (const attribute of ['aria-checked', 'aria-pressed']) {
1570+
const state = await el.getAttribute(attribute)
1571+
if (state !== null) return state === 'true'
1572+
}
1573+
1574+
const role = await el.getAttribute('role')
1575+
throw new Error(`Element is not a ${supportedTypes.join(' or ')} input and has no aria-checked or aria-pressed state (role: ${role || 'none'})`)
15681576
}
15691577
/**
15701578
* Return the disabled status of given element.
@@ -2163,7 +2171,7 @@ class Playwright extends Helper {
21632171
*/
21642172
async checkOption(field, context = null, options = { force: true }) {
21652173
const elm = await this._locateCheckable(field, context)
2166-
await elm.check(options)
2174+
await setCheckableState.call(this, elm, true, field, options)
21672175
return this._waitForAction()
21682176
}
21692177

@@ -2183,7 +2191,7 @@ class Playwright extends Helper {
21832191
*/
21842192
async uncheckOption(field, context = null, options = { force: true }) {
21852193
const elm = await this._locateCheckable(field, context)
2186-
await elm.uncheck(options)
2194+
await setCheckableState.call(this, elm, false, field, options)
21872195
return this._waitForAction()
21882196
}
21892197

@@ -2201,6 +2209,39 @@ class Playwright extends Helper {
22012209
return proceedIsChecked.call(this, 'negate', field)
22022210
}
22032211

2212+
/**
2213+
* {{> toggleButton }}
2214+
*/
2215+
async toggleButton(locator, options = {}) {
2216+
const els = await this._locateClickable(locator)
2217+
assertElementExists(els, locator, 'Toggle button')
2218+
const el = selectElement(els, locator, this)
2219+
const pressed = await el.getAttribute('aria-pressed')
2220+
if (pressed === null) {
2221+
throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`)
2222+
}
2223+
2224+
await highlightActiveElement.call(this, el)
2225+
await el.click(options)
2226+
await waitForAriaState(el, 'aria-pressed', pressed !== 'true', locator)
2227+
2228+
return this._waitForAction()
2229+
}
2230+
2231+
/**
2232+
* {{> seeButtonIsPressed }}
2233+
*/
2234+
async seeButtonIsPressed(locator) {
2235+
return proceedIsPressed.call(this, 'assert', locator)
2236+
}
2237+
2238+
/**
2239+
* {{> dontSeeButtonIsPressed }}
2240+
*/
2241+
async dontSeeButtonIsPressed(locator) {
2242+
return proceedIsPressed.call(this, 'negate', locator)
2243+
}
2244+
22042245
/**
22052246
* {{> pressKeyDown }}
22062247
*/
@@ -4412,11 +4453,72 @@ async function findCheckable(locator, context) {
44124453
async function proceedIsChecked(assertType, option) {
44134454
let els = await findCheckable.call(this, option)
44144455
assertElementExists(els, option, 'Checkable')
4415-
els = await Promise.all(els.map(el => el.isChecked()))
4456+
els = await Promise.all(els.map(el => isElementChecked(el, option)))
44164457
const selected = els.reduce((prev, cur) => prev || cur)
44174458
return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
44184459
}
44194460

4461+
async function isElementChecked(el, locator) {
4462+
try {
4463+
return await el.isChecked()
4464+
} catch (err) {
4465+
const checked = await el.getAttribute('aria-checked')
4466+
if (checked !== null) return checked === 'true'
4467+
4468+
const pressed = await el.getAttribute('aria-pressed')
4469+
if (pressed !== null) {
4470+
throw new Error(`Element ${new Locator(locator)} is a toggle button with aria-pressed="${pressed}", use seeButtonIsPressed to assert its state`)
4471+
}
4472+
throw err
4473+
}
4474+
}
4475+
4476+
async function setCheckableState(el, expected, locator, options = {}) {
4477+
const role = await el.getAttribute('role')
4478+
if (!ariaCheckableRoles.includes(role)) {
4479+
return expected ? el.check(options) : el.uncheck(options)
4480+
}
4481+
4482+
const current = await el.getAttribute('aria-checked')
4483+
if (current === null) {
4484+
throw new Error(`Element ${new Locator(locator)} with role "${role}" has no aria-checked state`)
4485+
}
4486+
if ((current === 'true') === expected) return
4487+
4488+
await el.click(options)
4489+
await waitForAriaState(el, 'aria-checked', expected, locator)
4490+
}
4491+
4492+
async function waitForAriaState(el, attribute, expected, locator) {
4493+
const deadline = Date.now() + 2000
4494+
4495+
while (Date.now() < deadline) {
4496+
if (!(await el.isVisible().catch(() => false))) return
4497+
4498+
const state = await el.getAttribute(attribute, { timeout: 1000 }).catch(() => null)
4499+
if (state === null) return
4500+
if ((state === 'true') === expected) return
4501+
4502+
await new Promise(resolve => setTimeout(resolve, 50))
4503+
}
4504+
4505+
throw new Error(`Element ${new Locator(locator)} was clicked but its ${attribute} did not become "${expected}"`)
4506+
}
4507+
4508+
async function proceedIsPressed(assertType, locator) {
4509+
const matcher = await this._getContext()
4510+
const els = await findClickable.call(this, matcher, locator)
4511+
assertElementExists(els, locator, 'Toggle button')
4512+
4513+
const states = await Promise.all(els.map(el => el.getAttribute('aria-pressed')))
4514+
if (states.every(state => state === null)) {
4515+
throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`)
4516+
}
4517+
4518+
const pressed = states.some(state => state === 'true')
4519+
return truth(`toggle button ${locator}`, 'to be pressed')[assertType](pressed)
4520+
}
4521+
44204522
async function findFields(locator, context = null) {
44214523
let contextEl
44224524
if (context) {

‎lib/helper/Puppeteer.js‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function wrapError(e) {
6464
let perfTiming
6565
const popupStore = new Popup()
6666
const consoleLogStore = new Console()
67-
const checkableRoles = ['checkbox', 'radio', 'switch']
67+
const checkableRoles = ['checkbox', 'radio', 'switch', 'menuitemcheckbox', 'menuitemradio']
6868

6969
/**
7070
* ## Configuration
@@ -1516,6 +1516,40 @@ class Puppeteer extends Helper {
15161516
return proceedIsChecked.call(this, 'negate', field)
15171517
}
15181518

1519+
/**
1520+
* {{> toggleButton }}
1521+
*/
1522+
async toggleButton(locator) {
1523+
const els = await this._locateClickable(locator)
1524+
assertElementExists(els, locator, 'Toggle button')
1525+
const el = selectElement(els, locator, this)
1526+
const pressed = await grabAriaState(el, 'aria-pressed')
1527+
if (pressed === null) {
1528+
throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`)
1529+
}
1530+
1531+
highlightActiveElement.call(this, el, await this._getContext())
1532+
1533+
await el.click()
1534+
await waitForAriaState(el, 'aria-pressed', pressed !== 'true', locator)
1535+
1536+
return this._waitForAction()
1537+
}
1538+
1539+
/**
1540+
* {{> seeButtonIsPressed }}
1541+
*/
1542+
async seeButtonIsPressed(locator) {
1543+
return proceedIsPressed.call(this, 'assert', locator)
1544+
}
1545+
1546+
/**
1547+
* {{> dontSeeButtonIsPressed }}
1548+
*/
1549+
async dontSeeButtonIsPressed(locator) {
1550+
return proceedIsPressed.call(this, 'negate', locator)
1551+
}
1552+
15191553
/**
15201554
* {{> pressKeyDown }}
15211555
*/
@@ -3241,6 +3275,37 @@ async function proceedIsChecked(assertType, option) {
32413275
return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
32423276
}
32433277

3278+
async function grabAriaState(el, attribute) {
3279+
return el.evaluate((node, name) => node.getAttribute(name), attribute).catch(() => null)
3280+
}
3281+
3282+
async function waitForAriaState(el, attribute, expected, locator) {
3283+
const deadline = Date.now() + 2000
3284+
3285+
while (Date.now() < deadline) {
3286+
const state = await grabAriaState(el, attribute)
3287+
if (state === null) return
3288+
if ((state === 'true') === expected) return
3289+
3290+
await new Promise(resolve => setTimeout(resolve, 50))
3291+
}
3292+
3293+
throw new Error(`Element ${new Locator(locator)} was clicked but its ${attribute} did not become "${expected}"`)
3294+
}
3295+
3296+
async function proceedIsPressed(assertType, locator) {
3297+
const els = await this._locateClickable(locator)
3298+
assertElementExists(els, locator, 'Toggle button')
3299+
3300+
const states = await Promise.all(els.map(el => grabAriaState(el, 'aria-pressed')))
3301+
if (states.every(state => state === null)) {
3302+
throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`)
3303+
}
3304+
3305+
const pressed = states.some(state => state === 'true')
3306+
return truth(`toggle button ${locator}`, 'to be pressed')[assertType](pressed)
3307+
}
3308+
32443309
async function findVisibleFields(locator, context = null) {
32453310
const els = await findFields.call(this, locator, context)
32463311
const visible = await Promise.all(els.map(el => el.boundingBox()))

‎lib/helper/WebDriver.js‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,6 +1635,41 @@ class WebDriver extends Helper {
16351635
return proceedSeeCheckbox.call(this, 'negate', field)
16361636
}
16371637

1638+
/**
1639+
* {{> toggleButton }}
1640+
*/
1641+
async toggleButton(locator) {
1642+
const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick'
1643+
const locateFn = prepareLocateFn.call(this)
1644+
1645+
const res = await findClickable.call(this, locator, locateFn)
1646+
assertElementExists(res, locator, 'Toggle button')
1647+
const elem = selectElement(res, locator, this)
1648+
const elementId = getElementId(elem)
1649+
const pressed = await this.browser.getElementAttribute(elementId, 'aria-pressed')
1650+
if (pressed === null) {
1651+
throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`)
1652+
}
1653+
highlightActiveElement.call(this, elem)
1654+
1655+
await this.browser[clickMethod](elementId)
1656+
return waitForAriaState.call(this, elementId, 'aria-pressed', pressed !== 'true', locator)
1657+
}
1658+
1659+
/**
1660+
* {{> seeButtonIsPressed }}
1661+
*/
1662+
async seeButtonIsPressed(locator) {
1663+
return proceedIsPressed.call(this, 'assert', locator)
1664+
}
1665+
1666+
/**
1667+
* {{> dontSeeButtonIsPressed }}
1668+
*/
1669+
async dontSeeButtonIsPressed(locator) {
1670+
return proceedIsPressed.call(this, 'negate', locator)
1671+
}
1672+
16381673
/**
16391674
* {{> seeElement }}
16401675
*
@@ -3189,7 +3224,10 @@ function toArray(item) {
31893224
}
31903225

31913226
async function proceedSeeCheckbox(assertType, field) {
3192-
const res = await findFields.call(this, field)
3227+
let res = await findFields.call(this, field)
3228+
if (!res.length) {
3229+
res = await findCheckable.call(this, field, prepareLocateFn.call(this))
3230+
}
31933231
assertElementExists(res, field, 'Field')
31943232

31953233
const selected = await forEachAsync(res, async el => {
@@ -3224,6 +3262,36 @@ async function getElementTextAttributes(element) {
32243262
return [ariaLabel, placeholder, innerText, labelText]
32253263
}
32263264

3265+
async function waitForAriaState(elementId, attribute, expected, locator) {
3266+
const deadline = Date.now() + 2000
3267+
3268+
while (Date.now() < deadline) {
3269+
const state = await this.browser.getElementAttribute(elementId, attribute).catch(() => null)
3270+
if (state === null) return
3271+
if ((state === 'true') === expected) return
3272+
3273+
await new Promise(resolve => setTimeout(resolve, 50))
3274+
}
3275+
3276+
throw new Error(`Element ${new Locator(locator)} was clicked but its ${attribute} did not become "${expected}"`)
3277+
}
3278+
3279+
async function proceedIsPressed(assertType, locator) {
3280+
const res = await findClickable.call(this, locator, prepareLocateFn.call(this))
3281+
assertElementExists(res, locator, 'Toggle button')
3282+
3283+
const states = []
3284+
for (const el of res) {
3285+
states.push(await this.browser.getElementAttribute(getElementId(el), 'aria-pressed'))
3286+
}
3287+
if (states.every(state => state === null)) {
3288+
throw new Error(`Element ${new Locator(locator)} is not a toggle button, it has no aria-pressed attribute`)
3289+
}
3290+
3291+
const pressed = states.some(state => state === 'true')
3292+
return truth(`toggle button ${locator}`, 'to be pressed')[assertType](pressed)
3293+
}
3294+
32273295
async function isElementChecked(browser, elementId) {
32283296
let isChecked = await browser.isElementSelected(elementId)
32293297
if (!isChecked) {
@@ -3270,7 +3338,7 @@ async function keepCheckable(els) {
32703338
return Array.prototype.slice.call(arguments).map(function (el) {
32713339
if (!el) return false
32723340
const role = el.getAttribute('role')
3273-
if (role) return ['checkbox', 'radio', 'switch'].indexOf(role) > -1
3341+
if (role) return ['checkbox', 'radio', 'switch', 'menuitemcheckbox', 'menuitemradio'].indexOf(role) > -1
32743342
return el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')
32753343
})
32763344
}, ...els)

0 commit comments

Comments
 (0)