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.
- package/docs/alternative-browsers.md +153 -0
- package/docs/basics.md +9 -1
- package/docs/configuration.md +2 -0
- package/docs/helpers/CDPBrowser.md +2138 -0
- package/docs/helpers/Kitesurf.md +118 -0
- package/docs/helpers/Obscura.md +210 -0
- package/docs/migration-4.md +3 -1
- package/docs/parallel.md +10 -0
- package/docs/plugins/screencast.md +18 -13
- package/docs/plugins.md +1 -1
- package/lib/command/info.js +11 -3
- package/lib/command/workers/runTests.js +14 -20
- package/lib/container.js +6 -0
- package/lib/data/context.js +4 -0
- package/lib/element/WebElement.js +5 -0
- package/lib/helper/Appium.js +14 -2
- package/lib/helper/CDPBrowser.js +3004 -0
- package/lib/helper/Kitesurf.js +139 -0
- package/lib/helper/Obscura.js +344 -0
- package/lib/helper/Playwright.js +30 -3
- package/lib/helper/Puppeteer.js +43 -16
- package/lib/helper/WebDriver.js +43 -8
- package/lib/helper/clientscripts/cdpBrowserClient.js +486 -0
- package/lib/helper/clientscripts/xpathPolyfill.js +31 -0
- package/lib/helper/extras/CDPConnection.js +92 -0
- package/lib/helper/extras/CDPElementHandle.js +27 -0
- package/lib/helper/extras/apngAssembler.js +156 -0
- package/lib/html.js +9 -2
- package/lib/listener/retryEnhancer.js +2 -1
- package/lib/listener/steps.js +8 -0
- package/lib/mocha/hooks.js +10 -0
- package/lib/parser.js +14 -2
- package/lib/plugin/junitReporter.js +17 -1
- package/lib/plugin/screencast.js +116 -24
- package/lib/step/base.js +15 -3
- package/lib/utils/loaderCheck.js +6 -0
- package/lib/utils.js +1 -1
- package/lib/workers.js +17 -0
- package/package.json +4 -1
- package/typings/promiseBasedTypes.d.ts +1833 -0
- 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
|
package/lib/helper/Playwright.js
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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 {
|