From 3642e974273a1cb930faebaa13f7260438448ca7 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 8 Sep 2026 20:40:24 +0300 Subject: [PATCH] feat: selectOption works with role=radiogroup widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A radio group is a "pick one of these" control, so the natural way to write it is `I.selectOption('Density', 'Comfortable')`. Until now that failed with "Element is not a ` path. `role=radiogroup` is now a third rung of the fuzzy ladder in all three helpers, and a third branch of proceedSelect: the descendant `role=radio` whose accessible name matches the option is clicked. Radix Toggle Group in single mode renders the same shape, so it is covered too. The option name is matched exactly first and only then by substring — Playwright's `name` option defaults to case-insensitive substring, which would let 'Compact' be answered by a sibling named 'Compact mode'. An unknown option raises ElementNotFound rather than timing out on a click, and an array of two or more options is refused, since a radio group holds a single value and clicking each in turn would silently keep the last. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi --- docs/basics.md | 10 ++- lib/helper/Playwright.js | 16 ++++ lib/helper/Puppeteer.js | 16 ++++ lib/helper/WebDriver.js | 21 +++++ test/data/app/view/form/radiogroup.php | 16 ++++ test/data/app/view/form/radiogroup/baseui.php | 58 +++++++++++++ test/data/app/view/form/radiogroup/plain.php | 61 +++++++++++++ test/data/app/view/form/radiogroup/radix.php | 57 ++++++++++++ test/helper/webapi.js | 86 +++++++++++++++++++ 9 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 test/data/app/view/form/radiogroup.php create mode 100644 test/data/app/view/form/radiogroup/baseui.php create mode 100644 test/data/app/view/form/radiogroup/plain.php create mode 100644 test/data/app/view/form/radiogroup/radix.php diff --git a/docs/basics.md b/docs/basics.md index c6b7121f3..5fc85cf39 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -239,7 +239,15 @@ I.uncheckOption('Subscribe') > Use `secret()` for sensitive data: `I.fillField('password', secret('123456'))` - [won't expose in logs](/secrets/). > -> [selectOption](/web-api#iselectoption) works with native `` elements as well as custom components using `role="combobox"`, `role="listbox"`, or `role="radiogroup"`. +> +> For a radio group the option is matched against the accessible name of a `role="radio"` item, so a group of buttons reads the same way as a ` element const tagName = await el.evaluate(e => e.tagName) if (tagName !== 'SELECT') { diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 62c0b4dc2..b1004fc9e 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -1329,6 +1329,10 @@ class WebDriver extends Helper { els = await this._locateByRole({ role: 'listbox', text: matchedLocator.value }) if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option) + // Fuzzy: try radiogroup + els = await this._locateByRole({ role: 'radiogroup', text: matchedLocator.value }) + if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option) + // Fuzzy: try native select const res = await findFields.call(this, select, context) assertElementExists(res, select, 'Selectable field') @@ -3562,6 +3566,23 @@ async function proceedSelectOption(elem, option) { return } + if (role === 'radiogroup') { + if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`) + const [opt] = options + const radios = await this.browser.findElementsFromElement(elementId, 'xpath', `.//*[@role="radio"]`) + const names = [] + for (const radio of radios) { + names.push(await getElementTextAttributes.call(this, radio)) + } + let index = names.findIndex(texts => texts.some(text => text && text.trim() === opt)) + if (index === -1) index = names.findIndex(texts => texts.some(text => text && text.includes(opt))) + if (index === -1) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group') + this.debugSection('SelectOption', `Clicking: "${opt}"`) + highlightActiveElement.call(this, radios[index]) + await this.browser.elementClick(getElementId(radios[index])) + return + } + // Native + + + + + +
density: Comfortable, theme: Light, framework:
+ + + + diff --git a/test/data/app/view/form/radiogroup/radix.php b/test/data/app/view/form/radiogroup/radix.php new file mode 100644 index 000000000..47573467f --- /dev/null +++ b/test/data/app/view/form/radiogroup/radix.php @@ -0,0 +1,57 @@ + + + + + Radix Radio Group + + + + +

Radix Radio Group

+
+
+ + + diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 40b51c3c3..c16e5dc34 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -611,6 +611,92 @@ export function tests() { }) }) + describe('#selectOption - radiogroups', function () { + this.timeout(60000) + + const pages = { + plain: { title: 'selects in a group named by aria-labelledby', group: 'Theme', option: 'Dark', checked: ['Light=false', 'Dark=true', 'System=false'] }, + radix: { + title: 'selects an item of a Toggle Group in single mode, named by aria-labelledby', + group: 'Text alignment', + option: 'Center', + checked: ['Left=false', 'Center=true', 'Right=false'], + }, + baseui: { title: 'selects in a group named by aria-labelledby', group: 'Theme', option: 'Dark', checked: ['Light=false', 'Dark=true', 'System=false'] }, + } + + async function open(page) { + await I.amOnPage(`/form/radiogroup/${page}`) + await I.waitForFunction(() => window.__ready === true, [], 30) + } + + async function checkedStates(index) { + return I.executeScript(i => { + const group = document.querySelectorAll('[role="radiogroup"]')[i] + return [...group.querySelectorAll('[role="radio"]')].map(radio => `${radio.textContent.trim()}=${radio.getAttribute('aria-checked')}`) + }, index) + } + + for (const page of Object.keys(pages)) { + describe(page, () => { + it('checks the radio matching the option and unchecks its siblings', async () => { + await open(page) + await I.selectOption('Density', 'Compact') + expect(await checkedStates(0)).to.deep.equal(['Compact mode=false', 'Compact=true', 'Comfortable=false']) + }) + + it('unchecks the previous selection when switching', async () => { + await open(page) + await I.selectOption('Density', 'Compact') + await I.selectOption('Density', 'Comfortable') + expect(await checkedStates(0)).to.deep.equal(['Compact mode=false', 'Compact=false', 'Comfortable=true']) + }) + + it(pages[page].title, async () => { + await open(page) + await I.selectOption(pages[page].group, pages[page].option) + expect(await checkedStates(1)).to.deep.equal(pages[page].checked) + }) + }) + } + + it('selects by a strict locator pointing at the group', async () => { + await open('plain') + await I.selectOption({ css: '#density' }, 'Compact mode') + expect(await checkedStates(0)).to.deep.equal(['Compact mode=true', 'Compact=false', 'Comfortable=false']) + }) + + it('reports an unknown option instead of doing nothing', async () => { + await open('plain') + let message = '' + try { + await I.selectOption('Density', 'Spacious') + } catch (e) { + message = e.message + } + expect(message).to.include('Spacious') + expect(await checkedStates(0)).to.deep.equal(['Compact mode=false', 'Compact=false', 'Comfortable=true']) + }) + + it('refuses to select more than one option in a radio group', async () => { + await open('plain') + let message = '' + try { + await I.selectOption('Density', ['Compact', 'Comfortable']) + } catch (e) { + message = e.message + } + expect(message).to.include('radio group holds one value') + expect(await checkedStates(0)).to.deep.equal(['Compact mode=false', 'Compact=false', 'Comfortable=true']) + }) + + it('leaves a native select on the same page unaffected', async () => { + await open('plain') + await I.selectOption('Framework', 'Remix') + await I.see('framework: remix', '#result') + }) + }) + describe('context parameter', () => { it('should see element within context', async () => { await I.amOnPage('/form/context')