codeceptjs 4.2.0-beta.1 → 4.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/advanced.md CHANGED
@@ -79,7 +79,7 @@ Data(function*() {
79
79
  }).Scenario() // ...
80
80
  ```
81
81
 
82
- *HINT: If you don't use DataTable. add `toString()` method to each object added to data set, so the data could be pretty printed in a test name*
82
+ Objects in a data set are serialized as JSON in the test name.
83
83
 
84
84
 
85
85
  ## Debug
@@ -28,9 +28,9 @@ process lifecycle, the same way Playwright manages its own browser process.
28
28
  never spawns or kills anything, no matter what `binaryPath`/`port` are set to.
29
29
  * **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
30
30
  the config, then the `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The helper
31
- spawns `obscura serve --port <port> --allow-private-network` (`port` from the config, or a
32
- free port picked automatically), waits for it to answer, connects, and kills it in
33
- `_finishTest`.
31
+ spawns `obscura serve --port <port> --allow-private-network --allow-file-access` (`port` from
32
+ the config, or a free port picked automatically), waits for it to answer, connects, and kills
33
+ it in `_finishTest`.
34
34
  * **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
35
35
  answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
36
36
  before this process ever ran). The helper attaches to it and never kills it — it isn't the
@@ -43,12 +43,13 @@ Download a release binary and put it on your `PATH` (or point `binaryPath`/`OBSC
43
43
  it directly) and the helper launches and tears it down for you automatically:
44
44
 
45
45
  ```sh
46
- curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.0/obscura-x86_64-linux.tar.gz | tar xz
46
+ curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.2/obscura-x86_64-linux.tar.gz | tar xz
47
47
  ```
48
48
 
49
- `--allow-private-network` is always passed by this helper (it's required to reach apps running
50
- on `localhost`/private IPs, e.g. a dev server on `127.0.0.1:8000` — Obscura blocks
51
- private-network requests by default).
49
+ `--allow-private-network` and `--allow-file-access` are always passed by this helper: the first
50
+ is required to reach apps running on `localhost`/private IPs, e.g. a dev server on
51
+ `127.0.0.1:8000`, the second to let `attachFile` upload local files. Obscura blocks both by
52
+ default.
52
53
 
53
54
  ## Config presets
54
55
 
@@ -67,7 +68,7 @@ Set them explicitly in your own config to skip probing or to force a mode.
67
68
  ## Limitations
68
69
 
69
70
  * `input` is always `synthetic`, even on rendering builds — see `input` above.
70
- * No frames, popups, or file uploads.
71
+ * No frames or popups.
71
72
  * On `-no-render` builds and v0.1.x: no screenshots, no visibility assertions
72
73
  (`seeElement`/`dontSeeElement` always throw) — only DOM presence
73
74
  (`seeElementInDOM`/`dontSeeElementInDOM`) is meaningful without a layout engine.
@@ -78,8 +78,9 @@ Type: [object][6]
78
78
  * `ignoreHTTPSErrors` **[boolean][27]?** Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
79
79
  * `bypassCSP` **[boolean][27]?** bypass Content Security Policy or CSP
80
80
  * `highlightElement` **[boolean][27]?** highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
81
+ * `visibleLocator` **[boolean][27]?** append [`visible()`][49] to locators, so only visible elements are matched. Requires Playwright 1.63 or newer. Switch it off for a single step with `stepOpts({ visibleLocator: false })`. Not applied to `dragAndDrop`, which passes selectors to Playwright directly, nor to steps that must reach hidden elements: `grab*` methods, `scrollTo`, `seeElementInDOM`, `dontSeeElementInDOM` and `seeNumberOfElements`. When enabled, a locator matching only hidden elements fails as "element not found" instead of timing out on actionability, `strict` mode ignores hidden duplicates, and elements hidden by CSS (like a custom checkbox built on a visually hidden `input`) are no longer found.
81
82
  * `recordHar` **[object][6]?** record HAR and will be saved to `output/har`. See more of [HAR options][3].
82
- * `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][49].
83
+ * `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][50].
83
84
  * `storageState` **([string][9] | [object][6])?** Playwright storage state (path to JSON file or object)
84
85
  passed directly to `browser.newContext`.
85
86
  If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`),
@@ -2967,4 +2968,6 @@ Returns **void** automatically synchronized promise through #recorder
2967
2968
 
2968
2969
  [48]: https://playwright.dev/docs/api/class-consolemessage#console-message-type
2969
2970
 
2970
- [49]: https://playwright.dev/docs/locators#locate-by-test-id
2971
+ [49]: https://playwright.dev/docs/api/class-locator#locator-visible
2972
+
2973
+ [50]: https://playwright.dev/docs/locators#locate-by-test-id
@@ -21,9 +21,16 @@ function parsePlaywrightBrowsers(output) {
21
21
  return versions.join(', ')
22
22
  }
23
23
 
