Skip to content

Commit 7d1791d

Browse files
author
DavertMik
committed
better combobox/lisbox support for selectOption
1 parent 124aaf3 commit 7d1791d

File tree

12 files changed

+610
-111
lines changed

12 files changed

+610
-111
lines changed

CLAUDE.md

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,31 @@
1-
# CodeceptJS ESM Migration Plan
1+
# CodeceptJS
22

3-
This project is migrating from 3.x to ESM replacing all require() calls with imports.
3+
CodeceptJS is a full-featured End-to-End testing framework for web and native apps. It is built on top of WebDriverIO, Appium, and Puppeteer, and provides a simple and intuitive API for writing tests.
44

5-
## Compare with Reference
5+
## Rules
66

7-
Each time unsure what to do refer to the same file implementation in **3.x branch**
8-
3.x branch contains stable version of all core classes that work
7+
## Testing with Shell Command
98

10-
## Running Tests
11-
12-
Focus on acceptance tests:
13-
14-
For instance this helps to understand if final migration is ok:
9+
To quickly test CodeceptJS features without writing full test files, use the shell command with a script file:
1510

11+
1. Create a temporary script file (e.g., `/tmp/test-script.js`):
12+
```javascript
13+
I.amOnPage('https://example.com')
14+
I.click('Login')
15+
I.fillField('Email', '[email protected]')
1616
```
17-
DEBUG="codeceptjs:*" ./bin/codecept.js run --config test/acceptance/codecept.Playwright.js --verbose
18-
19-
DEBUG="codeceptjs:*" ./bin/codecept.js run --config test/acceptance/codecept.Playwright.js --debug --grep within
20-
```
21-
22-
Do not say: it works befire running specific acceptance test.
23-
Tests may stuck so always run them with timeout call:
2417

18+
2. Run it via shell command:
19+
```bash
20+
./bin/codecept.js shell --file /tmp/test-script.js -c examples
2521
```
26-
timeout=30000 ./bin/codecept.js run --config test/acceptance/codecept.Playwright.js --verbose
27-
```
28-
29-
Web Server for this tests are running on port 8000 and it works but responds 500 for HEAD requests.
30-
31-
## Princinples
32-
33-
All unit tests for Playwright/Puppeteer/WebdriverIO are passing. But acceptance tests are failing due to promises composition. Complexity of promises composition comes from promise chaining and async/await syntax. And `session` mechanism from `promise.js` which allows spawning different promises chains and sync them up in the end.
34-
35-
The full test suite extensively uses plugins so refer to plugins and corresponding helpers as well
3622

37-
We are building test framework. So even passing ests are not indicator of successful usage. Side effects like: timeouts, bad output, errors, memory leaks, etc are affecting performance.
38-
For instance, if a tests stuck and won't finish this is very bad.
23+
3. Check the output for errors or success messages.
3924

40-
## Coding Style
25+
Notes:
26+
- Scripts use the global `I` object directly (no imports needed)
27+
- Commands don't need `await` - they queue automatically via the recorder
28+
- Use the `examples/` config which has Playwright helper configured
29+
- The shell initializes the browser and executes commands in sequence
4130

42-
- Never add comments unless explicitly required.
43-
- DO not mention plugins or helpers inside core classes
31+
## Helpers

bin/codecept.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ program
9191
.option(commandFlags.profile.flag, commandFlags.profile.description)
9292
.option(commandFlags.ai.flag, commandFlags.ai.description)
9393
.option(commandFlags.config.flag, commandFlags.config.description)
94-
.action(commandHandler('../lib/command/interactive.js'))
94+
.option('--file [path]', 'JavaScript file to execute in shell context')
95+
.action(commandHandler('../lib/command/shell.js'))
9596

9697
program.command('list [path]').alias('l').description('List all actions for I.').action(commandHandler('../lib/command/list.js'))
9798

