Skip to content

Commit 46ad07e

Browse files
author
DavertMik
committed
feat: add combobox/listbox support to Puppeteer and WebDriver selectOption
1 parent 59228bd commit 46ad07e

File tree

2 files changed

+203
-47
lines changed

2 files changed

+203
-47
lines changed

lib/helper/Puppeteer.js

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,33 +1617,30 @@ class Puppeteer extends Helper {
16171617
* {{> selectOption }}
16181618
*/
16191619
async selectOption(select, option) {
1620-
const els = await findVisibleFields.call(this, select)
1621-
assertElementExists(els, select, 'Selectable field')
1622-
const el = els[0]
1623-
if ((await el.getProperty('tagName').then(t => t.jsonValue())) !== 'SELECT') {
1624-
throw new Error('Element is not <select>')
1625-
}
1626-
highlightActiveElement.call(this, els[0], await this._getContext())
1627-
if (!Array.isArray(option)) option = [option]
1628-
1629-
for (const key in option) {
1630-
const opt = xpathLocator.literal(option[key])
1631-
let optEl = await findElements.call(this, el, { xpath: Locator.select.byVisibleText(opt) })
1632-
if (optEl.length) {
1633-
this._evaluateHandeInContext(el => (el.selected = true), optEl[0])
1634-
continue
1635-
}
1636-
optEl = await findElements.call(this, el, { xpath: Locator.select.byValue(opt) })
1637-
if (optEl.length) {
1638-
this._evaluateHandeInContext(el => (el.selected = true), optEl[0])
1639-
}
1620+
const context = await this._getContext()
1621+
const matchedLocator = new Locator(select)
1622+
1623+
// Strict locator
1624+
if (!matchedLocator.isFuzzy()) {
1625+
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
1626+
const els = await this._locate(select)
1627+
assertElementExists(els, select, 'Selectable element')
1628+
return proceedSelect.call(this, context, els[0], option)
16401629
}
1641-
await this._evaluateHandeInContext(element => {
1642-
element.dispatchEvent(new Event('input', { bubbles: true }))
1643-
element.dispatchEvent(new Event('change', { bubbles: true }))
1644-
}, el)
16451630

1646-
return this._waitForAction()
1631+
// Fuzzy: try combobox
1632+
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
1633+
let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value })
1634+
if (els?.length) return proceedSelect.call(this, context, els[0], option)
1635+
1636+
// Fuzzy: try listbox
1637+
els = await findByRole(context, { role: 'listbox', name: matchedLocator.value })
1638+
if (els?.length) return proceedSelect.call(this, context, els[0], option)
1639+
1640+
// Fuzzy: try native select
1641+
const visibleEls = await findVisibleFields.call(this, select)
1642+
assertElementExists(visibleEls, select, 'Selectable field')
1643+
return proceedSelect.call(this, context, visibleEls[0], option)
16471644
}
16481645

