Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ I.uncheckOption('Subscribe')

> [selectOption](/web-api#iselectoption) works with native `<select>` elements as well as custom components using `role="combobox"` or `role="listbox"`.

> [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:
>
> ```js
> I.fillField('Country', 'Ukr')
> I.click('Ukraine')
> ```

### Assertions

CodeceptJS provides **built-in browser assertions** instead of generic `expect()` calls. This keeps tests readable and produces clear failure messages without extra assertion libraries.
Expand Down
12 changes: 10 additions & 2 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import { findByPlaywrightLocator } from './extras/PlaywrightLocator.js'
import { dropFile } from './scripts/dropFile.js'
import WebElement from '../element/WebElement.js'
import { selectElement } from './extras/elementSelection.js'
import { fillRichEditor } from './extras/richTextEditor.js'
import { detectFillTarget, fillRichEditor, FILL_TARGET } from './extras/richTextEditor.js'
import { fillComboBox } from './extras/comboBox.js'

let playwright
let perfTiming
Expand Down Expand Up @@ -2282,7 +2283,14 @@ class Playwright extends Helper {

await highlightActiveElement.call(this, el)

if (await fillRichEditor(this, el, value)) {
const fillTarget = await detectFillTarget(this, el)

if (fillTarget === FILL_TARGET.COMBOBOX) {
await fillComboBox(this, el, value)
return this._waitForAction()
}

if (await fillRichEditor(this, fillTarget, value)) {
return this._waitForAction()
}

Expand Down
12 changes: 10 additions & 2 deletions lib/helper/Puppeteer.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElem
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
import WebElement from '../element/WebElement.js'
import { selectElement } from './extras/elementSelection.js'
import { fillRichEditor } from './extras/richTextEditor.js'
import { detectFillTarget, fillRichEditor, FILL_TARGET } from './extras/richTextEditor.js'
import { fillComboBox } from './extras/comboBox.js'

let puppeteer

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

if (await fillRichEditor(this, el, value)) {
const fillTarget = await detectFillTarget(this, el)

if (fillTarget === FILL_TARGET.COMBOBOX) {
await fillComboBox(this, el, value)
return this._waitForAction()
}

if (await fillRichEditor(this, fillTarget, value)) {
highlightActiveElement.call(this, el, await this._getContext())
return this._waitForAction()
}
Expand Down
15 changes: 12 additions & 3 deletions lib/helper/WebDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import { dropFile } from './scripts/dropFile.js'
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
import WebElement from '../element/WebElement.js'
import { selectElement } from './extras/elementSelection.js'
import { fillRichEditor } from './extras/richTextEditor.js'
import { detectFillTarget, fillRichEditor, FILL_TARGET } from './extras/richTextEditor.js'
import { fillComboBox } from './extras/comboBox.js'

const SHADOW = 'shadow'
const webRoot = 'body'
Expand Down Expand Up @@ -1264,8 +1265,16 @@ class WebDriver extends Helper {
const elem = selectElement(res, field, this)
highlightActiveElement.call(this, elem)

if (this.isWeb !== false && await fillRichEditor(this, elem, value)) {
return
if (this.isWeb !== false) {
const fillTarget = await detectFillTarget(this, elem)

if (fillTarget === FILL_TARGET.COMBOBOX) {
return fillComboBox(this, elem, value)
}

if (await fillRichEditor(this, fillTarget, value)) {
return
}
}

try {
Expand Down
136 changes: 136 additions & 0 deletions lib/helper/extras/comboBox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import WebElement from '../../element/WebElement.js'

const MARKER = 'data-codeceptjs-combobox-target'
const PREVIOUS = 'data-codeceptjs-combobox-previous'
const OPEN_TIMEOUT = 1000
const POLL_INTERVAL = 50

function locateInput(el, opts) {
const marker = opts.marker
const previous = opts.previous
const doc = el.ownerDocument
const NON_TEXT = ['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden']
const SELECTOR = 'input, textarea, [contenteditable="true"], [contenteditable=""]'

function isEditable(node) {
if (!node || node.nodeType !== 1) return false
if (node.isContentEditable) return true
const t = node.tagName
if (t !== 'INPUT' && t !== 'TEXTAREA') return false
if (node.disabled || node.readOnly) return false
return t !== 'INPUT' || NON_TEXT.indexOf((node.type || 'text').toLowerCase()) === -1
}

function isVisible(node) {
return node.getClientRects().length > 0
}

function mark(node) {
doc.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
node.setAttribute(marker, '1')
let name = node.tagName.toLowerCase()
if (node.id) name += '#' + node.id
if (node.placeholder) name += '[placeholder="' + node.placeholder + '"]'
return name
}

function pick(root) {
const active = doc.activeElement
if (active && active !== el && !active.hasAttribute(previous) && root.contains(active) && isEditable(active)) return active
const nodes = root.querySelectorAll(SELECTOR)
for (let i = 0; i < nodes.length; i++) {
if (isEditable(nodes[i]) && isVisible(nodes[i])) return nodes[i]
}
return null
}

if (opts.tagActive) {
doc.querySelectorAll('[' + previous + ']').forEach(n => n.removeAttribute(previous))
if (doc.activeElement && doc.activeElement.nodeType === 1) doc.activeElement.setAttribute(previous, '1')
}

if (isEditable(el)) return mark(el)

const inner = pick(el)
if (inner) return mark(inner)

const roots = []
const controlled = el.getAttribute('aria-controls') || el.getAttribute('aria-owns')
if (controlled && doc.getElementById(controlled)) roots.push(doc.getElementById(controlled))
doc.querySelectorAll('[role="dialog"], [role="listbox"]').forEach(n => {
if (isVisible(n)) roots.push(n)
})

for (let i = 0; i < roots.length; i++) {
const found = pick(roots[i])
if (found) return mark(found)
}

if (!opts.allowActive) return null

let active = doc.activeElement
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement
if (active && active !== el && !active.hasAttribute(previous) && isEditable(active) && isVisible(active)) return mark(active)

return null
}

function readValue(el) {
return el.value === undefined ? el.textContent : el.value
}

function isActive(el) {
return el.ownerDocument.activeElement === el
}

function unmarkAll(markers) {
markers.forEach(marker => document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)))
}

async function findMarked(helper) {
const root = helper.page || helper.browser
const raw = await root.$('[' + MARKER + ']')
return new WebElement(raw, helper)
}

async function clearMarker(helper) {
if (helper.page) return helper.page.evaluate(unmarkAll, [MARKER, PREVIOUS])
return helper.executeScript(unmarkAll, [MARKER, PREVIOUS])
}

export async function fillComboBox(helper, el, value) {
const trigger = el instanceof WebElement ? el : new WebElement(el, helper)
const options = { marker: MARKER, previous: PREVIOUS }
let found = await trigger.evaluate(locateInput, { ...options, tagActive: true })

if (!found) {
if ((await trigger.getAttribute('aria-expanded')) !== 'true') {
helper.debugSection('ComboBox', 'Expanding combobox')
await trigger.click()
}
const deadline = Date.now() + OPEN_TIMEOUT
while (!found && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL))
found = await trigger.evaluate(locateInput, { ...options, allowActive: true })
}
}

if (!found) {
await clearMarker(helper)
throw new Error('fillField: combobox exposes no text input to type into. Use I.selectOption() to pick one of its options.')
}

const target = await findMarked(helper)
helper.debugSection('ComboBox', `Typing into ${found}`)

await target.focus()
if (!(await target.evaluate(isActive))) await target.click()
if (!(await target.evaluate(isActive))) {
throw new Error(`fillField: combobox input ${found} did not accept focus.`)
}

if (await target.evaluate(readValue)) await target.selectAllAndDelete()
await target.typeText(value, { delay: helper.options.pressKeyDelay })

await clearMarker(helper)
}
24 changes: 22 additions & 2 deletions lib/helper/extras/richTextEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,33 @@ const EDITOR = {
IFRAME: 'iframe',
CONTENTEDITABLE: 'contenteditable',
HIDDEN_TEXTAREA: 'hidden-textarea',
COMBOBOX: 'combobox',
UNREACHABLE: 'unreachable',
}

export const FILL_TARGET = EDITOR

function detectAndMark(el, opts) {
const marker = opts.marker
const kinds = opts.kinds
const CE = '[contenteditable="true"], [contenteditable=""]'
const NON_TEXT = ['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden']

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

function isEditable(node) {
if (!node || node.nodeType !== 1) return false
if (node.isContentEditable) return true
const t = node.tagName
if (t !== 'INPUT' && t !== 'TEXTAREA') return false
if (node.disabled || node.readOnly) return false
return t !== 'INPUT' || NON_TEXT.indexOf((node.type || 'text').toLowerCase()) === -1
}

if (!el || el.nodeType !== 1) return mark(kinds.STANDARD, el)

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

if (el.getAttribute('role') === 'combobox' || (!isEditable(el) && el.hasAttribute('aria-haspopup'))) {
return mark(kinds.COMBOBOX, null)
}

const canSearchDescendants = tag !== 'INPUT' && tag !== 'TEXTAREA'
if (canSearchDescendants) {
const iframe = el.querySelector('iframe')
Expand Down Expand Up @@ -136,9 +153,12 @@ async function clearMarker(helper) {
return helper.executeScript(unmarkAll, MARKER)
}

export async function fillRichEditor(helper, el, value) {
export async function detectFillTarget(helper, el) {
const source = el instanceof WebElement ? el : new WebElement(el, helper)
const kind = await source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR })
return source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR })
}

export async function fillRichEditor(helper, kind, value) {
if (kind === EDITOR.STANDARD) return false
if (kind === EDITOR.UNREACHABLE) {
throw new Error('fillField: cannot fill a display:none form control. Locator must point at the visible editor surface (a wrapper, iframe, or contenteditable).')
Expand Down
14 changes: 7 additions & 7 deletions lib/locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,8 +615,8 @@ Locator.field = {
*/
labelEquals: literal =>
xpathLocator.combine([
`.//*[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})]`,
`.//label[normalize-space(string(.)) = ${literal}]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
`.//*[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})]`,
`.//label[normalize-space(string(.)) = ${literal}]//.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
]),

/**
Expand All @@ -625,8 +625,8 @@ Locator.field = {
*/
labelContains: literal =>
xpathLocator.combine([
`.//*[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})]`,
`.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
`.//*[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})]`,
`.//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')]`,
`.//*[@aria-label = ${literal}]`,
`.//*[@title = ${literal}]`,
`.//*[@aria-labelledby][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`,
Expand All @@ -636,16 +636,16 @@ Locator.field = {
* @param {string} literal
* @returns {string}
*/
byName: literal => `.//*[self::input | self::textarea | self::select][@name = ${literal}]`,
byName: literal => `.//*[self::input | self::textarea | self::select][not(./@aria-hidden = 'true')][@name = ${literal}]`,

/**
* @param {string} literal
* @returns {string}
*/
byText: literal =>
xpathLocator.combine([
`.//*[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})]`,
`.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
`.//*[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})]`,
`.//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')]`,
]),
}

Expand Down
16 changes: 16 additions & 0 deletions test/data/app/view/form/combobox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Comboboxes</title>
</head>
<body>
<h1>Comboboxes</h1>
<p>Test pages for fillField against combobox widgets.</p>
<ul>
<li><a href="/form/combobox/baseui">Base UI</a> — button[role=combobox], input inside popup</li>
<li><a href="/form/combobox/baseui-inline">Base UI (inline)</a> — input[role=combobox], typed directly</li>
<li><a href="/form/custom_select">Custom select</a> — div[role=combobox], no text input</li>
</ul>
</body>
</html>
Loading