examples/codecept.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ export const config = {
77
url: 'http://github.com',
88
browser: 'chromium',
99
// restart: 'context',
10-
// show: false,
10+
show: false,
1111
// timeout: 5000,
1212
windowSize: '1600x1200',
1313
// video: true,
1414
chromium: {
1515
// browserWSEndpoint: 'ws://127.0.0.1:45635/09b7aa1ac28c317e5abee7cb6d35d519',
1616
},
17-
show: !process.env.HEADLESS,
17+
// show: !process.env.HEADLESS,
1818
},
1919
REST: {},
2020
User: {
Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ import Container from '../container.js'
55
import event from '../event.js'
66
import pause from '../pause.js'
77
import output from '../output.js'
8+
import { fileURLToPath } from 'url'
9+
import { createRequire } from 'module'
10+
import path from 'path'
11+
12+
const require = createRequire(import.meta.url)
13+
const __filename = fileURLToPath(import.meta.url)
14+
const __dirname = path.dirname(__filename)
15+
816
const webHelpers = Container.STANDARD_ACTING_HELPERS
917

10-
export default async function (path, options) {
18+
export default async function (shellPath, options) {
1119
// Backward compatibility for --profile
1220
process.profile = options.profile
1321
process.env.profile = options.profile
@@ -17,7 +25,7 @@ export default async function (path, options) {
1725
const testsPath = getTestRoot(configFile)
1826

1927
const codecept = new Codecept(config, options)
20-
codecept.init(testsPath)
28+
await codecept.init(testsPath)
2129

2230
try {
2331
await codecept.bootstrap()
@@ -53,7 +61,27 @@ export default async function (path, options) {
5361
break
5462
}
5563
}
56-
pause()
64+
65+
if (options.file) {
66+
const scriptPath = path.resolve(options.file)
67+
output.print(`Executing script: ${scriptPath}`)
68+
69+
// Use the same I actor that pause() uses
70+
const I = Container.support('I')
71+
global.I = I
72+
globalThis.I = I
73+
74+
recorder.add('execute script', async () => {
75+
try {
76+
await import(scriptPath)
77+
output.print('Script executed successfully')
78+
} catch (err) {
79+
output.error(`Error executing script: ${err.message}`)
80+
}
81+
})
82+
} else {
83+
pause()
84+
}
5785
recorder.add(() => event.emit(event.test.after, {}))
5886
recorder.add(() => event.emit(event.suite.after, {}))
5987
recorder.add(() => event.emit(event.all.result, {}))

lib/helper/Playwright.js

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2489,35 +2489,30 @@ class Playwright extends Helper {
24892489
* {{> selectOption }}
24902490
*/
24912491
async selectOption(select, option) {
2492-
const selectLocator = Locator.from(select, 'css')
2493-
let els = null
2492+
const context = await this.context
2493+
const matchedLocator = new Locator(select)
24942494

2495-
if (selectLocator.isFuzzy()) {
2496-
els = await findByRole(this.page, { role: 'listbox', name: selectLocator.value })
2497-
if (!els || els.length === 0) {
2498-
els = await findByRole(this.page, { role: 'combobox', name: selectLocator.value })
2499-
}
2500-
}
2501-
2502-
if (!els || els.length === 0) {
2503-
els = await findFields.call(this, select)
2495+
// Strict locator
2496+
if (!matchedLocator.isFuzzy()) {
2497+
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
2498+
const els = await this._locate(matchedLocator)
2499+
assertElementExists(els, select, 'Selectable element')
2500+
return proceedSelect.call(this, context, els[0], option)
25042501
}
2505-
assertElementExists(els, select, 'Selectable field')
2506-
const el = els[0]
2507-
2508-
await highlightActiveElement.call(this, el)
2509-
let optionToSelect = ''
25102502

2511-
try {
2512-
optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
2513-
} catch (e) {
2514-
optionToSelect = option
2515-
}
2503+
// Fuzzy: try combobox
2504+
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
2505+
let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value })
2506+
if (els?.length) return proceedSelect.call(this, context, els[0], option)
25162507

2517-
if (!Array.isArray(option)) option = [optionToSelect]
2508+
// Fuzzy: try listbox
2509+
els = await findByRole(context, { role: 'listbox', name: matchedLocator.value })
2510+
if (els?.length) return proceedSelect.call(this, context, els[0], option)
25182511

2519-
await el.selectOption(option)
2520-
return this._waitForAction()
2512+
// Fuzzy: try native select
2513+
els = await findFields.call(this, select)
2514+
assertElementExists(els, select, 'Selectable element')
2515+
return proceedSelect.call(this, context, els[0], option)
25212516
}
25222517

25232518
/**
@@ -4649,6 +4644,45 @@ async function findFields(locator) {
46494644
return this._locate({ css: locator })
46504645
}
46514646

4647+
async function proceedSelect(context, el, option) {
4648+
const role = await el.getAttribute('role')
4649+
const options = Array.isArray(option) ? option : [option]
4650+
4651+
if (role === 'combobox') {
4652+
this.debugSection('SelectOption', 'Expanding combobox')
4653+
await highlightActiveElement.call(this, el)
4654+
const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')])
4655+
await el.click()
4656+
await this._waitForAction()
4657+
4658+
const listboxId = ariaOwns || ariaControls
4659+
let listbox = listboxId ? context.locator(`#${listboxId}`).first() : null
4660+
if (!listbox || !(await listbox.count())) listbox = context.getByRole('listbox').first()
4661+
4662+
for (const opt of options) {
4663+
const optEl = listbox.getByRole('option', { name: opt }).first()
4664+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
4665+
await highlightActiveElement.call(this, optEl)
4666+
await optEl.click()
4667+
}
4668+
return this._waitForAction()
4669+
}
4670+
4671+
if (role === 'listbox') {
4672+
for (const opt of options) {
4673+
const optEl = el.getByRole('option', { name: opt }).first()
4674+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
4675+
await highlightActiveElement.call(this, optEl)
4676+
await optEl.click()
4677+
}
4678+
return this._waitForAction()
4679+
}
4680+
4681+
await highlightActiveElement.call(this, el)
4682+
await el.selectOption(option)
4683+
return this._waitForAction()
4684+
}
4685+
46524686
async function proceedSeeInField(assertType, field, value) {
46534687
const els = await findFields.call(this, field)
46544688
assertElementExists(els, field, 'Field')

lib/helper/Puppeteer.js

Lines changed: 106 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(matchedLocator)
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.call(this, 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.call(this, 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+
els = await findVisibleFields.call(this, select)
1642+
assertElementExists(els, select, 'Selectable element')
1643+
return proceedSelect.call(this, context, els[0], option)
16471644
}
16481645

16491646
/**
@@ -3160,6 +3157,90 @@ async function findFields(locator) {
31603157
return this._locate({ css: matchedLocator.value })
31613158
}
31623159

3160+
async function proceedSelect(context, el, option) {
3161+
const role = await el.evaluate(e => e.getAttribute('role'))
3162+
const options = Array.isArray(option) ? option : [option]
3163+
3164+
if (role === 'combobox') {
3165+
this.debugSection('SelectOption', 'Expanding combobox')
3166+
highlightActiveElement.call(this, el, context)
3167+
const [ariaOwns, ariaControls] = await el.evaluate(e => [e.getAttribute('aria-owns'), e.getAttribute('aria-controls')])
3168+
await el.click()
3169+
await this._waitForAction()
3170+
3171+
const listboxId = ariaOwns || ariaControls
3172+
let listbox = listboxId ? await context.$(`#${listboxId}`) : null
3173+
if (!listbox) {
3174+
const listboxes = await context.$$('::-p-aria([role="listbox"])')
3175+
listbox = listboxes[0]
3176+
}
3177+
if (!listbox) throw new Error('Cannot find listbox for combobox')
3178+
3179+
for (const opt of options) {
3180+
const optionEls = await listbox.$$('::-p-aria([role="option"])')
3181+
let optEl = null
3182+
for (const optionEl of optionEls) {
3183+
const text = await optionEl.evaluate(e => e.textContent.trim())
3184+
if (text === opt || text.includes(opt)) {
3185+
optEl = optionEl
3186+
break
3187+
}
3188+
}
3189+
if (!optEl) throw new Error(`Cannot find option "${opt}" in listbox`)
3190+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3191+
highlightActiveElement.call(this, optEl, context)
3192+
await optEl.click()
3193+
}
3194+
return this._waitForAction()
3195+
}
3196+
3197+
if (role === 'listbox') {
3198+
highlightActiveElement.call(this, el, context)
3199+
for (const opt of options) {
3200+
const optionEls = await el.$$('::-p-aria([role="option"])')
3201+
let optEl = null
3202+
for (const optionEl of optionEls) {
3203+
const text = await optionEl.evaluate(e => e.textContent.trim())
3204+
if (text === opt || text.includes(opt)) {
3205+
optEl = optionEl
3206+
break
3207+
}
3208+
}
3209+
if (!optEl) throw new Error(`Cannot find option "${opt}" in listbox`)
3210+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3211+
highlightActiveElement.call(this, optEl, context)
3212+
await optEl.click()
3213+
}
3214+
return this._waitForAction()
3215+
}
3216+
3217+
// Native <select> element
3218+
const tagName = await el.evaluate(e => e.tagName)
3219+
if (tagName !== 'SELECT') {
3220+
throw new Error('Element is not <select>')
3221+
}
3222+
3223+
highlightActiveElement.call(this, el, context)
3224+
for (const key in options) {
3225+
const opt = xpathLocator.literal(options[key])
3226+
let optEl = await findElements.call(this, el, { xpath: Locator.select.byVisibleText(opt) })
3227+
if (optEl.length) {
3228+
this._evaluateHandeInContext(e => (e.selected = true), optEl[0])
3229+
continue
3230+
}
3231+
optEl = await findElements.call(this, el, { xpath: Locator.select.byValue(opt) })
3232+
if (optEl.length) {
3233+
this._evaluateHandeInContext(e => (e.selected = true), optEl[0])
3234+
}
3235+
}
3236+
await this._evaluateHandeInContext(element => {
3237+
element.dispatchEvent(new Event('input', { bubbles: true }))
3238+
element.dispatchEvent(new Event('change', { bubbles: true }))
3239+
}, el)
3240+
3241+
return this._waitForAction()
3242+
}
3243+
31633244
async function proceedDragAndDrop(sourceLocator, destinationLocator) {
31643245
const src = await this._locateElement(sourceLocator)
31653246
if (!src) {

0 commit comments

Comments
 (0)