16491646
/**
@@ -3560,3 +3557,81 @@ function createRoleTextMatcher(expected, exactMatch) {
35603557
return value => typeof value === 'string' && value.includes(target)
35613558
}
35623559

3560+
async function proceedSelect(context, el, option) {
3561+
const role = await el.evaluate(e => e.getAttribute('role'))
3562+
const options = Array.isArray(option) ? option : [option]
3563+
3564+
if (role === 'combobox') {
3565+
this.debugSection('SelectOption', 'Expanding combobox')
3566+
highlightActiveElement.call(this, el, context)
3567+
const ariaOwns = await el.evaluate(e => e.getAttribute('aria-owns'))
3568+
const ariaControls = await el.evaluate(e => e.getAttribute('aria-controls'))
3569+
await el.click()
3570+
await this._waitForAction()
3571+
3572+
const listboxId = ariaOwns || ariaControls
3573+
let listbox = null
3574+
if (listboxId) {
3575+
const listboxEls = await context.$$( `#${listboxId}`)
3576+
if (listboxEls.length) listbox = listboxEls[0]
3577+
}
3578+
if (!listbox) {
3579+
const listboxEls = await findByRole.call(this, context, { role: 'listbox' })
3580+
if (listboxEls?.length) listbox = listboxEls[0]
3581+
}
3582+
3583+
if (listbox) {
3584+
for (const opt of options) {
3585+
const optEls = await findByRole.call(this, listbox, { role: 'option', name: opt })
3586+
if (optEls?.length) {
3587+
const optEl = optEls[0]
3588+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3589+
highlightActiveElement.call(this, optEl, context)
3590+
await optEl.click()
3591+
}
3592+
}
3593+
}
3594+
return this._waitForAction()
3595+
}
3596+
3597+
if (role === 'listbox') {
3598+
for (const opt of options) {
3599+
const optEls = await findByRole.call(this, el, { role: 'option', name: opt })
3600+
if (optEls?.length) {
3601+
const optEl = optEls[0]
3602+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3603+
highlightActiveElement.call(this, optEl, context)
3604+
await optEl.click()
3605+
}
3606+
}
3607+
return this._waitForAction()
3608+
}
3609+
3610+
// Native <select> element
3611+
const tagName = await el.evaluate(e => e.tagName)
3612+
if (tagName !== 'SELECT') {
3613+
throw new Error('Element is not <select>')
3614+
}
3615+
highlightActiveElement.call(this, el, context)
3616+
const optionArray = Array.isArray(option) ? option : [option]
3617+
3618+
for (const key in optionArray) {
3619+
const opt = xpathLocator.literal(optionArray[key])
3620+
let optEl = await findElements.call(this, el, { xpath: Locator.select.byVisibleText(opt) })
3621+
if (optEl.length) {
3622+
this._evaluateHandeInContext(el => (el.selected = true), optEl[0])
3623+
continue
3624+
}
3625+
optEl = await findElements.call(this, el, { xpath: Locator.select.byValue(opt) })
3626+
if (optEl.length) {
3627+
this._evaluateHandeInContext(el => (el.selected = true), optEl[0])
3628+
}
3629+
}
3630+
await this._evaluateHandeInContext(element => {
3631+
element.dispatchEvent(new Event('input', { bubbles: true }))
3632+
element.dispatchEvent(new Event('change', { bubbles: true }))
3633+
}, el)
3634+
3635+
return this._waitForAction()
3636+
}
3637+

lib/helper/WebDriver.js

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,33 +1302,29 @@ class WebDriver extends Helper {
13021302
* {{> selectOption }}
13031303
*/
13041304
async selectOption(select, option) {
1305-
const res = await findFields.call(this, select)
1306-
assertElementExists(res, select, 'Selectable field')
1307-
const elem = usingFirstElement(res)
1308-
highlightActiveElement.call(this, elem)
1305+
const matchedLocator = new Locator(select)
13091306

1310-
if (!Array.isArray(option)) {
1311-
option = [option]
1307+
// Strict locator
1308+
if (!matchedLocator.isFuzzy()) {
1309+
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
1310+
const els = await this._locate(select)
1311+
assertElementExists(els, select, 'Selectable element')
1312+
return proceedSelectOption.call(this, usingFirstElement(els), option)
13121313
}
13131314

1314-
// select options by visible text
1315-
let els = await forEachAsync(option, async opt => this.browser.findElementsFromElement(getElementId(elem), 'xpath', Locator.select.byVisibleText(xpathLocator.literal(opt))))
1315+
// Fuzzy: try combobox
1316+
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
1317+
let els = await this._locateByRole({ role: 'combobox', name: matchedLocator.value })
1318+
if (els?.length) return proceedSelectOption.call(this, usingFirstElement(els), option)
13161319

1317-
const clickOptionFn = async el => {
1318-
if (el[0]) el = el[0]
1319-
const elementId = getElementId(el)
1320-
if (elementId) return this.browser.elementClick(elementId)
1321-
}
1320+
// Fuzzy: try listbox
1321+
els = await this._locateByRole({ role: 'listbox', name: matchedLocator.value })
1322+
if (els?.length) return proceedSelectOption.call(this, usingFirstElement(els), option)
13221323

1323-
if (Array.isArray(els) && els.length) {
1324-
return forEachAsync(els, clickOptionFn)
1325-
}
1326-
// select options by value
1327-
els = await forEachAsync(option, async opt => this.browser.findElementsFromElement(getElementId(elem), 'xpath', Locator.select.byValue(xpathLocator.literal(opt))))
1328-
if (els.length === 0) {
1329-
throw new ElementNotFound(select, `Option "${option}" in`, 'was not found neither by a visible text nor by a value')
1330-
}
1331-
return forEachAsync(els, clickOptionFn)
1324+
// Fuzzy: try native select
1325+
const res = await findFields.call(this, select)
1326+
assertElementExists(res, select, 'Selectable field')
1327+
return proceedSelectOption.call(this, usingFirstElement(res), option)
13321328
}
13331329

13341330
/**
@@ -3376,4 +3372,89 @@ function logEvents(event) {
33763372
browserLogs.push(event.text) // add log message to the array
33773373
}
33783374

3375+
async function proceedSelectOption(elem, option) {
3376+
const elementId = getElementId(elem)
3377+
const role = await this.browser.getElementAttribute(elementId, 'role').catch(() => null)
3378+
const options = Array.isArray(option) ? option : [option]
3379+
3380+
if (role === 'combobox') {
3381+
this.debugSection('SelectOption', 'Expanding combobox')
3382+
highlightActiveElement.call(this, elem)
3383+
const ariaOwns = await this.browser.getElementAttribute(elementId, 'aria-owns').catch(() => null)
3384+
const ariaControls = await this.browser.getElementAttribute(elementId, 'aria-controls').catch(() => null)
3385+
await this.browser.elementClick(elementId)
3386+
await this._waitForAction()
3387+
3388+
const listboxId = ariaOwns || ariaControls
3389+
let listbox = null
3390+
if (listboxId) {
3391+
const listboxEls = await this.browser.$$(`#${listboxId}`)
3392+
if (listboxEls?.length) listbox = listboxEls[0]
3393+
}
3394+
if (!listbox) {
3395+
const listboxEls = await this._locateByRole({ role: 'listbox' })
3396+
if (listboxEls?.length) listbox = listboxEls[0]
3397+
}
3398+
3399+
if (listbox) {
3400+
const listboxElementId = getElementId(listbox)
3401+
for (const opt of options) {
3402+
const optEls = await this._locateByRole({ role: 'option', text: opt })
3403+
if (optEls?.length) {
3404+
const optEl = optEls[0]
3405+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3406+
highlightActiveElement.call(this, optEl)
3407+
await this.browser.elementClick(getElementId(optEl))
3408+
}
3409+
}
3410+
}
3411+
return this._waitForAction()
3412+
}
3413+
3414+
if (role === 'listbox') {
3415+
for (const opt of options) {
3416+
const optEls = await this.browser.findElementsFromElement(elementId, 'css', `[role="option"]`)
3417+
if (optEls?.length) {
3418+
for (const optEl of optEls) {
3419+
const optElId = getElementId(optEl)
3420+
const text = await this.browser.getElementText(optElId).catch(() => '')
3421+
if (text && text.includes(opt)) {
3422+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3423+
highlightActiveElement.call(this, optEl)
3424+
await this.browser.elementClick(optElId)
3425+
break
3426+
}
3427+
}
3428+
}
3429+
}
3430+
return this._waitForAction()
3431+
}
3432+
3433+
// Native <select> element
3434+
highlightActiveElement.call(this, elem)
3435+
3436+
if (!Array.isArray(option)) {
3437+
option = [option]
3438+
}
3439+
3440+
const clickOptionFn = async el => {
3441+
if (el[0]) el = el[0]
3442+
const elId = getElementId(el)
3443+
if (elId) return this.browser.elementClick(elId)
3444+
}
3445+
3446+
// select options by visible text
3447+
let els = await forEachAsync(option, async opt => this.browser.findElementsFromElement(elementId, 'xpath', Locator.select.byVisibleText(xpathLocator.literal(opt))))
3448+
3449+
if (Array.isArray(els) && els.length) {
3450+
return forEachAsync(els, clickOptionFn)
3451+
}
3452+
// select options by value
3453+
els = await forEachAsync(option, async opt => this.browser.findElementsFromElement(elementId, 'xpath', Locator.select.byValue(xpathLocator.literal(opt))))
3454+
if (els.length === 0) {
3455+
throw new ElementNotFound(elem, `Option "${option}" in`, 'was not found neither by a visible text nor by a value')
3456+
}
3457+
return forEachAsync(els, clickOptionFn)
3458+
}
3459+
33793460
export { WebDriver as default }

0 commit comments

Comments
 (0)