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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Bunoshfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,13 @@ async function docsHelperMarkdown(name, { parent, exclude = [], excludeConfig =
const documentation = await import('documentation')
const buildOptions = { shallow: true, sortOrder: ['alpha'] }
const doc = await documentation.build([`docs/build/${name}.js`], buildOptions)
doc.sort((a, b) => (b.kind === 'class') - (a.kind === 'class'))
let members = doc[0].members.instance

if (parent) {
const parentDoc = await documentation.build([`docs/build/${parent}.js`], buildOptions)
for (const method of parentDoc[0].members.instance) {
const parentClass = parentDoc.find(c => c.kind === 'class')
for (const method of parentClass.members.instance) {
if (exclude.some(f => method.name.match(f))) continue
if (members.some(m => m.name === method.name)) continue
members.push(method)
Expand Down
1,474 changes: 551 additions & 923 deletions docs/helpers/Kitesurf.md

Large diffs are not rendered by default.

1,569 changes: 567 additions & 1,002 deletions docs/helpers/Obscura.md

Large diffs are not rendered by default.

1,508 changes: 118 additions & 1,390 deletions lib/helper/CDPBrowser.js

Large diffs are not rendered by default.

24 changes: 3 additions & 21 deletions lib/helper/Kitesurf.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import CDPBrowser from './CDPBrowser.js'
/**
* ## Configuration
*
* This helper should be configured in codecept.conf.js. It accepts everything `CDPBrowser`
* accepts (see its config table), plus:
* This helper should be configured in codecept.conf.js
*
* @typedef KitesurfConfig
* @type {object}
Expand All @@ -14,8 +13,8 @@ import CDPBrowser from './CDPBrowser.js'
* @prop {string} [apiToken] - Cloudflare API token; defaults to CF_API_TOKEN env var
* @prop {number} [keepAlive=240000] - session keep-alive time in milliseconds
* @prop {string} [apiBase=https://api.cloudflare.com/client/v4] - Cloudflare API base URL
* @prop {string} [input=cdp] - input method for user actions; defaults to 'cdp' for Kitesurf's real layout engine, but can be overridden
* @prop {object} [capabilities] - pre-configured capabilities; Kitesurf uses { layout: 'real', screenshot: true }
* @prop {string} [input=cdp] - how actions are dispatched: `cdp` (real mouse and keyboard events) or `synthetic` (DOM events).
* @prop {object} [capabilities] - browser capabilities; Kitesurf sets `{ layout: 'real', screenshot: true }`.
*/
const config = {}

Expand Down Expand Up @@ -90,14 +89,6 @@ class Kitesurf extends CDPBrowser {
this.cloudSessionId = null
}

/**
* Acquires a Kitesurf browser session from the Cloudflare Browser Run API and resolves it to
* the `wss://` debugger URL `CDPConnection` connects to. Overrides `CDPBrowser._resolveEndpoint`,
* which resolves a fixed local endpoint instead of provisioning a cloud session per test.
*
* @returns {Promise<string>} a `wss://` debugger URL ready to be passed to `CDPConnection`.
* @protected
*/
async _resolveEndpoint() {
if (!this.options.accountId || !this.options.apiToken) {
throw new Error('Kitesurf requires accountId and apiToken (or CF_ACCOUNT_ID / CF_API_TOKEN env vars)')
Expand All @@ -113,15 +104,6 @@ class Kitesurf extends CDPBrowser {
return data.webSocketDebuggerUrl
}

/**
* Closes the target as `CDPBrowser._finishTest` does, then releases the cloud session acquired
* in `_resolveEndpoint` via the Cloudflare API so it does not linger for the full `keepAlive`
* window. The release runs in a `finally` so a rejection while closing the CDP connection still
* frees the cloud session instead of leaving the browser alive until `keepAlive` expires; the
* session id is cleared before the request, so a repeated call never releases it twice.
*
* @protected
*/
async _finishTest() {
try {
await super._finishTest()
Expand Down
107 changes: 15 additions & 92 deletions lib/helper/Obscura.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,20 @@ import { isFile, isWindows } from '../utils.js'
*
* @typedef ObscuraConfig
* @type {object}
* @prop {string} [endpoint] - explicit CDP endpoint. Setting this switches the helper to ATTACH
* mode: it only connects, and never spawns or kills a process, no matter what else is configured.
* Leave it unset for SELF-MANAGED mode (see below).
* @prop {string} [binaryPath] - path to the `obscura` executable, used in SELF-MANAGED mode
* (`endpoint` unset). Checked before `OBSCURA_PATH` and `PATH`.
* @prop {number} [port] - port `obscura serve` listens on, in SELF-MANAGED mode. When unset, a
* free port is picked automatically, which is what makes `run-workers` collision-free — every
* worker gets its own instance on its own port with zero config.
* @prop {number} [serverStartTimeout=15000] - milliseconds to wait for a spawned `obscura serve`
* to answer `/json/version` before `_connect` gives up.
* @prop {string} [endpoint] - connect to a running Obscura instead of starting one (ATTACH mode).
* @prop {string} [binaryPath] - path to the `obscura` executable. Checked before `OBSCURA_PATH` and `PATH`.
* @prop {number} [port] - port for `obscura serve`. A free port is picked when unset, so parallel workers never collide.
* @prop {number} [serverStartTimeout=15000] - how long to wait for `obscura serve` to start, in milliseconds.
*/
const config = {}

/**
* Obscura drives [Obscura](https://github.com/h4ckf0r0day/obscura), a minimal headless
* browser exposed over the Chrome DevTools Protocol. From v0.2.0, default release builds ship a
* real rendering engine (layout, paint, screenshots); `-no-render` variants and v0.1.x builds keep
* the original single-V8-isolate, nothing-rendered mode. This helper does not hardcode which mode a
* given binary is in — `CDPBrowser._probeCapabilities` detects `layout`/`screenshot` per binary at
* runtime, so the same helper works against either.
* Obscura drives [Obscura](https://github.com/h4ckf0r0day/obscura), a lightweight headless
* browser controlled over the Chrome DevTools Protocol. Default builds from v0.2.0 render pages
* (layout, screenshots); `-no-render` builds and v0.1.x run JavaScript and the DOM only. The helper
* detects which build it runs against, so the same config works for both.
*
* This helper is a thin `CDPBrowser` subclass: it changes nothing about how locating or acting on
* elements works, it only pins the config presets Obscura requires and manages the `obscura serve`
* process lifecycle, the same way Playwright manages its own browser process.
* The helper starts and stops `obscura serve` for you, the same way Playwright manages its browser.
*
* > Obscura support is experimental in CodeceptJS 4.2. Pin the browser version in CI and keep a
* > Playwright/WebDriver job for browser-compatibility coverage.
Expand All @@ -56,8 +46,8 @@ const config = {}
* - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
* the config, then the `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The helper
* spawns `obscura serve --port <port> --allow-private-network --allow-file-access` (`port` from
* the config, or a free port picked automatically), waits for it to answer, connects, and kills
* it in `_finishTest`.
* the config, or a free port picked automatically), waits for it to answer, connects, and stops
* it when tests finish.
* - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
* answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
* before this process ever ran). The helper attaches to it and never kills it — it isn't the
Expand Down Expand Up @@ -96,13 +86,11 @@ const config = {}
*
* | option | value | why |
* | --- | --- | --- |
* | `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 |
* | `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 |
* | `input` | `synthetic` | clicks that navigate are unreliable in Obscura, so `click` behaves like `forceClick` |
* | `xpathPolyfill` | `auto` | Obscura's XPath lacks attribute selection and `not()`, so the polyfill is used when needed |
*
* `capabilities.layout`/`capabilities.screenshot`/`capabilities.xpath` are intentionally left
* unset here — `CDPBrowser._probeCapabilities` detects them at runtime from the actual binary
* (`'real'`/`true` on v0.2.0+ default builds, `'none'`/`false` on `-no-render` builds and v0.1.x).
* Set them explicitly in your own config to skip probing or to force a mode.
* `capabilities` are detected at runtime from the Obscura build. Set them in your config to skip
* detection or to force a mode.
*
* ## Limitations
*
Expand Down Expand Up @@ -168,17 +156,6 @@ class Obscura extends CDPBrowser {
this._selfManagedResolved = false
}

/**
* In ATTACH mode, connects exactly as `CDPBrowser._connect` would. In SELF-MANAGED mode,
* resolves and spawns `obscura serve` (or courtesy-attaches to an already-running one on
* :9222) exactly once via `_resolveSelfManaged`, then connects.
*
* A spawn failure (e.g. a bad binary) is delivered asynchronously by Node as an `error`
* event; it is recorded on `this.serverError` and surfaced as a rejection from `_waitForServer`
* instead of crashing the process as an uncaught exception.
*
* @protected
*/
async _connect() {
if (this.mode !== 'attach' && !this._selfManagedResolved) {
await this._resolveSelfManaged()
Expand All @@ -187,14 +164,6 @@ class Obscura extends CDPBrowser {
return super._connect()
}

/**
* Resolves how to reach Obscura when no explicit `endpoint` was configured, trying, in order:
* spawn a binary (`binaryPath` config, then `OBSCURA_PATH` env, then `obscura` on `PATH`),
* courtesy-attach to `http://127.0.0.1:9222` if something already answers there, or throw a
* loud, actionable error. Sets `this.options.endpoint` as a side effect.
*
* @protected
*/
async _resolveSelfManaged() {
const binaryPath = this._resolveBinary()
if (binaryPath) {
Expand Down Expand Up @@ -225,15 +194,6 @@ class Obscura extends CDPBrowser {
)
}

/**
* Resolves the `obscura` binary to spawn, in priority order: `options.binaryPath`, then the
* `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The `PATH` lookup walks the
* directories itself instead of shelling out to `which`, which does not exist on Windows: on
* Windows every `PATHEXT` suffix is tried, so an `obscura.exe` on `PATH` is found too.
*
* @returns {string|null} an absolute or relative path to the binary, or null if none resolved.
* @protected
*/
_resolveBinary() {
if (this.options.binaryPath) return this.options.binaryPath
if (process.env.OBSCURA_PATH) return process.env.OBSCURA_PATH
Expand All @@ -256,14 +216,6 @@ class Obscura extends CDPBrowser {
return null
}

/**
* Picks a free TCP port on 127.0.0.1 by briefly listening on port 0 and reading back the OS-assigned
* port. Used as the SELF-LAUNCH default when `options.port` isn't explicitly set, so multiple
* `run-workers` workers never collide on the same port.
*
* @returns {Promise<number>} a free port.
* @protected
*/
async _findFreePort() {
return new Promise((resolve, reject) => {
const srv = net.createServer()
Expand All @@ -276,13 +228,6 @@ class Obscura extends CDPBrowser {
})
}

/**
* Probes a `/json/version`-style URL with a short timeout, used for the COURTESY-ATTACH check.
*
* @param {string} url
* @returns {Promise<boolean>} true if the URL answered.
* @protected
*/
async _probeUp(url) {
try {
await axios.get(url, { timeout: 1000 })
Expand All @@ -292,15 +237,6 @@ class Obscura extends CDPBrowser {
}
}

/**
* Polls `http://127.0.0.1:<port>/json/version` until `obscura serve` responds, `this.serverError`
* is set by the spawned process' `error` event, or `options.serverStartTimeout` elapses. The
* process typically comes up within tens of milliseconds — a 20ms retry interval (down from a
* previous 200ms) keeps the wasted tail after the server is actually ready small, since this cost
* is paid once per run and counts directly toward real-world startup latency.
*
* @protected
*/
async _waitForServer() {
const timeout = this.options.serverStartTimeout || 15000
const deadline = Date.now() + timeout
Expand All @@ -321,19 +257,6 @@ class Obscura extends CDPBrowser {
throw new Error(`obscura serve did not start on port ${this.options.port} within ${timeout}ms`)
}

/**
* Closes the CDP connection (via `CDPBrowser._finishTest`), then kills the `obscura serve`
* process spawned by `_connect`, if any (never runs in ATTACH or COURTESY-ATTACH mode, since
* `this.serverProcess` is only ever set in SELF-LAUNCH mode). Runs in a `finally` so the process
* is always reaped even if closing the CDP connection throws. Sends `SIGTERM` first and waits for
* the process to exit; a process that ignores `SIGTERM` is escalated to `SIGKILL` after 5s. The
* promise only resolves once the child has actually exited (confirmed via the `exit` event, not
* merely once `SIGKILL` was sent — the kernel needs a moment to reap it), with a final safety-net
* timeout so a stuck child can never keep the event loop alive even if that confirmation is
* somehow lost.
*
* @protected
*/
async _finishTest() {
try {
await super._finishTest()
Expand Down
4 changes: 3 additions & 1 deletion runok.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -430,11 +430,13 @@ title: ${name}
const documentation = await import('documentation')
const buildOptions = { shallow: true, sortOrder: ['alpha'] }
const doc = await documentation.build([`docs/build/${name}.js`], buildOptions)
doc.sort((a, b) => (b.kind === 'class') - (a.kind === 'class'))
let members = doc[0].members.instance

if (parent) {
const parentDoc = await documentation.build([`docs/build/${parent}.js`], buildOptions)
for (const method of parentDoc[0].members.instance) {
const parentClass = parentDoc.find(c => c.kind === 'class')
for (const method of parentClass.members.instance) {
if (exclude.some(f => method.name.match(f))) continue
if (members.some(m => m.name === method.name)) continue
members.push(method)
Expand Down
Loading