24
+ // Bun has its own package runner and a Bun-only install has no `npx` on PATH at all, so the
25
+ // runner has to follow the runtime that is actually executing rather than what PATH happens to hold.
26
+ function getPackageRunner() {
27
+ if (process.versions.bun) return 'bunx'
28
+ return 'npx'
29
+ }
30
+
24
31
  async function getPlaywrightBrowsers() {
25
32
  try {
26
- const info = execSync('npx playwright install --dry-run').toString().trim()
33
+ const info = execSync(`${getPackageRunner()} playwright install --dry-run`).toString().trim()
27
34
  return parsePlaywrightBrowsers(info)
28
35
  } catch (err) {
29
36
  return 'Playwright not installed'
@@ -85,7 +92,7 @@ export default async function (path) {
85
92
  output.print('***************************************')
86
93
  }
87
94
 
88
- export { parsePlaywrightBrowsers, getRuntimeInfo }
95
+ export { parsePlaywrightBrowsers, getRuntimeInfo, getPackageRunner }
89
96
 
90
97
  export const getMachineInfo = async () => {
91
98
  const info = {
@@ -72,13 +72,11 @@ function replaceTitle(title, dataRow) {
72
72
  return `${title} | ${dataRow.data.getMasked()}`
73
73
  }
74
74
 
75
- // if `dataRow` is object and has own `toString()` method,
76
- // it should be printed
77
- if (Object.prototype.toString.call(dataRow.data) === Object().toString() && dataRow.data.toString() !== Object().toString()) {
78
- return `${title} | ${dataRow.data}`
79
- }
75
+ return `${title} | ${JSON.stringify(dataRow.data, maskSecret)}`
76
+ }
80
77
 
81
- return `${title} | ${JSON.stringify(dataRow.data)}`
78
+ function maskSecret(key, value) {
79
+ return typeof value?.getMasked === 'function' ? value.getMasked() : value
82
80
  }
83
81
 
84
82
  function isTableDataRow(row) {
@@ -1631,6 +1631,9 @@ class CDPBrowser extends Helper {
1631
1631
  const value = Array.isArray(option) ? option.map(String) : String(option)
1632
1632
  const res = await this._run(this._candidates(select, 'field'), 'select', { value }, context)
1633
1633
  if (!res.found) throw new ElementNotFound(select, 'Selectable field')
1634
+ if (res.result === '__RADIOGROUP_MULTI__') {
1635
+ throw new Error(`selectOption: a radio group holds one value, but ${value.length} options were passed: ${value.join(', ')}`)
1636
+ }
1634
1637
  if (res.result === false) throw new Error(`Option "${Array.isArray(option) ? option.join(',') : option}" not found in ${new Locator(select).toString()}`)
1635
1638
  }
1636
1639
 
@@ -1810,7 +1813,6 @@ class CDPBrowser extends Helper {
1810
1813
  */
1811
1814
  async waitInUrl(urlPart, sec = null) {
1812
1815
  const timeout = sec || this.options.waitForTimeout
1813
- const expectedUrl = resolveUrl(urlPart, this.options.url)
1814
1816
  let lastUrl = ''
1815
1817
  try {
1816
1818
  return await this._poll(
@@ -1822,7 +1824,7 @@ class CDPBrowser extends Helper {
1822
1824
  'placeholder',
1823
1825
  )
1824
1826
  } catch (e) {
1825
- throw new Error(`expected url to include ${expectedUrl}, but found ${lastUrl}`)
1827
+ throw new Error(`expected url to include ${urlPart}, but found ${lastUrl}`)
1826
1828
  }
1827
1829
  }
1828
1830
 
@@ -45,9 +45,9 @@ const config = {}
45
45
  * never spawns or kills anything, no matter what `binaryPath`/`port` are set to.
46
46
  * - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
47
47
  * the config, then the `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The helper
48
- * spawns `obscura serve --port <port> --allow-private-network` (`port` from the config, or a
49
- * free port picked automatically), waits for it to answer, connects, and kills it in
50
- * `_finishTest`.
48
+ * spawns `obscura serve --port <port> --allow-private-network --allow-file-access` (`port` from
49
+ * the config, or a free port picked automatically), waits for it to answer, connects, and kills
50
+ * it in `_finishTest`.
51
51
  * - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
52
52
  * answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
53
53
  * before this process ever ran). The helper attaches to it and never kills it — it isn't the
@@ -60,12 +60,13 @@ const config = {}
60
60
  * it directly) and the helper launches and tears it down for you automatically:
61
61
  *
62
62
  * ```sh
63
- * curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.0/obscura-x86_64-linux.tar.gz | tar xz
63
+ * curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.2/obscura-x86_64-linux.tar.gz | tar xz
64
64
  * ```
65
65
  *
66
- * `--allow-private-network` is always passed by this helper (it's required to reach apps running
67
- * on `localhost`/private IPs, e.g. a dev server on `127.0.0.1:8000` — Obscura blocks
68
- * private-network requests by default).
66
+ * `--allow-private-network` and `--allow-file-access` are always passed by this helper: the first
67
+ * is required to reach apps running on `localhost`/private IPs, e.g. a dev server on
68
+ * `127.0.0.1:8000`, the second to let `attachFile` upload local files. Obscura blocks both by
69
+ * default.
69
70
  *
70
71
  * ## Config presets
71
72
  *
@@ -84,7 +85,7 @@ const config = {}
84
85
  * ## Limitations
85
86
  *
86
87
  * - `input` is always `synthetic`, even on rendering builds — see `input` above.
87
- * - No frames, popups, or file uploads.
88
+ * - No frames or popups.
88
89
  * - On `-no-render` builds and v0.1.x: no screenshots, no visibility assertions
89
90
  * (`seeElement`/`dontSeeElement` always throw) — only DOM presence
90
91
  * (`seeElementInDOM`/`dontSeeElementInDOM`) is meaningful without a layout engine.
@@ -179,7 +180,7 @@ class Obscura extends CDPBrowser {
179
180
  const port = this.options.port || (await this._findFreePort())
180
181
  this.options.port = port
181
182
  this.serverError = null
182
- this.serverProcess = spawn(binaryPath, ['serve', '--port', String(port), '--allow-private-network'], { stdio: 'ignore' })
183
+ this.serverProcess = spawn(binaryPath, ['serve', '--port', String(port), '--allow-private-network', '--allow-file-access'], { stdio: 'ignore' })
183
184
  this.serverProcess.on('error', err => {
184
185
  this.serverError = err
185
186
  })
@@ -50,6 +50,7 @@ let defaultSelectorEnginesInitialized = false
50
50
  const popupStore = new Popup()
51
51
  const consoleLogStore = new Console()
52
52
  const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
53
+ const visibilityAgnosticSteps = ['seeElementInDOM', 'dontSeeElementInDOM', 'seeNumberOfElements', 'scrollTo']
53
54
  const checkableRoles = ['checkbox', 'radio', 'switch']
54
55
 
55
56
  import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
@@ -102,6 +103,7 @@ const pathSeparator = path.sep
102
103
  * @prop {boolean} [ignoreHTTPSErrors] - Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
103
104
  * @prop {boolean} [bypassCSP] - bypass Content Security Policy or CSP
104
105
  * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
106
+ * @prop {boolean} [visibleLocator=false] - append [`visible()`](https://playwright.dev/docs/api/class-locator#locator-visible) to locators, so only visible elements are matched. Requires Playwright 1.63 or newer. Switch it off for a single step with `stepOpts({ visibleLocator: false })`. Not applied to `dragAndDrop`, which passes selectors to Playwright directly, nor to steps that must reach hidden elements: `grab*` methods, `scrollTo`, `seeElementInDOM`, `dontSeeElementInDOM` and `seeNumberOfElements`. When enabled, a locator matching only hidden elements fails as "element not found" instead of timing out on actionability, `strict` mode ignores hidden duplicates, and elements hidden by CSS (like a custom checkbox built on a visually hidden `input`) are no longer found.
105
107
  * @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har).
106
108
  * @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id).
107
109
  * @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
@@ -399,6 +401,7 @@ class Playwright extends Helper {
399
401
  storageState: undefined,
400
402
  onResponse: null,
401
403
  strict: false,
404
+ visibleLocator: false,
402
405
  }
403
406
 
404
407
  process.env.testIdAttribute = 'data-testid'
@@ -555,6 +558,11 @@ class Playwright extends Helper {
555
558
  }
556
559
  }
557
560
 
561
+ _beforeStep(step) {
562
+ const reachesHidden = step.helperMethod?.startsWith('grab') || visibilityAgnosticSteps.includes(step.helperMethod)
563
+ store.visibleLocator = step.opts?.visibleLocator ?? (this.options.visibleLocator && !reachesHidden)
564
+ }
565
+
558
566
  async _before(test) {
559
567
  // Skip browser operations in dry-run mode (used by check command)
560
568
  if (store.dryRun) {
@@ -1518,6 +1526,7 @@ class Playwright extends Helper {
1518
1526
  assertElementExists(el, locator)
1519
1527
  }
1520
1528
 
1529
+ await el.scrollIntoViewIfNeeded()
1521
1530
  // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
1522
1531
  const { x, y } = await clickablePoint(el)
1523
1532
  await this.page.mouse.move(x + offsetX, y + offsetY)
@@ -4197,6 +4206,14 @@ export function buildLocatorString(locator) {
4197
4206
  return locator.simplify()
4198
4207
  }
4199
4208
 
4209
+ function withVisibleLocator(locator) {
4210
+ if (!store.visibleLocator) return locator
4211
+ if (typeof locator.visible !== 'function') {
4212
+ throw new Error('visibleLocator option requires Playwright 1.63 or newer. Upgrade the playwright package or disable visibleLocator in helper config')
4213
+ }
4214
+ return locator.visible()
4215
+ }
4216
+
4200
4217
  /**
4201
4218
  * Handles role locator objects by converting them to Playwright's getByRole() API
4202
4219
  * Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects.
@@ -4212,7 +4229,7 @@ async function handleRoleLocator(context, locator) {
4212
4229
  if (roleObj.name) options.name = roleObj.name
4213
4230
  if (roleObj.exact !== undefined) options.exact = roleObj.exact
4214
4231
 
4215
- return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all()
4232
+ return withVisibleLocator(context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined)).all()
4216
4233
  }
4217
4234
 
4218
4235
  async function findByRole(context, locator) {
@@ -4220,13 +4237,13 @@ async function findByRole(context, locator) {
4220
4237
  const options = {}
4221
4238
  if (locator.name) options.name = locator.name
4222
4239
  if (locator.exact !== undefined) options.exact = locator.exact
4223
- return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4240
+ return withVisibleLocator(context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined)).all()
4224
4241
  }
4225
4242
 
4226
4243
  async function findElements(matcher, locator) {
4227
4244
  const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
4228
4245
 
4229
- if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
4246
+ if (isPwLocator) return withVisibleLocator(findByPlaywrightLocator.call(this, matcher, locator)).all()
4230
4247
 
4231
4248
  // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
4232
4249
  const roleElements = await handleRoleLocator(matcher, locator)
@@ -4236,11 +4253,11 @@ async function findElements(matcher, locator) {
4236
4253
 
4237
4254
  const locatorString = buildLocatorString(locator)
4238
4255
 
4239
- return matcher.locator(locatorString).all()
4256
+ return withVisibleLocator(matcher.locator(locatorString)).all()
4240
4257
  }
4241
4258
 
4242
4259
  async function findElement(matcher, locator) {
4243
- if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4260
+ if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator).first()
4244
4261
 
4245
4262
  locator = new Locator(locator, 'css')
4246
4263
 
@@ -4313,14 +4330,14 @@ async function findClickable(matcher, locator) {
4313
4330
  const literal = xpathLocator.literal(matchedLocator.value)
4314
4331
 
4315
4332
  try {
4316
- els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4333
+ els = await withVisibleLocator(matcher.getByRole('button', { name: matchedLocator.value })).all()
4317
4334
  if (els.length) return els
4318
4335
  } catch (err) {
4319
4336
  // getByRole not supported or failed
4320
4337
  }
4321
4338
 
4322
4339
  try {
4323
- els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4340
+ els = await withVisibleLocator(matcher.getByRole('link', { name: matchedLocator.value })).all()
4324
4341
  if (els.length) return els
4325
4342
  } catch (err) {
4326
4343
  // getByRole not supported or failed
@@ -4389,17 +4406,6 @@ async function findCheckable(locator, context) {
4389
4406
  return findElements.call(this, contextEl, matchedLocator)
4390
4407
  }
4391
4408
 
4392
- for (const exact of [true, false]) {
4393
- for (const role of checkableRoles) {
4394
- try {
4395
- const roleEls = await contextEl.getByRole(role, { name: matchedLocator.value, exact }).all()
4396
- if (roleEls.length) return roleEls
4397
- } catch (err) {
4398
- // getByRole not supported or failed
4399
- }
4400
- }
4401
- }
4402
-
4403
4409
  const literal = xpathLocator.literal(matchedLocator.value)
4404
4410
  let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
4405
4411
  if (els.length) {
@@ -64,7 +64,6 @@ function wrapError(e) {
64
64
  let perfTiming
65
65
  const popupStore = new Popup()
66
66
  const consoleLogStore = new Console()
67
- const checkableRoles = ['checkbox', 'radio', 'switch']
68
67
 
69
68
  /**
70
69
  * ## Configuration
@@ -846,6 +845,9 @@ class Puppeteer extends Helper {
846
845
  }
847
846
  }
848
847
 
848
+ if (!(await el.isIntersectingViewport({ threshold: 1 }))) {
849
+ await el.evaluate(el => el.scrollIntoView({ block: 'center', inline: 'center' }))
850
+ }
849
851
  // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
850
852
  const { x, y } = await getClickablePoint(el)
851
853
  await this.page.mouse.move(x + offsetX, y + offsetY)
@@ -3196,19 +3198,8 @@ async function findCheckable(locator, context) {
3196
3198
  return findElements.call(this, contextEl, matchedLocator)
3197
3199
  }
3198
3200
 
3199
- // Try ARIA selector for accessible name
3200
- let els
3201
- for (const role of checkableRoles) {
3202
- try {
3203
- els = await contextEl.$$(`::-p-aria([name="${matchedLocator.value}"][role="${role}"])`)
3204
- if (els.length) return els
3205
- } catch (err) {
3206
- // ARIA selector not supported or failed
3207
- }
3208
- }
3209
-
3210
3201
  const literal = xpathLocator.literal(matchedLocator.value)
3211
- els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
3202
+ let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
3212
3203
  if (els.length) {
3213
3204
  return els
3214
3205
  }
@@ -3217,6 +3208,14 @@ async function findCheckable(locator, context) {
3217
3208
  return els
3218
3209
  }
3219
3210
 
3211
+ // Try ARIA selector for accessible name
3212
+ try {
3213
+ els = await contextEl.$$(`::-p-aria(${matchedLocator.value})`)
3214
+ if (els.length) return els
3215
+ } catch (err) {
3216
+ // ARIA selector not supported or failed
3217
+ }
3218
+
3220
3219
  return findElements.call(this, contextEl, matchedLocator.value)
3221
3220
  }
3222
3221
 
@@ -3248,14 +3248,6 @@ async function findCheckable(locator, locateFn) {
3248
3248
  if (locator.isRole()) return locateFn(locator, true)
3249
3249
  if (!locator.isFuzzy()) return locateFn(locator, true)
3250
3250
 
3251
- // Try ARIA selector for accessible name
3252
- try {
3253
- els = await keepCheckable.call(this, await locateFn(`aria/${locator.value}`))
3254
- if (els.length) return els
3255
- } catch (e) {
3256
- // ARIA selector not supported or failed
3257
- }
3258
-
3259
3251
  const literal = xpathLocator.literal(locator.value)
3260
3252
  els = await locateFn(Locator.checkable.byText(literal))
3261
3253
  if (els.length) return els
@@ -3263,23 +3255,17 @@ async function findCheckable(locator, locateFn) {
3263
3255
  els = await locateFn(Locator.checkable.byName(literal))
3264
3256
  if (els.length) return els
3265
3257
 
3258
+ // Try ARIA selector for accessible name
3259
+ try {
3260
+ els = await locateFn(`aria/${locator.value}`)
3261
+ if (els.length) return els
3262
+ } catch (e) {
3263
+ // ARIA selector not supported or failed
3264
+ }
3265
+
3266
3266
  return await locateFn(locator.value) // by css or xpath
3267
3267
  }
3268
3268
 
3269
- async function keepCheckable(els) {
3270
- if (!els || !els.length) return []
3271
-
3272
- const checkable = await this.browser.execute(function () {
3273
- return Array.prototype.slice.call(arguments).map(function (el) {
3274
- if (!el) return false
3275
- const role = el.getAttribute('role')
3276
- if (role) return ['checkbox', 'radio', 'switch'].indexOf(role) > -1
3277
- return el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')
3278
- })
3279
- }, ...els)
3280
-
3281
- return els.filter((el, index) => checkable[index])
3282
- }
3283
3269
 
3284
3270
  function withStrictLocator(locator) {
3285
3271
  locator = new Locator(locator)
@@ -387,6 +387,17 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
387
387
  return true
388
388
  }
389
389
 
390
+ if (resolveRole(el) === 'radiogroup') {
391
+ if (values.length > 1) return '__RADIOGROUP_MULTI__'
392
+ const radios = Array.from(el.querySelectorAll('[role="radio"]'))
393
+ const [wanted] = values
394
+ const named = (radio, matchFn) => roleTextCandidates(radio).some(matchFn)
395
+ const radio = radios.find(r => named(r, t => t === wanted)) || radios.find(r => named(r, t => t.indexOf(wanted) !== -1))
396
+ if (!radio) return false
397
+ radio.click()
398
+ return true
399
+ }
400
+
390
401
  // ARIA combobox/listbox widgets: click the trigger (if any) to reveal the
391
402
  // listbox, then click each matching [role="option"].
392
403
  let container = el
@@ -1,5 +1,60 @@
1
1
  import Locator from '../../locator.js'
2
2
 
3
+ export function splitXPath(xpath) {
4
+ if (typeof xpath !== 'string' || xpath.length === 0) return []
5
+ const withoutRoot = xpath.startsWith('//') ? xpath.slice(1) : xpath
6
+ return withoutRoot.split('/').filter(Boolean)
7
+ }
8
+
9
+ export function isAncestorXPath(ancestor, descendant) {
10
+ if (!ancestor || !descendant || ancestor === descendant) return false
11
+ const ancestorSegments = splitXPath(ancestor)
12
+ const descendantSegments = splitXPath(descendant)
13
+ if (ancestorSegments.length === 0 || ancestorSegments.length >= descendantSegments.length) return false
14
+ return ancestorSegments.every((segment, index) => segment === descendantSegments[index])
15
+ }
16
+
17
+ export function computeParents(entries) {
18
+ const parents = new Array(entries.length).fill(-1)
19
+ const stack = []
20
+ for (let i = 0; i < entries.length; i++) {
21
+ const xpath = entries[i].xpath
22
+ if (!xpath) continue
23
+ while (stack.length > 0 && !isAncestorXPath(entries[stack[stack.length - 1]].xpath, xpath)) {
24
+ stack.pop()
25
+ }
26
+ parents[i] = stack.length > 0 ? stack[stack.length - 1] : -1
27
+ stack.push(i)
28
+ }
29
+ return parents
30
+ }
31
+
32
+ export function computeDepths(entries) {
33
+ const parents = computeParents(entries)
34
+ return parents.map((parent, i) => {
35
+ if (!entries[i].xpath) return 0
36
+ let depth = 0
37
+ let current = parent
38
+ while (current !== -1) {
39
+ depth++
40
+ current = parents[current]
41
+ }
42
+ return depth
43
+ })
44
+ }
45
+
46
+ export function formatTree(entries, depths, parents) {
47
+ return entries.map((entry, i) => {
48
+ const pad = ' '.repeat(depths[i] || 0)
49
+ if (entry.error) {
50
+ return `${pad} ${entry.index}. [Unable to get element info: ${entry.error}]`
51
+ }
52
+ const parentPos = parents ? parents[i] : -1
53
+ const nesting = parentPos !== undefined && parentPos !== -1 ? ` (inside ${entries[parentPos].index}.)` : ''
54
+ return `${pad} ${entry.index}.${nesting} > ${entry.xpath}\n${pad} ${entry.html}`
55
+ })
56
+ }
57
+
3
58
  class MultipleElementsFound extends Error {
4
59
  constructor(locator, webElements) {
5
60
  const locatorStr = (typeof locator === 'object' && !(locator instanceof Locator))
@@ -17,7 +72,7 @@ class MultipleElementsFound extends Error {
17
72
  if (this._detailsFetched) return
18
73
 
19
74
  try {
20
- const items = []
75
+ const entries = []
21
76
  const maxToShow = Math.min(this.count, 10)
22
77
 
23
78
  for (let i = 0; i < maxToShow; i++) {
@@ -25,12 +80,14 @@ class MultipleElementsFound extends Error {
25
80
  try {
26
81
  const xpath = await webEl.toAbsoluteXPath()
27
82
  const html = await webEl.toSimplifiedHTML()
28
- items.push(` ${i + 1}. > ${xpath}\n ${html}`)
83
+ entries.push({ index: i + 1, xpath, html })
29
84
  } catch (err) {
30
- items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`)
85
+ entries.push({ index: i + 1, error: err.message })
31
86
  }
32
87
  }
33
88
 
89
+ const items = formatTree(entries, computeDepths(entries), computeParents(entries))
90
+
34
91
  if (this.count > 10) {
35
92
  items.push(` ... and ${this.count - 10} more`)
36
93
  }
@@ -1,10 +1,10 @@
1
- async function findByPlaywrightLocator(matcher, locator) {
1
+ function findByPlaywrightLocator(matcher, locator) {
2
2
  const pwLocator = locator.locator || locator
3
3
  if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) {
4
4
  return matcher.getByTestId(pwLocator.pw.value.split('=')[1])
5
5
  }
6
6
  const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw
7
- return matcher.locator(pwValue).all()
7
+ return matcher.locator(pwValue)
8
8
  }
9
9
 
10
10
  export { findByPlaywrightLocator }
package/lib/locator.js CHANGED
@@ -649,6 +649,9 @@ Locator.field = {
649
649
  ]),
650
650
  }
651
651
 
652
+ const checkable = `self::input[@type = 'checkbox' or @type = 'radio'] or @role = 'checkbox' or @role = 'radio' or @role = 'switch'`
653
+ const visibleCheckable = `.//*[${checkable}][not(@aria-hidden = 'true')]`
654
+
652
655
  Locator.checkable = {
653
656
  /**
654
657
  * @param {string} literal
@@ -656,8 +659,10 @@ Locator.checkable = {
656
659
  */
657
660
  byText: literal =>
658
661
  xpathLocator.combine([
659
- `.//input[@type = 'checkbox' or @type = 'radio'][(@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or @placeholder = ${literal}]`,
660
- `.//label[contains(normalize-space(string(.)), ${literal})]//input[@type = 'radio' or @type = 'checkbox']`,
662
+ `${visibleCheckable}[(@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or @placeholder = ${literal}]`,
663
+ `.//label[contains(normalize-space(string(.)), ${literal})]//*[${checkable}][not(@aria-hidden = 'true')]`,
664
+ `${visibleCheckable}[@aria-labelledby = //*[@id][contains(normalize-space(string(.)), ${literal})]/@id]`,
665
+ `${visibleCheckable}[@aria-label = ${literal}]`,
661
666
  ]),
662
667
 
663
668
  /**
@@ -4,7 +4,7 @@ import recorder from '../recorder.js'
4
4
  import assertThrown from '../assert/throws.js'
5
5
  import { ucfirst, isAsyncFunction } from '../utils.js'
6
6
  import { getInjectedArguments } from './inject.js'
7
- import { fireHook } from './hooks.js'
7
+ import { fireHook, BeforeSuiteHook, AfterSuiteHook } from './hooks.js'
8
8
 
9
9
  const injectHook = function (inject, suite) {
10
10
  try {
@@ -232,6 +232,10 @@ export function suiteSetup(suite) {
232
232
 
233
233
  // Set up error handler for suite setup
234
234
  recorder.errHandler(err => {
235
+ // A helper's `_beforeSuite()` runs through this hook, not through the
236
+ // `injected()` wrapper, so nothing here used to emit `hook.failed` and
237
+ // reporters listening for it never saw the failure. (#5660)
238
+ event.emit(event.hook.failed, new BeforeSuiteHook(suite, err))
235
239
  doneFn(err)
236
240
  })
237
241
 
@@ -254,6 +258,8 @@ export function suiteTeardown(suite) {
254
258
 
255
259
  // Set up error handler for suite teardown
256
260
  recorder.errHandler(err => {
261
+ // Same for a helper's `_afterSuite()`. (#5660)
262
+ event.emit(event.hook.failed, new AfterSuiteHook(suite, err))
257
263
  doneFn(err)
258
264
  })
259
265
 
@@ -4,6 +4,7 @@
4
4
  * @property {boolean} [exact] - Enable strict mode for this step. Throws if multiple elements match.
5
5
  * @property {boolean} [strictMode] - Alias for exact.
6
6
  * @property {boolean} [ignoreCase] - Perform case-insensitive text matching.
7
+ * @property {boolean} [visibleLocator] - Match only visible elements. Overrides the Playwright helper `visibleLocator` config option for this step.
7
8
  */
8
9
 
9
10
  /**
package/lib/store.js CHANGED
@@ -93,6 +93,12 @@ const store = {
93
93
  /** @type {CodeceptJS.Suite | null} */
94
94
  currentSuite: null,
95
95
 
96
+ /**
97
+ * Locators match only visible elements, resolved per step
98
+ * @type {boolean}
99
+ */
100
+ visibleLocator: false,
101
+
96
102
  /** @type {Map<string, string> | null} */
97
103
  tsFileMapping: null,
98
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "4.2.0-beta.1",
3
+ "version": "4.2.0-beta.3",
4
4
  "type": "module",
5
5
  "description": "Supercharged End 2 End Testing Framework for NodeJS",
6
6
  "keywords": [
@@ -177,7 +177,7 @@
177
177
  "jsdoc": "^3.6.11",
178
178
  "jsdoc-typeof-plugin": "1.0.0",
179
179
  "json-server": "0.17.4",
180
- "playwright": "^1.59.0",
180
+ "playwright": "^1.63.0",
181
181
  "prettier": "^3.3.2",
182
182
  "puppeteer": "24.36.0",
183
183
  "qrcode-terminal": "0.12.0",
@@ -3429,9 +3429,9 @@ declare namespace CodeceptJS {
3429
3429
  * never spawns or kills anything, no matter what `binaryPath`/`port` are set to.
3430
3430
  * - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
3431
3431
  * the config, then the `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The helper
3432
- * spawns `obscura serve --port <port> --allow-private-network` (`port` from the config, or a
3433
- * free port picked automatically), waits for it to answer, connects, and kills it in
3434
- * `_finishTest`.
3432
+ * spawns `obscura serve --port <port> --allow-private-network --allow-file-access` (`port` from
3433
+ * the config, or a free port picked automatically), waits for it to answer, connects, and kills
3434
+ * it in `_finishTest`.
3435
3435
  * - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
3436
3436
  * answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
3437
3437
  * before this process ever ran). The helper attaches to it and never kills it — it isn't the
@@ -3444,12 +3444,13 @@ declare namespace CodeceptJS {
3444
3444
  * it directly) and the helper launches and tears it down for you automatically:
3445
3445
  *
3446
3446
  * ```sh
3447
- * curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.0/obscura-x86_64-linux.tar.gz | tar xz
3447
+ * curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.2/obscura-x86_64-linux.tar.gz | tar xz
3448
3448
  * ```
3449
3449
  *
3450
- * `--allow-private-network` is always passed by this helper (it's required to reach apps running
3451
- * on `localhost`/private IPs, e.g. a dev server on `127.0.0.1:8000` — Obscura blocks
3452
- * private-network requests by default).
3450
+ * `--allow-private-network` and `--allow-file-access` are always passed by this helper: the first
3451
+ * is required to reach apps running on `localhost`/private IPs, e.g. a dev server on
3452
+ * `127.0.0.1:8000`, the second to let `attachFile` upload local files. Obscura blocks both by
3453
+ * default.
3453
3454
  *
3454
3455
  * ## Config presets
3455
3456
  *
@@ -3468,7 +3469,7 @@ declare namespace CodeceptJS {
3468
3469
  * ## Limitations
3469
3470
  *
3470
3471
  * - `input` is always `synthetic`, even on rendering builds — see `input` above.
3471
- * - No frames, popups, or file uploads.
3472
+ * - No frames or popups.
3472
3473
  * - On `-no-render` builds and v0.1.x: no screenshots, no visibility assertions
3473
3474
  * (`seeElement`/`dontSeeElement` always throw) — only DOM presence
3474
3475
  * (`seeElementInDOM`/`dontSeeElementInDOM`) is meaningful without a layout engine.
@@ -3607,6 +3608,7 @@ declare namespace CodeceptJS {
3607
3608
  * @property [ignoreHTTPSErrors] - Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
3608
3609
  * @property [bypassCSP] - bypass Content Security Policy or CSP
3609
3610
  * @property [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
3611
+ * @property [visibleLocator = false] - append [`visible()`](https://playwright.dev/docs/api/class-locator#locator-visible) to locators, so only visible elements are matched. Requires Playwright 1.63 or newer. Switch it off for a single step with `stepOpts({ visibleLocator: false })`. Not applied to `dragAndDrop`, which passes selectors to Playwright directly, nor to steps that must reach hidden elements: `grab*` methods, `scrollTo`, `seeElementInDOM`, `dontSeeElementInDOM` and `seeNumberOfElements`. When enabled, a locator matching only hidden elements fails as "element not found" instead of timing out on actionability, `strict` mode ignores hidden duplicates, and elements hidden by CSS (like a custom checkbox built on a visually hidden `input`) are no longer found.
3610
3612
  * @property [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har).
3611
3613
  * @property [testIdAttribute = data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id).
3612
3614
  * @property [storageState] - Playwright storage state (path to JSON file or object)
@@ -3651,6 +3653,7 @@ declare namespace CodeceptJS {
3651
3653
  ignoreHTTPSErrors?: boolean;
3652
3654
  bypassCSP?: boolean;
3653
3655
  highlightElement?: boolean;
3656
+ visibleLocator?: boolean;
3654
3657
  recordHar?: any;
3655
3658
  testIdAttribute?: string;
3656
3659
  storageState?: string | any;
@@ -3462,9 +3462,9 @@ declare namespace CodeceptJS {
3462
3462
  * never spawns or kills anything, no matter what `binaryPath`/`port` are set to.
3463
3463
  * - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
3464
3464
  * the config, then the `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The helper
3465
- * spawns `obscura serve --port <port> --allow-private-network` (`port` from the config, or a
3466
- * free port picked automatically), waits for it to answer, connects, and kills it in
3467
- * `_finishTest`.
3465
+ * spawns `obscura serve --port <port> --allow-private-network --allow-file-access` (`port` from
3466
+ * the config, or a free port picked automatically), waits for it to answer, connects, and kills
3467
+ * it in `_finishTest`.
3468
3468
  * - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
3469
3469
  * answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
3470
3470
  * before this process ever ran). The helper attaches to it and never kills it — it isn't the
@@ -3477,12 +3477,13 @@ declare namespace CodeceptJS {
3477
3477
  * it directly) and the helper launches and tears it down for you automatically:
3478
3478
  *
3479
3479
  * ```sh
3480
- * curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.0/obscura-x86_64-linux.tar.gz | tar xz
3480
+ * curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.2/obscura-x86_64-linux.tar.gz | tar xz
3481
3481
  * ```
3482
3482
  *
3483
- * `--allow-private-network` is always passed by this helper (it's required to reach apps running
3484
- * on `localhost`/private IPs, e.g. a dev server on `127.0.0.1:8000` — Obscura blocks
3485
- * private-network requests by default).
3483
+ * `--allow-private-network` and `--allow-file-access` are always passed by this helper: the first
3484
+ * is required to reach apps running on `localhost`/private IPs, e.g. a dev server on
3485
+ * `127.0.0.1:8000`, the second to let `attachFile` upload local files. Obscura blocks both by
3486
+ * default.
3486
3487
  *
3487
3488
  * ## Config presets
3488
3489
  *
@@ -3501,7 +3502,7 @@ declare namespace CodeceptJS {
3501
3502
  * ## Limitations
3502
3503
  *
3503
3504
  * - `input` is always `synthetic`, even on rendering builds — see `input` above.
3504
- * - No frames, popups, or file uploads.
3505
+ * - No frames or popups.
3505
3506
  * - On `-no-render` builds and v0.1.x: no screenshots, no visibility assertions
3506
3507
  * (`seeElement`/`dontSeeElement` always throw) — only DOM presence
3507
3508
  * (`seeElementInDOM`/`dontSeeElementInDOM`) is meaningful without a layout engine.
@@ -3641,6 +3642,7 @@ declare namespace CodeceptJS {
3641
3642
  * @property [ignoreHTTPSErrors] - Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
3642
3643
  * @property [bypassCSP] - bypass Content Security Policy or CSP
3643
3644
  * @property [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
3645
+ * @property [visibleLocator = false] - append [`visible()`](https://playwright.dev/docs/api/class-locator#locator-visible) to locators, so only visible elements are matched. Requires Playwright 1.63 or newer. Switch it off for a single step with `stepOpts({ visibleLocator: false })`. Not applied to `dragAndDrop`, which passes selectors to Playwright directly, nor to steps that must reach hidden elements: `grab*` methods, `scrollTo`, `seeElementInDOM`, `dontSeeElementInDOM` and `seeNumberOfElements`. When enabled, a locator matching only hidden elements fails as "element not found" instead of timing out on actionability, `strict` mode ignores hidden duplicates, and elements hidden by CSS (like a custom checkbox built on a visually hidden `input`) are no longer found.
3644
3646
  * @property [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har).
3645
3647
  * @property [testIdAttribute = data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id).
3646
3648
  * @property [storageState] - Playwright storage state (path to JSON file or object)
@@ -3685,6 +3687,7 @@ declare namespace CodeceptJS {
3685
3687
  ignoreHTTPSErrors?: boolean;
3686
3688
  bypassCSP?: boolean;
3687
3689
  highlightElement?: boolean;
3690
+ visibleLocator?: boolean;
3688
3691
  recordHar?: any;
3689
3692
  testIdAttribute?: string;
3690
3693
  storageState?: string | any;
@@ -11054,12 +11057,14 @@ declare namespace CodeceptJS {
11054
11057
  * @property [exact] - Enable strict mode for this step. Throws if multiple elements match.
11055
11058
  * @property [strictMode] - Alias for exact.
11056
11059
  * @property [ignoreCase] - Perform case-insensitive text matching.
11060
+ * @property [visibleLocator] - Match only visible elements. Overrides the Playwright helper `visibleLocator` config option for this step.
11057
11061
  */
11058
11062
  type StepOptions = {
11059
11063
  elementIndex?: number | 'first' | 'last';
11060
11064
  exact?: boolean;
11061
11065
  strictMode?: boolean;
11062
11066
  ignoreCase?: boolean;
11067
+ visibleLocator?: boolean;
11063
11068
  };
11064
11069
  /**
11065
11070
  * StepConfig is a configuration object for a step.
@@ -11169,6 +11174,10 @@ declare namespace CodeceptJS {
11169
11174
  var currentTest: CodeceptJS.Test | null;
11170
11175
  var currentStep: CodeceptJS.Step | null;
11171
11176
  var currentSuite: CodeceptJS.Suite | null;
11177
+ /**
11178
+ * Locators match only visible elements, resolved per step
11179
+ */
11180
+ var visibleLocator: boolean;
11172
11181
  var tsFileMapping: Map<string, string> | null;
11173
11182
  /**
11174
11183
  * Initialize required store fields.