codeceptjs 4.1.0 → 4.2.0-beta.1

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.
Files changed (41) hide show
  1. package/docs/alternative-browsers.md +153 -0
  2. package/docs/basics.md +9 -1
  3. package/docs/configuration.md +2 -0
  4. package/docs/helpers/CDPBrowser.md +2138 -0
  5. package/docs/helpers/Kitesurf.md +118 -0
  6. package/docs/helpers/Obscura.md +210 -0
  7. package/docs/migration-4.md +3 -1
  8. package/docs/parallel.md +10 -0
  9. package/docs/plugins/screencast.md +18 -13
  10. package/docs/plugins.md +1 -1
  11. package/lib/command/info.js +11 -3
  12. package/lib/command/workers/runTests.js +14 -20
  13. package/lib/container.js +6 -0
  14. package/lib/data/context.js +4 -0
  15. package/lib/element/WebElement.js +5 -0
  16. package/lib/helper/Appium.js +14 -2
  17. package/lib/helper/CDPBrowser.js +3004 -0
  18. package/lib/helper/Kitesurf.js +139 -0
  19. package/lib/helper/Obscura.js +344 -0
  20. package/lib/helper/Playwright.js +30 -3
  21. package/lib/helper/Puppeteer.js +43 -16
  22. package/lib/helper/WebDriver.js +43 -8
  23. package/lib/helper/clientscripts/cdpBrowserClient.js +486 -0
  24. package/lib/helper/clientscripts/xpathPolyfill.js +31 -0
  25. package/lib/helper/extras/CDPConnection.js +92 -0
  26. package/lib/helper/extras/CDPElementHandle.js +27 -0
  27. package/lib/helper/extras/apngAssembler.js +156 -0
  28. package/lib/html.js +9 -2
  29. package/lib/listener/retryEnhancer.js +2 -1
  30. package/lib/listener/steps.js +8 -0
  31. package/lib/mocha/hooks.js +10 -0
  32. package/lib/parser.js +14 -2
  33. package/lib/plugin/junitReporter.js +17 -1
  34. package/lib/plugin/screencast.js +116 -24
  35. package/lib/step/base.js +15 -3
  36. package/lib/utils/loaderCheck.js +6 -0
  37. package/lib/utils.js +1 -1
  38. package/lib/workers.js +17 -0
  39. package/package.json +4 -1
  40. package/typings/promiseBasedTypes.d.ts +1833 -0
  41. package/typings/types.d.ts +1840 -0
