Skip to content

Commit 3dc317e

Browse files
DavertMikclaude
andcommitted
feat: fillField works with role=combobox widgets
fillField now handles comboboxes whose trigger is not a text field — a `<button role="combobox">` (shadcn, Base UI), a `<div role="combobox">`, or a readonly input trigger. The widget is expanded, its search input is located, and the value is typed there, leaving the list filtered so an option can be picked with a following click. Locating the input walks: an editable descendant of the trigger, the `aria-controls`/`aria-owns` container, a visible dialog/listbox popup, then the focused element. Focus lands asynchronously after the click (~80ms in Base UI), so the search is polled to a 1s deadline rather than sampled once. The element focused before the click is excluded, so a previously focused field never receives the keystrokes. A combobox with no text input raises an error pointing at selectOption. Also fixes Locator.field matching an aria-hidden proxy input. Base UI points `<label for>` at a visually hidden form-value input rather than at the trigger, so fillField typed into an invisible field and passed while the app saw nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ut1rjeUiZmKU7V61CeYhns
1 parent eb1bcdc commit 3dc317e

12 files changed

Lines changed: 449 additions & 16 deletions

File tree

docs/basics.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,13 @@ I.uncheckOption('Subscribe')
241241
242242
> [selectOption](/web-api#iselectoption) works with native `<select>` elements as well as custom components using `role="combobox"` or `role="listbox"`.
243243
244+
> [fillField](/web-api#ifillfield) works with searchable comboboxes built on `role="combobox"` — including triggers rendered as a `<button>`. The combobox is expanded, its search input is located, and the value is typed into it, leaving the list filtered so an option can be picked:
245+
>
246+
> ```js
247+
> I.fillField('Country', 'Ukr')
248+
> I.click('Ukraine')
249+
> ```
250+
244251
### Assertions
245252
246253
CodeceptJS provides **built-in browser assertions** instead of generic `expect()` calls. This keeps tests readable and produces clear failure messages without extra assertion libraries.

lib/helper/Playwright.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import { findByPlaywrightLocator } from './extras/PlaywrightLocator.js'
4040
import { dropFile } from './scripts/dropFile.js'
4141
import WebElement from '../element/WebElement.js'
4242
import { selectElement } from './extras/elementSelection.js'
43-
import { fillRichEditor } from './extras/richTextEditor.js'
43+
import { detectFillTarget, fillRichEditor, FILL_TARGET } from './extras/richTextEditor.js'
44+
import { fillComboBox } from './extras/comboBox.js'
4445

4546
let playwright
4647
let perfTiming
@@ -2282,7 +2283,14 @@ class Playwright extends Helper {
22822283

22832284
await highlightActiveElement.call(this, el)
22842285

2285-
if (await fillRichEditor(this, el, value)) {
2286+
const fillTarget = await detectFillTarget(this, el)
2287+
2288+
if (fillTarget === FILL_TARGET.COMBOBOX) {
2289+
await fillComboBox(this, el, value)
2290+
return this._waitForAction()
2291+
}
2292+
2293+
if (await fillRichEditor(this, fillTarget, value)) {
22862294
return this._waitForAction()
22872295
}
22882296

lib/helper/Puppeteer.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElem
4545
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
4646
import WebElement from '../element/WebElement.js'
4747
import { selectElement } from './extras/elementSelection.js'
48-
import { fillRichEditor } from './extras/richTextEditor.js'
48+
import { detectFillTarget, fillRichEditor, FILL_TARGET } from './extras/richTextEditor.js'
49+
import { fillComboBox } from './extras/comboBox.js'
4950

5051
let puppeteer
5152

@@ -1592,7 +1593,14 @@ class Puppeteer extends Helper {
15921593
assertElementExists(els, field, 'Field')
15931594
const el = selectElement(els, field, this)
15941595

1595-
if (await fillRichEditor(this, el, value)) {
1596+
const fillTarget = await detectFillTarget(this, el)
1597+
1598+
if (fillTarget === FILL_TARGET.COMBOBOX) {
1599+
await fillComboBox(this, el, value)
1600+
return this._waitForAction()
1601+
}
1602+
1603+
if (await fillRichEditor(this, fillTarget, value)) {
15961604
highlightActiveElement.call(this, el, await this._getContext())
15971605
return this._waitForAction()
15981606
}

lib/helper/WebDriver.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ import { dropFile } from './scripts/dropFile.js'
4242
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
4343
import WebElement from '../element/WebElement.js'
4444
import { selectElement } from './extras/elementSelection.js'
45-
import { fillRichEditor } from './extras/richTextEditor.js'
45+
import { detectFillTarget, fillRichEditor, FILL_TARGET } from './extras/richTextEditor.js'
46+
import { fillComboBox } from './extras/comboBox.js'
4647

4748
const SHADOW = 'shadow'
4849
const webRoot = 'body'
@@ -1264,8 +1265,16 @@ class WebDriver extends Helper {
12641265
const elem = selectElement(res, field, this)
12651266
highlightActiveElement.call(this, elem)
12661267

1267-
if (this.isWeb !== false && await fillRichEditor(this, elem, value)) {
1268-
return
1268+
if (this.isWeb !== false) {
1269+
const fillTarget = await detectFillTarget(this, elem)
1270+
1271+
if (fillTarget === FILL_TARGET.COMBOBOX) {
1272+
return fillComboBox(this, elem, value)
1273+
}
1274+
1275+
if (await fillRichEditor(this, fillTarget, value)) {
1276+
return
1277+
}
12691278
}
12701279

12711280
try {

lib/helper/extras/comboBox.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import WebElement from '../../element/WebElement.js'
2+
3+
const MARKER = 'data-codeceptjs-combobox-target'
4+
const PREVIOUS = 'data-codeceptjs-combobox-previous'
5+
const OPEN_TIMEOUT = 1000
6+
const POLL_INTERVAL = 50
7+
8+
function locateInput(el, opts) {
9+
const marker = opts.marker
10+
const previous = opts.previous
11+
const doc = el.ownerDocument
12+
const NON_TEXT = ['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden']
13+
const SELECTOR = 'input, textarea, [contenteditable="true"], [contenteditable=""]'
14+
15+
function isEditable(node) {
16+
if (!node || node.nodeType !== 1) return false
17+
if (node.isContentEditable) return true
18+
const t = node.tagName
19+
if (t !== 'INPUT' && t !== 'TEXTAREA') return false
20+
if (node.disabled || node.readOnly) return false
21+
return t !== 'INPUT' || NON_TEXT.indexOf((node.type || 'text').toLowerCase()) === -1
22+
}
23+
24+
function isVisible(node) {
25+
return node.getClientRects().length > 0
26+
}
27+
28+
function mark(node) {
29+
doc.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
30+
node.setAttribute(marker, '1')
31+
let name = node.tagName.toLowerCase()
32+
if (node.id) name += '#' + node.id
33+
if (node.placeholder) name += '[placeholder="' + node.placeholder + '"]'
34+
return name
35+
}
36+
37+
function pick(root) {
38+
const active = doc.activeElement
39+
if (active && active !== el && !active.hasAttribute(previous) && root.contains(active) && isEditable(active)) return active
40+
const nodes = root.querySelectorAll(SELECTOR)
41+
for (let i = 0; i < nodes.length; i++) {
42+
if (isEditable(nodes[i]) && isVisible(nodes[i])) return nodes[i]
43+
}
44+
return null
45+
}
46+
47+
if (opts.tagActive) {
48+
doc.querySelectorAll('[' + previous + ']').forEach(n => n.removeAttribute(previous))
49+
if (doc.activeElement && doc.activeElement.nodeType === 1) doc.activeElement.setAttribute(previous, '1')
50+
}
51+
52+
if (isEditable(el)) return mark(el)
53+
54+
const inner = pick(el)
55+
if (inner) return mark(inner)
56+
57+
const roots = []
58+
const controlled = el.getAttribute('aria-controls') || el.getAttribute('aria-owns')
59+
if (controlled && doc.getElementById(controlled)) roots.push(doc.getElementById(controlled))
60+
doc.querySelectorAll('[role="dialog"], [role="listbox"]').forEach(n => {
61+
if (isVisible(n)) roots.push(n)
62+
})
63+
64+
for (let i = 0; i < roots.length; i++) {
65+
const found = pick(roots[i])
66+
if (found) return mark(found)
67+
}
68+
69+
if (!opts.allowActive) return null
70+
71+
let active = doc.activeElement
72+
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement
73+
if (active && active !== el && !active.hasAttribute(previous) && isEditable(active) && isVisible(active)) return mark(active)
74+
75+
return null
76+
}
77+
78+
function readValue(el) {
79+
return el.value === undefined ? el.textContent : el.value
80+
}
81+
82+
function isActive(el) {
83+
return el.ownerDocument.activeElement === el
84+
}
85+
86+
function unmarkAll(markers) {
87+
markers.forEach(marker => document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)))
88+
}
89+
90+
async function findMarked(helper) {
91+
const root = helper.page || helper.browser
92+
const raw = await root.$('[' + MARKER + ']')
93+
return new WebElement(raw, helper)
94+
}
95+
96+
async function clearMarker(helper) {
97+
if (helper.page) return helper.page.evaluate(unmarkAll, [MARKER, PREVIOUS])
98+
return helper.executeScript(unmarkAll, [MARKER, PREVIOUS])
99+
}
100+
101+
export async function fillComboBox(helper, el, value) {
102+
const trigger = el instanceof WebElement ? el : new WebElement(el, helper)
103+
const options = { marker: MARKER, previous: PREVIOUS }
104+
let found = await trigger.evaluate(locateInput, { ...options, tagActive: true })
105+
106+
if (!found) {
107+
if ((await trigger.getAttribute('aria-expanded')) !== 'true') {
108+
helper.debugSection('ComboBox', 'Expanding combobox')
109+
await trigger.click()
110+
}
111+
const deadline = Date.now() + OPEN_TIMEOUT
112+
while (!found && Date.now() < deadline) {
113+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL))
114+
found = await trigger.evaluate(locateInput, { ...options, allowActive: true })
115+
}
116+
}
117+
118+
if (!found) {
119+
await clearMarker(helper)
120+
throw new Error('fillField: combobox exposes no text input to type into. Use I.selectOption() to pick one of its options.')
121+
}
122+
123+
const target = await findMarked(helper)
124+
helper.debugSection('ComboBox', `Typing into ${found}`)
125+
126+
await target.focus()
127+
if (!(await target.evaluate(isActive))) await target.click()
128+
if (!(await target.evaluate(isActive))) {
129+
throw new Error(`fillField: combobox input ${found} did not accept focus.`)
130+
}
131+
132+
if (await target.evaluate(readValue)) await target.selectAllAndDelete()
133+
await target.typeText(value, { delay: helper.options.pressKeyDelay })
134+
135+
await clearMarker(helper)
136+
}

lib/helper/extras/richTextEditor.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,33 @@ const EDITOR = {
77
IFRAME: 'iframe',
88
CONTENTEDITABLE: 'contenteditable',
99
HIDDEN_TEXTAREA: 'hidden-textarea',
10+
COMBOBOX: 'combobox',
1011
UNREACHABLE: 'unreachable',
1112
}
1213

14+
export const FILL_TARGET = EDITOR
15+
1316
function detectAndMark(el, opts) {
1417
const marker = opts.marker
1518
const kinds = opts.kinds
1619
const CE = '[contenteditable="true"], [contenteditable=""]'
20+
const NON_TEXT = ['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden']
1721

1822
function mark(kind, target) {
1923
document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
2024
if (target && target.nodeType === 1) target.setAttribute(marker, '1')
2125
return kind
2226
}
2327

28+
function isEditable(node) {
29+
if (!node || node.nodeType !== 1) return false
30+
if (node.isContentEditable) return true
31+
const t = node.tagName
32+
if (t !== 'INPUT' && t !== 'TEXTAREA') return false
33+
if (node.disabled || node.readOnly) return false
34+
return t !== 'INPUT' || NON_TEXT.indexOf((node.type || 'text').toLowerCase()) === -1
35+
}
36+
2437
if (!el || el.nodeType !== 1) return mark(kinds.STANDARD, el)
2538

2639
const tag = el.tagName
@@ -33,6 +46,10 @@ function detectAndMark(el, opts) {
3346
if (style.display === 'none') return mark(kinds.UNREACHABLE, el)
3447
}
3548

49+
if (el.getAttribute('role') === 'combobox' || (!isEditable(el) && el.hasAttribute('aria-haspopup'))) {
50+
return mark(kinds.COMBOBOX, null)
51+
}
52+
3653
const canSearchDescendants = tag !== 'INPUT' && tag !== 'TEXTAREA'
3754
if (canSearchDescendants) {
3855
const iframe = el.querySelector('iframe')
@@ -136,9 +153,12 @@ async function clearMarker(helper) {
136153
return helper.executeScript(unmarkAll, MARKER)
137154
}
138155

139-
export async function fillRichEditor(helper, el, value) {
156+
export async function detectFillTarget(helper, el) {
140157
const source = el instanceof WebElement ? el : new WebElement(el, helper)
141-
const kind = await source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR })
158+
return source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR })
159+
}
160+
161+
export async function fillRichEditor(helper, kind, value) {
142162
if (kind === EDITOR.STANDARD) return false
143163
if (kind === EDITOR.UNREACHABLE) {
144164
throw new Error('fillField: cannot fill a display:none form control. Locator must point at the visible editor surface (a wrapper, iframe, or contenteditable).')

lib/locator.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -615,8 +615,8 @@ Locator.field = {
615615
*/
616616
labelEquals: literal =>
617617
xpathLocator.combine([
618-
`.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][((./@name = ${literal}) or ./@id = //label[@for][normalize-space(string(.)) = ${literal}]/@for or ./@placeholder = ${literal})]`,
619-
`.//label[normalize-space(string(.)) = ${literal}]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
618+
`.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][((./@name = ${literal}) or ./@id = //label[@for][normalize-space(string(.)) = ${literal}]/@for or ./@placeholder = ${literal})]`,
619+
`.//label[normalize-space(string(.)) = ${literal}]//.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
620620
]),
621621

622622
/**
@@ -625,8 +625,8 @@ Locator.field = {
625625
*/
626626
labelContains: literal =>
627627
xpathLocator.combine([
628-
`.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`,
629-
`.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
628+
`.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`,
629+
`.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
630630
`.//*[@aria-label = ${literal}]`,
631631
`.//*[@title = ${literal}]`,
632632
`.//*[@aria-labelledby][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`,
@@ -636,16 +636,16 @@ Locator.field = {
636636
* @param {string} literal
637637
* @returns {string}
638638
*/
639-
byName: literal => `.//*[self::input | self::textarea | self::select][@name = ${literal}]`,
639+
byName: literal => `.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][@name = ${literal}]`,
640640

641641
/**
642642
* @param {string} literal
643643
* @returns {string}
644644
*/
645645
byText: literal =>
646646
xpathLocator.combine([
647-
`.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`,
648-
`.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
647+
`.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`,
648+
`.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
649649
]),
650650
}
651651

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Comboboxes</title>
6+
</head>
7+
<body>
8+
<h1>Comboboxes</h1>
9+
<p>Test pages for fillField against combobox widgets.</p>
10+
<ul>
11+
<li><a href="/form/combobox/baseui">Base UI</a> — button[role=combobox], input inside popup</li>
12+
<li><a href="/form/combobox/baseui-inline">Base UI (inline)</a> — input[role=combobox], typed directly</li>
13+
<li><a href="/form/custom_select">Custom select</a> — div[role=combobox], no text input</li>
14+
</ul>
15+
</body>
16+
</html>

0 commit comments

Comments
 (0)