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')