@@ -0,0 +1,139 @@
1
+ import axios from 'axios'
2
+ import CDPBrowser from './CDPBrowser.js'
3
+
4
+ /**
5
+ * ## Configuration
6
+ *
7
+ * This helper should be configured in codecept.conf.js. It accepts everything `CDPBrowser`
8
+ * accepts (see its config table), plus:
9
+ *
10
+ * @typedef KitesurfConfig
11
+ * @type {object}
12
+ * @prop {string} [url=http://localhost] - base URL for tests
13
+ * @prop {string} [accountId] - Cloudflare account ID; defaults to CF_ACCOUNT_ID env var
14
+ * @prop {string} [apiToken] - Cloudflare API token; defaults to CF_API_TOKEN env var
15
+ * @prop {number} [keepAlive=240000] - session keep-alive time in milliseconds
16
+ * @prop {string} [apiBase=https://api.cloudflare.com/client/v4] - Cloudflare API base URL
17
+ * @prop {string} [input=cdp] - input method for user actions; defaults to 'cdp' for Kitesurf's real layout engine, but can be overridden
18
+ * @prop {object} [capabilities] - pre-configured capabilities; Kitesurf uses { layout: 'real', screenshot: true }
19
+ */
20
+ const config = {}
21
+
22
+ /**
23
+ * Kitesurf is a cloud browser helper that extends CDPBrowser to run tests against
24
+ * Cloudflare's Browser Run service (Kitesurf browser). It automates real browser
25
+ * sessions in Cloudflare's cloud infrastructure, eliminating the need to manage
26
+ * local browser instances.
27
+ *
28
+ * **Status:** Beta
29
+ *
30
+ * ## Requirements
31
+ *
32
+ * - Cloudflare account with Browser Run enabled
33
+ * - API token with **Browser Rendering Edit** permission
34
+ *
35
+ * ## Setup
36
+ *
37
+ * To create an API token with Browser Rendering Edit permission:
38
+ * 1. Log in to your Cloudflare dashboard
39
+ * 2. Go to My Profile > API Tokens
40
+ * 3. Click "Create Token"
41
+ * 4. Use the "Custom token" template
42
+ * 5. Under "Permissions", select "Browser Rendering" > "Edit"
43
+ * 6. Set the account scope to your target account
44
+ * 7. Copy the token and set it as `CF_API_TOKEN` environment variable
45
+ *
46
+ * For more details, see:
47
+ * - [Cloudflare Browser Run Developers Docs](https://developers.cloudflare.com/browser-run/)
48
+ * - [Cloudflare Blog - Kitesurf Announcement](https://blog.cloudflare.com/kitesurf/)
49
+ *
50
+ * ## Example
51
+ *
52
+ * ```js
53
+ * // codecept.conf.js
54
+ * {
55
+ * helpers: {
56
+ * Kitesurf: {
57
+ * url: 'https://example.com',
58
+ * accountId: process.env.CF_ACCOUNT_ID,
59
+ * apiToken: process.env.CF_API_TOKEN,
60
+ * }
61
+ * }
62
+ * }
63
+ * ```
64
+ *
65
+ * Or set environment variables and rely on defaults:
66
+ *
67
+ * ```bash
68
+ * export CF_ACCOUNT_ID="your-account-id"
69
+ * export CF_API_TOKEN="your-api-token"
70
+ * ```
71
+ *
72
+ * <!-- configuration -->
73
+ *
74
+ * ## Methods
75
+ */
76
+ class Kitesurf extends CDPBrowser {
77
+ /**
78
+ * @param {KitesurfConfig} config
79
+ */
80
+ constructor(config) {
81
+ super({
82
+ input: 'cdp',
83
+ keepAlive: 240000,
84
+ apiBase: 'https://api.cloudflare.com/client/v4',
85
+ ...config,
86
+ capabilities: { layout: 'real', screenshot: true, ...(config.capabilities || {}) },
87
+ })
88
+ this.options.accountId = this.options.accountId || process.env.CF_ACCOUNT_ID
89
+ this.options.apiToken = this.options.apiToken || process.env.CF_API_TOKEN
90
+ this.cloudSessionId = null
91
+ }
92
+
93
+ /**
94
+ * Acquires a Kitesurf browser session from the Cloudflare Browser Run API and resolves it to
95
+ * the `wss://` debugger URL `CDPConnection` connects to. Overrides `CDPBrowser._resolveEndpoint`,
96
+ * which resolves a fixed local endpoint instead of provisioning a cloud session per test.
97
+ *
98
+ * @returns {Promise<string>} a `wss://` debugger URL ready to be passed to `CDPConnection`.
99
+ * @protected
100
+ */
101
+ async _resolveEndpoint() {
102
+ if (!this.options.accountId || !this.options.apiToken) {
103
+ throw new Error('Kitesurf requires accountId and apiToken (or CF_ACCOUNT_ID / CF_API_TOKEN env vars)')
104
+ }
105
+ const url = `${this.options.apiBase}/accounts/${this.options.accountId}/browser-run/devtools/browser?browser=kitesurf&keep_alive=${this.options.keepAlive}`
106
+ const res = await axios.post(url, null, { headers: { Authorization: `Bearer ${this.options.apiToken}` }, validateStatus: () => true })
107
+ const data = res.data
108
+ if (!data || !data.webSocketDebuggerUrl) {
109
+ throw new Error(`Could not acquire Kitesurf session: ${JSON.stringify(data).slice(0, 300)}`)
110
+ }
111
+ this.cloudSessionId = data.sessionId
112
+ this.options.headers = { Authorization: `Bearer ${this.options.apiToken}` }
113
+ return data.webSocketDebuggerUrl
114
+ }
115
+
116
+ /**
117
+ * Closes the target as `CDPBrowser._finishTest` does, then releases the cloud session acquired
118
+ * in `_resolveEndpoint` via the Cloudflare API so it does not linger for the full `keepAlive`
119
+ * window. The release runs in a `finally` so a rejection while closing the CDP connection still
120
+ * frees the cloud session instead of leaving the browser alive until `keepAlive` expires; the
121
+ * session id is cleared before the request, so a repeated call never releases it twice.
122
+ *
123
+ * @protected
124
+ */
125
+ async _finishTest() {
126
+ try {
127
+ await super._finishTest()
128
+ } finally {
129
+ if (this.cloudSessionId) {
130
+ const sessionId = this.cloudSessionId
131
+ this.cloudSessionId = null
132
+ const url = `${this.options.apiBase}/accounts/${this.options.accountId}/browser-run/devtools/browser/${sessionId}`
133
+ await axios.delete(url, { headers: { Authorization: `Bearer ${this.options.apiToken}` } }).catch(() => null)
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ export default Kitesurf
@@ -0,0 +1,344 @@
1
+ import { spawn } from 'child_process'
2
+ import fs from 'fs'
3
+ import net from 'net'
4
+ import path from 'path'
5
+ import axios from 'axios'
6
+ import CDPBrowser from './CDPBrowser.js'
7
+ import { isFile, isWindows } from '../utils.js'
8
+
9
+ /**
10
+ * ## Configuration
11
+ *
12
+ * This helper should be configured in codecept.conf.js. It accepts everything `CDPBrowser`
13
+ * accepts (see its config table), plus:
14
+ *
15
+ * @typedef ObscuraConfig
16
+ * @type {object}
17
+ * @prop {string} [endpoint] - explicit CDP endpoint. Setting this switches the helper to ATTACH
18
+ * mode: it only connects, and never spawns or kills a process, no matter what else is configured.
19
+ * Leave it unset for SELF-MANAGED mode (see below).
20
+ * @prop {string} [binaryPath] - path to the `obscura` executable, used in SELF-MANAGED mode
21
+ * (`endpoint` unset). Checked before `OBSCURA_PATH` and `PATH`.
22
+ * @prop {number} [port] - port `obscura serve` listens on, in SELF-MANAGED mode. When unset, a
23
+ * free port is picked automatically, which is what makes `run-workers` collision-free — every
24
+ * worker gets its own instance on its own port with zero config.
25
+ * @prop {number} [serverStartTimeout=15000] - milliseconds to wait for a spawned `obscura serve`
26
+ * to answer `/json/version` before `_connect` gives up.
27
+ */
28
+ const config = {}
29
+
30
+ /**
31
+ * Obscura drives [Obscura](https://github.com/h4ckf0r0day/obscura), a minimal headless
32
+ * browser exposed over the Chrome DevTools Protocol. From v0.2.0, default release builds ship a
33
+ * real rendering engine (layout, paint, screenshots); `-no-render` variants and v0.1.x builds keep
34
+ * the original single-V8-isolate, nothing-rendered mode. This helper does not hardcode which mode a
35
+ * given binary is in — `CDPBrowser._probeCapabilities` detects `layout`/`screenshot` per binary at
36
+ * runtime, so the same helper works against either.
37
+ *
38
+ * This helper is a thin `CDPBrowser` subclass: it changes nothing about how locating or acting on
39
+ * elements works, it only pins the config presets Obscura requires and manages the `obscura serve`
40
+ * process lifecycle, the same way Playwright manages its own browser process.
41
+ *
42
+ * ## Modes
43
+ *
44
+ * - **ATTACH** — `endpoint` is set explicitly in the config. The helper only connects to it; it
45
+ * never spawns or kills anything, no matter what `binaryPath`/`port` are set to.
46
+ * - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
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`.
51
+ * - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
52
+ * answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
53
+ * before this process ever ran). The helper attaches to it and never kills it — it isn't the
54
+ * helper's process to kill. If neither a binary nor a running server on :9222 can be found, the
55
+ * helper throws a loud, actionable error.
56
+ *
57
+ * ## Install
58
+ *
59
+ * Download a release binary and put it on your `PATH` (or point `binaryPath`/`OBSCURA_PATH` at
60
+ * it directly) and the helper launches and tears it down for you automatically:
61
+ *
62
+ * ```sh
63
+ * curl -sL https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.0/obscura-x86_64-linux.tar.gz | tar xz
64
+ * ```
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).
69
+ *
70
+ * ## Config presets
71
+ *
72
+ * These are set automatically and only need overriding for unusual setups:
73
+ *
74
+ * | option | value | why |
75
+ * | --- | --- | --- |
76
+ * | `input` | `synthetic` | coordinate-click navigation is unreliable over CDP on Obscura even on rendering builds (no `frameNavigated` event, stale `page.url()`); `click` always takes the `forceClick` path — on Obscura, `click` and `forceClick` are the same thing |
77
+ * | `xpathPolyfill` | `auto` | probed per binary/page: Obscura's native `document.evaluate` still doesn't support attribute selection or `not()`, so the polyfill is used until that lands |
78
+ *
79
+ * `capabilities.layout`/`capabilities.screenshot`/`capabilities.xpath` are intentionally left
80
+ * unset here — `CDPBrowser._probeCapabilities` detects them at runtime from the actual binary
81
+ * (`'real'`/`true` on v0.2.0+ default builds, `'none'`/`false` on `-no-render` builds and v0.1.x).
82
+ * Set them explicitly in your own config to skip probing or to force a mode.
83
+ *
84
+ * ## Limitations
85
+ *
86
+ * - `input` is always `synthetic`, even on rendering builds — see `input` above.
87
+ * - No frames, popups, or file uploads.
88
+ * - On `-no-render` builds and v0.1.x: no screenshots, no visibility assertions
89
+ * (`seeElement`/`dontSeeElement` always throw) — only DOM presence
90
+ * (`seeElementInDOM`/`dontSeeElementInDOM`) is meaningful without a layout engine.
91
+ * - On v0.2.0+ default (rendering) builds: layout, screenshots, and CSS work, but it's a new,
92
+ * independently implemented rendering/CSS engine — expect edge cases and gaps versus a real browser.
93
+ * - Single V8 isolate: heavy or long-running pages, or many pages in parallel against one
94
+ * `obscura serve` process, compete for the same isolate.
95
+ *
96
+ * <!-- configuration -->
97
+ *
98
+ * ## Example
99
+ *
100
+ * ```js
101
+ * // inside codecept.conf.js — SELF-LAUNCH mode (recommended): the helper finds/starts/stops
102
+ * // obscura serve on its own, on a free port. Ideal for run-workers: every worker gets its own
103
+ * // instance with no config.
104
+ * {
105
+ * helpers: {
106
+ * Obscura: {
107
+ * url: 'http://localhost',
108
+ * }
109
+ * }
110
+ * }
111
+ * ```
112
+ *
113
+ * ```js
114
+ * // ATTACH mode — connect to an Obscura instance you manage yourself (remote host, container, etc.)
115
+ * {
116
+ * helpers: {
117
+ * Obscura: {
118
+ * url: 'http://localhost',
119
+ * endpoint: 'http://127.0.0.1:9222',
120
+ * }
121
+ * }
122
+ * }
123
+ * ```
124
+ *
125
+ * ## Methods
126
+ */
127
+ class Obscura extends CDPBrowser {
128
+ /**
129
+ * @param {ObscuraConfig} config
130
+ */
131
+ constructor(config) {
132
+ const explicitEndpoint = !!(config && config.endpoint)
133
+ super({
134
+ input: 'synthetic',
135
+ xpathPolyfill: 'auto',
136
+ ...config,
137
+ })
138
+ this.mode = explicitEndpoint ? 'attach' : 'self-managed'
139
+ if (!explicitEndpoint) {
140
+ this.options.endpoint = null
141
+ }
142
+ this.serverProcess = null
143
+ this.serverError = null
144
+ this.binaryPath = null
145
+ this._selfManagedResolved = false
146
+ }
147
+
148
+ /**
149
+ * In ATTACH mode, connects exactly as `CDPBrowser._connect` would. In SELF-MANAGED mode,
150
+ * resolves and spawns `obscura serve` (or courtesy-attaches to an already-running one on
151
+ * :9222) exactly once via `_resolveSelfManaged`, then connects.
152
+ *
153
+ * A spawn failure (e.g. a bad binary) is delivered asynchronously by Node as an `error`
154
+ * event; it is recorded on `this.serverError` and surfaced as a rejection from `_waitForServer`
155
+ * instead of crashing the process as an uncaught exception.
156
+ *
157
+ * @protected
158
+ */
159
+ async _connect() {
160
+ if (this.mode !== 'attach' && !this._selfManagedResolved) {
161
+ await this._resolveSelfManaged()
162
+ this._selfManagedResolved = true
163
+ }
164
+ return super._connect()
165
+ }
166
+
167
+ /**
168
+ * Resolves how to reach Obscura when no explicit `endpoint` was configured, trying, in order:
169
+ * spawn a binary (`binaryPath` config, then `OBSCURA_PATH` env, then `obscura` on `PATH`),
170
+ * courtesy-attach to `http://127.0.0.1:9222` if something already answers there, or throw a
171
+ * loud, actionable error. Sets `this.options.endpoint` as a side effect.
172
+ *
173
+ * @protected
174
+ */
175
+ async _resolveSelfManaged() {
176
+ const binaryPath = this._resolveBinary()
177
+ if (binaryPath) {
178
+ this.binaryPath = binaryPath
179
+ const port = this.options.port || (await this._findFreePort())
180
+ this.options.port = port
181
+ this.serverError = null
182
+ this.serverProcess = spawn(binaryPath, ['serve', '--port', String(port), '--allow-private-network'], { stdio: 'ignore' })
183
+ this.serverProcess.on('error', err => {
184
+ this.serverError = err
185
+ })
186
+ await this._waitForServer()
187
+ this.options.endpoint = `ws://127.0.0.1:${port}/devtools/browser`
188
+ return
189
+ }
190
+ if (await this._probeUp('http://127.0.0.1:9222/json/version')) {
191
+ this.debugSection('Obscura', 'no binaryPath/OBSCURA_PATH/obscura-on-PATH found — courtesy-attaching to an already-running obscura serve on 127.0.0.1:9222 (this helper will not manage or kill it)')
192
+ this.options.endpoint = 'http://127.0.0.1:9222'
193
+ return
194
+ }
195
+ throw new Error(
196
+ [
197
+ 'Obscura has no endpoint configured, no binary could be resolved (checked config.binaryPath, OBSCURA_PATH, and PATH), and nothing answered http://127.0.0.1:9222/json/version.',
198
+ 'Start it yourself: obscura serve --port 9222 --allow-private-network',
199
+ 'Or let this helper launch it: put a release binary on PATH, set OBSCURA_PATH=/path/to/obscura, or pass binaryPath in the config.',
200
+ 'Releases: https://github.com/h4ckf0r0day/obscura/releases',
201
+ ].join('\n '),
202
+ )
203
+ }
204
+
205
+ /**
206
+ * Resolves the `obscura` binary to spawn, in priority order: `options.binaryPath`, then the
207
+ * `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The `PATH` lookup walks the
208
+ * directories itself instead of shelling out to `which`, which does not exist on Windows: on
209
+ * Windows every `PATHEXT` suffix is tried, so an `obscura.exe` on `PATH` is found too.
210
+ *
211
+ * @returns {string|null} an absolute or relative path to the binary, or null if none resolved.
212
+ * @protected
213
+ */
214
+ _resolveBinary() {
215
+ if (this.options.binaryPath) return this.options.binaryPath
216
+ if (process.env.OBSCURA_PATH) return process.env.OBSCURA_PATH
217
+ const windows = isWindows()
218
+ const extensions = windows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']
219
+ for (const entry of (process.env.PATH || '').split(path.delimiter)) {
220
+ const dir = windows ? entry.replace(/^"|"$/g, '') : entry
221
+ if (!dir) continue
222
+ for (const extension of extensions) {
223
+ const candidate = path.join(dir, `obscura${extension}`)
224
+ if (!isFile(candidate)) continue
225
+ try {
226
+ fs.accessSync(candidate, fs.constants.X_OK)
227
+ return candidate
228
+ } catch (e) {
229
+ continue
230
+ }
231
+ }
232
+ }
233
+ return null
234
+ }
235
+
236
+ /**
237
+ * Picks a free TCP port on 127.0.0.1 by briefly listening on port 0 and reading back the OS-assigned
238
+ * port. Used as the SELF-LAUNCH default when `options.port` isn't explicitly set, so multiple
239
+ * `run-workers` workers never collide on the same port.
240
+ *
241
+ * @returns {Promise<number>} a free port.
242
+ * @protected
243
+ */
244
+ async _findFreePort() {
245
+ return new Promise((resolve, reject) => {
246
+ const srv = net.createServer()
247
+ srv.unref()
248
+ srv.on('error', reject)
249
+ srv.listen(0, '127.0.0.1', () => {
250
+ const { port } = srv.address()
251
+ srv.close(() => resolve(port))
252
+ })
253
+ })
254
+ }
255
+
256
+ /**
257
+ * Probes a `/json/version`-style URL with a short timeout, used for the COURTESY-ATTACH check.
258
+ *
259
+ * @param {string} url
260
+ * @returns {Promise<boolean>} true if the URL answered.
261
+ * @protected
262
+ */
263
+ async _probeUp(url) {
264
+ try {
265
+ await axios.get(url, { timeout: 1000 })
266
+ return true
267
+ } catch (e) {
268
+ return false
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Polls `http://127.0.0.1:<port>/json/version` until `obscura serve` responds, `this.serverError`
274
+ * is set by the spawned process' `error` event, or `options.serverStartTimeout` elapses. The
275
+ * process typically comes up within tens of milliseconds — a 20ms retry interval (down from a
276
+ * previous 200ms) keeps the wasted tail after the server is actually ready small, since this cost
277
+ * is paid once per run and counts directly toward real-world startup latency.
278
+ *
279
+ * @protected
280
+ */
281
+ async _waitForServer() {
282
+ const timeout = this.options.serverStartTimeout || 15000
283
+ const deadline = Date.now() + timeout
284
+ while (Date.now() < deadline) {
285
+ if (this.serverError) {
286
+ throw new Error(`Failed to start obscura at ${this.binaryPath}: ${this.serverError.message}`)
287
+ }
288
+ try {
289
+ await axios.get(`http://127.0.0.1:${this.options.port}/json/version`)
290
+ return
291
+ } catch (e) {
292
+ await new Promise(r => setTimeout(r, 20))
293
+ }
294
+ }
295
+ if (this.serverError) {
296
+ throw new Error(`Failed to start obscura at ${this.binaryPath}: ${this.serverError.message}`)
297
+ }
298
+ throw new Error(`obscura serve did not start on port ${this.options.port} within ${timeout}ms`)
299
+ }
300
+
301
+ /**
302
+ * Closes the CDP connection (via `CDPBrowser._finishTest`), then kills the `obscura serve`
303
+ * process spawned by `_connect`, if any (never runs in ATTACH or COURTESY-ATTACH mode, since
304
+ * `this.serverProcess` is only ever set in SELF-LAUNCH mode). Runs in a `finally` so the process
305
+ * is always reaped even if closing the CDP connection throws. Sends `SIGTERM` first and waits for
306
+ * the process to exit; a process that ignores `SIGTERM` is escalated to `SIGKILL` after 5s. The
307
+ * promise only resolves once the child has actually exited (confirmed via the `exit` event, not
308
+ * merely once `SIGKILL` was sent — the kernel needs a moment to reap it), with a final safety-net
309
+ * timeout so a stuck child can never keep the event loop alive even if that confirmation is
310
+ * somehow lost.
311
+ *
312
+ * @protected
313
+ */
314
+ async _finishTest() {
315
+ try {
316
+ await super._finishTest()
317
+ } finally {
318
+ if (this.serverProcess) {
319
+ const proc = this.serverProcess
320
+ this.serverProcess = null
321
+ await new Promise(resolve => {
322
+ if (proc.exitCode !== null || proc.signalCode !== null) {
323
+ resolve()
324
+ return
325
+ }
326
+ let settled = false
327
+ const finish = () => {
328
+ if (settled) return
329
+ settled = true
330
+ clearTimeout(killTimer)
331
+ clearTimeout(safetyTimer)
332
+ resolve()
333
+ }
334
+ proc.once('exit', finish)
335
+ const killTimer = setTimeout(() => proc.kill('SIGKILL'), 5000)
336
+ const safetyTimer = setTimeout(finish, 5500)
337
+ proc.kill()
338
+ })
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ export default Obscura
@@ -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 checkableRoles = ['checkbox', 'radio', 'switch']
53
54
 
54
55
  import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
55
56
  import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
@@ -2404,6 +2405,10 @@ class Playwright extends Helper {
2404
2405
  els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
2405
2406
  if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2406
2407
 
2408
+ // Fuzzy: try radiogroup
2409
+ els = await findByRole(comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
2410
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2411
+
2407
2412
  // Fuzzy: try native select
2408
2413
  els = await findFields.call(this, select, context)
2409
2414
  assertElementExists(els, select, 'Selectable element')
@@ -3423,7 +3428,6 @@ class Playwright extends Helper {
3423
3428
  */
3424
3429
  async waitInUrl(urlPart, sec = null) {
3425
3430
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3426
- const expectedUrl = resolveUrl(urlPart, this.options.url)
3427
3431
 
3428
3432
  return this.page
3429
3433
  .waitForFunction(
@@ -3431,13 +3435,13 @@ class Playwright extends Helper {
3431
3435
  const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3432
3436
  return currUrl.indexOf(urlPart) > -1
3433
3437
  },
3434
- expectedUrl,
3438
+ urlPart,
3435
3439
  { timeout: waitTimeout },
3436
3440
  )
3437
3441
  .catch(async e => {
3438
3442
  const currUrl = await this._getPageUrl()
3439
3443
  if (/Timeout/i.test(e.message)) {
3440
- throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
3444
+ throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
3441
3445
  } else {
3442
3446
  throw e
3443
3447
  }
@@ -4385,6 +4389,17 @@ async function findCheckable(locator, context) {
4385
4389
  return findElements.call(this, contextEl, matchedLocator)
4386
4390
  }
4387
4391
 
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
+
4388
4403
  const literal = xpathLocator.literal(matchedLocator.value)
4389
4404
  let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
4390
4405
  if (els.length) {
@@ -4477,6 +4492,18 @@ async function proceedSelect(context, el, option) {
4477
4492
  return this._waitForAction()
4478
4493
  }
4479
4494
 
4495
+ if (role === 'radiogroup') {
4496
+ if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
4497
+ const [opt] = options
4498
+ let optEl = el.getByRole('radio', { name: opt, exact: true }).first()
4499
+ if (!(await optEl.count())) optEl = el.getByRole('radio', { name: opt }).first()
4500
+ if (!(await optEl.count())) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
4501
+ this.debugSection('SelectOption', `Clicking: "${opt}"`)
4502
+ await highlightActiveElement.call(this, optEl)
4503
+ await optEl.click()
4504
+ return this._waitForAction()
4505
+ }
4506
+
4480
4507
  await highlightActiveElement.call(this, el)
4481
4508
  let optionToSelect = option
4482
4509
  try {