codeceptjs 4.2.0-beta.4 → 4.2.1-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 (65) hide show
  1. package/README.md +2 -0
  2. package/bin/codecept.js +11 -0
  3. package/docs/agents.md +26 -1
  4. package/docs/ai.md +57 -0
  5. package/docs/alternative-browsers.md +175 -148
  6. package/docs/assertions.md +74 -0
  7. package/docs/bootstrap.md +2 -0
  8. package/docs/commands.md +45 -0
  9. package/docs/configuration.md +37 -0
  10. package/docs/debugging.md +1 -1
  11. package/docs/helpers/ApiDataFactory.md +20 -20
  12. package/docs/helpers/Appium.md +1615 -670
  13. package/docs/helpers/Decision.md +146 -0
  14. package/docs/helpers/GraphQL.md +31 -31
  15. package/docs/helpers/GraphQLDataFactory.md +20 -20
  16. package/docs/helpers/Kitesurf.md +1808 -25
  17. package/docs/helpers/Lightpanda.md +139 -0
  18. package/docs/helpers/Obscura.md +1799 -74
  19. package/docs/helpers/Playwright.md +490 -428
  20. package/docs/helpers/Puppeteer.md +317 -255
  21. package/docs/helpers/REST.md +43 -43
  22. package/docs/helpers/WebDriver.md +237 -175
  23. package/docs/installation.md +16 -0
  24. package/docs/mcp.md +7 -1
  25. package/docs/parallel.md +35 -1
  26. package/docs/plugins/pause.md +2 -1
  27. package/docs/plugins/sitemap.md +48 -0
  28. package/docs/plugins.md +4 -0
  29. package/lib/ai.js +81 -2
  30. package/lib/codecept.js +32 -2
  31. package/lib/command/init.js +22 -0
  32. package/lib/command/initAgent.js +78 -0
  33. package/lib/command/run-multiple.js +3 -0
  34. package/lib/command/run-workers.js +10 -3
  35. package/lib/command/run.js +3 -2
  36. package/lib/command/utils.js +15 -0
  37. package/lib/command/workers/runTests.js +10 -10
  38. package/lib/container.js +4 -2
  39. package/lib/effects.js +19 -1
  40. package/lib/helper/Appium.js +23 -0
  41. package/lib/helper/CDPBrowser.js +387 -1338
  42. package/lib/helper/Decision.js +256 -0
  43. package/lib/helper/Kitesurf.js +3 -21
  44. package/lib/helper/Lightpanda.js +137 -0
  45. package/lib/helper/Obscura.js +21 -238
  46. package/lib/helper/Playwright.js +50 -4
  47. package/lib/helper/Puppeteer.js +62 -8
  48. package/lib/helper/WebDriver.js +45 -1
  49. package/lib/helper/extras/clipboard.js +31 -0
  50. package/lib/listener/result.js +4 -0
  51. package/lib/mocha/bdd.js +19 -1
  52. package/lib/plugin/junitReporter.js +4 -1
  53. package/lib/plugin/pause.js +4 -1
  54. package/lib/plugin/sitemap.js +228 -0
  55. package/lib/recorder.js +19 -18
  56. package/lib/result.js +16 -0
  57. package/lib/step/base.js +12 -0
  58. package/lib/utils.js +2 -10
  59. package/lib/webServer.js +193 -0
  60. package/lib/workers.js +118 -31
  61. package/package.json +4 -2
  62. package/typings/index.d.ts +60 -7
  63. package/typings/promiseBasedTypes.d.ts +985 -814
  64. package/typings/types.d.ts +1084 -815
  65. package/docs/helpers/CDPBrowser.md +0 -2138
@@ -0,0 +1,256 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+
4
+ import Helper from '@codeceptjs/helper'
5
+ import Container from '../container.js'
6
+ import Config from '../config.js'
7
+ import store from '../store.js'
8
+ import output from '../output.js'
9
+ import AssertionFailedError from '../assert/error.js'
10
+ import { compactAriaSnapshot } from '../aria.js'
11
+ import { minifyHtml } from '../html.js'
12
+ import { pickActingHelper } from '../utils/trace.js'
13
+ import { DecisionAI, DecisionConnectionError } from '../ai.js'
14
+
15
+ /**
16
+ * Asserts statements about the current page with a decision model.
17
+ *
18
+ * Decision models (like [Jev](https://openrouter.ai/typesafe/jev-1.13) or [Clef](https://openrouter.ai/cloudflare/clef))
19
+ * do not generate text. They read the page state and return the probability that a statement is true.
20
+ * A statement passes when its probability reaches the configured `confidence`.
21
+ *
22
+ * ```js
23
+ * I.decide('top level navigation is available');
24
+ * I.decide([
25
+ * 'checkout form has all required fields',
26
+ * 'success message is shown',
27
+ * 'submit button enabled',
28
+ * 'cancel button present',
29
+ * ]);
30
+ * I.decideVisually('sidebar is shown');
31
+ * ```
32
+ *
33
+ * The model receives the page URL, title and ARIA snapshot.
34
+ * Helpers without ARIA snapshots (anything but Playwright) send minified HTML instead.
35
+ * `decideVisually` also sends a screenshot, so it requires a model with image input.
36
+ *
37
+ * This helper must be enabled together with a web helper (Playwright, Puppeteer, WebDriver).
38
+ *
39
+ * ## Configuration
40
+ *
41
+ * Decision model is created with the OpenRouter provider from `@openrouter/ai-sdk-provider`
42
+ * and configured in the `ai.decisionModel` section of the config:
43
+ *
44
+ * ```js
45
+ * import { createOpenRouter } from '@openrouter/ai-sdk-provider'
46
+ *
47
+ * const openrouter = createOpenRouter({
48
+ * apiKey: process.env.OPENROUTER_API_KEY,
49
+ * })
50
+ *
51
+ * export default {
52
+ * ai: {
53
+ * decisionModel: {
54
+ * model: openrouter.evaluationModel('typesafe/jev-1.13'),
55
+ * confidence: 0.8,
56
+ * },
57
+ * },
58
+ * helpers: {
59
+ * Playwright: { url: 'http://localhost' },
60
+ * Decision: {},
61
+ * },
62
+ * }
63
+ * ```
64
+ *
65
+ * * `model` - decision model used by `decide`.
66
+ * * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass.
67
+ * * `timeout` (default: `15000`) - request timeout in ms.
68
+ * * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model.
69
+ *
70
+ * `decideVisually` uses a separate decision model with image input, configured as `visualModel`:
71
+ *
72
+ * ```js
73
+ * decisionModel: {
74
+ * model: openrouter.evaluationModel('typesafe/jev-1.13'),
75
+ * visualModel: openrouter.evaluationModel('cloudflare/clef'),
76
+ * },
77
+ * ```
78
+ *
79
+ * The helper has one option:
80
+ *
81
+ * * `mode` (default: `assert`) - how decisions are executed:
82
+ * * `assert` - request the model and fail the step when a statement is not confirmed.
83
+ * * `report` - request the model but never fail, even on API errors. Results and errors are added to the step as a comment.
84
+ * * `skip` - do not request the model, every decision passes.
85
+ *
86
+ * ```js
87
+ * Decision: { mode: process.env.CI ? 'assert' : 'skip' }
88
+ * ```
89
+ *
90
+ * Decisions work without the `--ai` flag.
91
+ * Browse all decision models at [OpenRouter](https://openrouter.ai/models?output_modalities=decisions).
92
+ *
93
+ * ## Methods
94
+ */
95
+ class Decision extends Helper {
96
+ constructor(config = {}) {
97
+ super(config)
98
+ const modes = ['assert', 'report', 'skip']
99
+ this.mode = config.mode || 'assert'
100
+ if (!modes.includes(this.mode)) throw new Error(`Unknown Decision helper mode "${this.mode}", use one of: ${modes.join(', ')}`)
101
+ this.decisionAI = new DecisionAI(Config.get('ai', {}).decisionModel)
102
+ this.options = this.decisionAI.config
103
+ }
104
+
105
+ get isAsserting() {
106
+ return this.mode === 'assert'
107
+ }
108
+
109
+ get isReporting() {
110
+ return this.mode === 'report'
111
+ }
112
+
113
+ get isSkipping() {
114
+ return this.mode === 'skip'
115
+ }
116
+
117
+ /**
118
+ * Asserts that a statement (or each of statements) is true for the current page.
119
+ * The decision model receives page URL, title and ARIA snapshot.
120
+ * Multiple statements are checked in a single request and all of them must pass.
121
+ *
122
+ * ```js
123
+ * I.decide('user is logged in');
124
+ * I.decide(['submit button enabled', 'cancel button present']);
125
+ * const probability = await I.decide('cart is empty');
126
+ * ```
127
+ *
128
+ * @param {string|string[]} statements statement or list of statements to verify.
129
+ * @returns {Promise<number|number[]|undefined>} probability of each statement, `undefined` in `skip` mode.
130
+ */
131
+ async decide(statements) {
132
+ if (this.isSkipping) return this._skip()
133
+ return this._run(async () => {
134
+ const state = await this._grabState()
135
+ return this._assert(statements, 'model', state)
136
+ })
137
+ }
138
+
139
+ /**
140
+ * Asserts that a statement (or each of statements) is true for the current page using a screenshot.
141
+ * The decision model receives page URL, title, ARIA snapshot and a screenshot.
142
+ * Requires `visualModel` with image input. Experimental.
143
+ *
144
+ * ```js
145
+ * I.decideVisually('sidebar is shown');
146
+ * I.decideVisually(['logo is in the header', 'page uses dark theme']);
147
+ * ```
148
+ *
149
+ * @param {string|string[]} statements statement or list of statements to verify.
150
+ * @returns {Promise<number|number[]|undefined>} probability of each statement, `undefined` in `skip` mode.
151
+ */
152
+ async decideVisually(statements) {
153
+ if (this.isSkipping) return this._skip()
154
+ return this._run(async () => {
155
+ const state = await this._grabState()
156
+ const screenshot = await this._grabScreenshot()
157
+ const content = [
158
+ { type: 'text', text: JSON.stringify(state) },
159
+ { type: 'image_url', image_url: { url: `data:image/png;base64,${screenshot}` } },
160
+ ]
161
+ return this._assert(statements, 'visualModel', content)
162
+ })
163
+ }
164
+
165
+ _skip() {
166
+ if (store.currentStep) store.currentStep.comment = ' (skipped)'
167
+ }
168
+
169
+ async _run(fn) {
170
+ try {
171
+ return await fn()
172
+ } catch (err) {
173
+ if (this.isReporting) {
174
+ if (store.currentStep) store.currentStep.comment = ` (error: ${err.message})`
175
+ output.say(` ✖ ${err.message}`, 'yellow')
176
+ return
177
+ }
178
+ if (!(err instanceof DecisionConnectionError)) err.isTerminal = true
179
+ throw err
180
+ }
181
+ }
182
+
183
+ async _assert(statements, option, state) {
184
+ const list = [statements].flat()
185
+ if (!list.length) throw new Error('No statements to decide')
186
+
187
+ const probabilities = await this.decisionAI.decide(option, state, list)
188
+
189
+ const failed = []
190
+ const results = list.map((statement, i) => {
191
+ const passed = probabilities[i] >= this.options.confidence
192
+ const result = `${passed ? '✔' : '✖'} ${statement} (${formatProbability(probabilities[i])})`
193
+ this.debugSection('Decision', result)
194
+ if (!passed) failed.push(`"${statement}" (${formatProbability(probabilities[i])})`)
195
+ return result
196
+ })
197
+
198
+ if (this.isReporting) {
199
+ if (store.currentStep) store.currentStep.comment = `\n${results.join('\n')}`
200
+ results.forEach(result => output.say(` ${result}`, result.startsWith('✔') ? 'green' : 'yellow'))
201
+ return Array.isArray(statements) ? probabilities : probabilities[0]
202
+ }
203
+
204
+ if (failed.length) {
205
+ const err = new AssertionFailedError(
206
+ { statements: failed.join(', '), confidence: formatProbability(this.options.confidence) },
207
+ 'expected page to satisfy {{statements}} with confidence of {{confidence}}',
208
+ )
209
+ err.showDiff = false
210
+ err.message = err.cliMessage()
211
+ throw err
212
+ }
213
+
214
+ return Array.isArray(statements) ? probabilities : probabilities[0]
215
+ }
216
+
217
+ async _grabState() {
218
+ const helper = this._actingHelper()
219
+ const state = {
220
+ url: await helper.grabCurrentUrl(),
221
+ title: await helper.grabTitle(),
222
+ }
223
+
224
+ if (helper.grabAriaSnapshot) {
225
+ state.aria = compactAriaSnapshot(await helper.grabAriaSnapshot()).slice(0, this.options.maxLength)
226
+ return state
227
+ }
228
+
229
+ state.html = (await minifyHtml(await helper.grabSource())).slice(0, this.options.maxLength)
230
+ return state
231
+ }
232
+
233
+ async _grabScreenshot() {
234
+ const helper = this._actingHelper()
235
+ const file = path.join(store.outputDir, `decision_${Date.now()}.png`)
236
+ await helper.saveScreenshot(file)
237
+ if (!fs.existsSync(file)) throw new Error('Could not take a screenshot for visual decision')
238
+ try {
239
+ return fs.readFileSync(file).toString('base64')
240
+ } finally {
241
+ fs.rmSync(file, { force: true })
242
+ }
243
+ }
244
+
245
+ _actingHelper() {
246
+ const helper = pickActingHelper(Container.helpers())
247
+ if (!helper) throw new Error('Decision helper requires a web helper: Playwright, Puppeteer or WebDriver')
248
+ return helper
249
+ }
250
+ }
251
+
252
+ function formatProbability(probability) {
253
+ return `${Math.round(probability * 100)}%`
254
+ }
255
+
256
+ export default Decision
@@ -4,8 +4,7 @@ import CDPBrowser from './CDPBrowser.js'
4
4
  /**
5
5
  * ## Configuration
6
6
  *
7
- * This helper should be configured in codecept.conf.js. It accepts everything `CDPBrowser`
8
- * accepts (see its config table), plus:
7
+ * This helper should be configured in codecept.conf.js
9
8
  *
10
9
  * @typedef KitesurfConfig
11
10
  * @type {object}
@@ -14,8 +13,8 @@ import CDPBrowser from './CDPBrowser.js'
14
13
  * @prop {string} [apiToken] - Cloudflare API token; defaults to CF_API_TOKEN env var
15
14
  * @prop {number} [keepAlive=240000] - session keep-alive time in milliseconds
16
15
  * @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 }
16
+ * @prop {string} [input=cdp] - how actions are dispatched: `cdp` (real mouse and keyboard events) or `synthetic` (DOM events).
17
+ * @prop {object} [capabilities] - browser capabilities; Kitesurf sets `{ layout: 'real', screenshot: true }`.
19
18
  */
20
19
  const config = {}
21
20
 
@@ -90,14 +89,6 @@ class Kitesurf extends CDPBrowser {
90
89
  this.cloudSessionId = null
91
90
  }
92
91
 
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
92
  async _resolveEndpoint() {
102
93
  if (!this.options.accountId || !this.options.apiToken) {
103
94
  throw new Error('Kitesurf requires accountId and apiToken (or CF_ACCOUNT_ID / CF_API_TOKEN env vars)')
@@ -113,15 +104,6 @@ class Kitesurf extends CDPBrowser {
113
104
  return data.webSocketDebuggerUrl
114
105
  }
115
106
 
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
107
  async _finishTest() {
126
108
  try {
127
109
  await super._finishTest()
@@ -0,0 +1,137 @@
1
+ import CDPBrowser from './CDPBrowser.js'
2
+
3
+ /**
4
+ * ## Configuration
5
+ *
6
+ * This helper should be configured in codecept.conf.js
7
+ *
8
+ * @typedef LightpandaConfig
9
+ * @type {object}
10
+ * @prop {string} [endpoint] - explicit CDP endpoint. Setting this switches the helper to ATTACH
11
+ * mode: it only connects, and never spawns or kills a process, no matter what else is configured.
12
+ * Leave it unset for SELF-MANAGED mode (see below).
13
+ * @prop {string} [binaryPath] - path to the `lightpanda` executable, used in SELF-MANAGED mode
14
+ * (`endpoint` unset). Checked before `LIGHTPANDA_PATH` and `PATH`.
15
+ * @prop {number} [port] - port `lightpanda serve` listens on, in SELF-MANAGED mode. When unset, a
16
+ * free port is picked automatically, which is what makes `run-workers` collision-free — every
17
+ * worker gets its own instance on its own port with zero config.
18
+ * @prop {number} [serverStartTimeout=15000] - milliseconds to wait for a spawned `lightpanda serve`
19
+ * to answer `/json/version` before `_connect` gives up.
20
+ */
21
+ const config = {}
22
+
23
+ /**
24
+ * Lightpanda drives [Lightpanda](https://lightpanda.io), a headless browser written from scratch
25
+ * for automation, exposed over the Chrome DevTools Protocol. It executes real JavaScript in V8 and
26
+ * implements the DOM and Web APIs, but has no graphical rendering engine: nothing is ever painted,
27
+ * so there are no screenshots.
28
+ *
29
+ * This helper is a thin `CDPBrowser` subclass: it changes nothing about how locating or acting on
30
+ * elements works, it only pins the config presets Lightpanda requires and manages the
31
+ * `lightpanda serve` process lifecycle, the same way Playwright manages its own browser process.
32
+ *
33
+ * > Lightpanda support is experimental. Pin the browser version in CI and keep a
34
+ * > Playwright/WebDriver job for browser-compatibility coverage.
35
+ *
36
+ * ## Modes
37
+ *
38
+ * - **ATTACH** — `endpoint` is set explicitly in the config. The helper only connects to it; it
39
+ * never spawns or kills anything, no matter what `binaryPath`/`port` are set to.
40
+ * - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
41
+ * the config, then the `LIGHTPANDA_PATH` environment variable, then `lightpanda` on `PATH`. The
42
+ * helper spawns `lightpanda serve --host 127.0.0.1 --port <port>` (`port` from the config, or a
43
+ * free port picked automatically), waits for it to answer, connects, and kills it in
44
+ * `_finishTest`.
45
+ * - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
46
+ * answers `http://127.0.0.1:9222/json/version` (e.g. `lightpanda serve` started by hand, or the
47
+ * `lightpanda/browser` Docker image). The helper attaches to it and never kills it. If neither a
48
+ * binary nor a running server on :9222 can be found, the helper throws a loud, actionable error.
49
+ *
50
+ * ## Install
51
+ *
52
+ * Follow the [official installation instructions](https://lightpanda.io/docs/open-source/installation),
53
+ * then put `lightpanda` on your `PATH`, or point `binaryPath`/`LIGHTPANDA_PATH` at it. The helper
54
+ * then launches and tears it down automatically.
55
+ *
56
+ * Lightpanda sends usage telemetry by default. This helper always spawns it with
57
+ * `LIGHTPANDA_DISABLE_TELEMETRY=true`; set that variable yourself when you start the server by hand.
58
+ *
59
+ * Lightpanda is licensed under AGPL-3.0. It runs as a separate process that CodeceptJS talks to
60
+ * over a WebSocket, so it does not change the license of CodeceptJS or of your tests.
61
+ *
62
+ * ## Config presets
63
+ *
64
+ * These are set automatically and only need overriding for unusual setups:
65
+ *
66
+ * | option | value | why |
67
+ * | --- | --- | --- |
68
+ * | `input` | `synthetic` | nothing is painted, so coordinates are not hit-tested; `click` always takes the `forceClick` path |
69
+ * | `xpathPolyfill` | `auto` | probed per binary/page |
70
+ * | `capabilities.layout` | `real` | Lightpanda computes visibility-related styles and element boxes without painting, which is enough for `seeElement` and the `waitForVisible` family; it is pinned because the runtime layout probe cannot run on Lightpanda's `about:blank` |
71
+ * | `capabilities.screenshot` | `false` | there is no rendering engine |
72
+ *
73
+ * ## Limitations
74
+ *
75
+ * - No screenshots and no screencast: `saveScreenshot` throws.
76
+ * - No frames or popups.
77
+ * - No scrolling: scroll positions always stay at 0.
78
+ * - Computed styles cover what visibility checks need (`display`, `visibility`, `opacity`,
79
+ * `pointer-events`); `grabCssPropertyFrom` and `seeCssPropertiesOnElements` are unreliable for
80
+ * other properties.
81
+ * - No coordinate-based input (`clickXY`).
82
+ * - No clipboard access.
83
+ * - Some rich text editors (TinyMCE, Trix, Monaco) never finish initialising.
84
+ *
85
+ * <!-- configuration -->
86
+ *
87
+ * ## Example
88
+ *
89
+ * ```js
90
+ * // inside codecept.conf.js — SELF-LAUNCH mode (recommended): the helper finds/starts/stops
91
+ * // lightpanda serve on its own, on a free port. Ideal for run-workers: every worker gets its
92
+ * // own instance with no config.
93
+ * {
94
+ * helpers: {
95
+ * Lightpanda: {
96
+ * url: 'http://localhost',
97
+ * }
98
+ * }
99
+ * }
100
+ * ```
101
+ *
102
+ * ```js
103
+ * // ATTACH mode — connect to a Lightpanda instance you manage yourself (remote host, container, etc.)
104
+ * {
105
+ * helpers: {
106
+ * Lightpanda: {
107
+ * url: 'http://localhost',
108
+ * endpoint: 'http://127.0.0.1:9222',
109
+ * }
110
+ * }
111
+ * }
112
+ * ```
113
+ *
114
+ * ## Methods
115
+ */
116
+ class Lightpanda extends CDPBrowser {
117
+ /**
118
+ * @param {LightpandaConfig} config
119
+ */
120
+ constructor(config) {
121
+ super({
122
+ input: 'synthetic',
123
+ xpathPolyfill: 'auto',
124
+ ...config,
125
+ capabilities: { layout: 'real', screenshot: false, ...((config && config.capabilities) || {}) },
126
+ })
127
+ this._manageServer(config, {
128
+ name: 'lightpanda',
129
+ envVar: 'LIGHTPANDA_PATH',
130
+ args: port => ['serve', '--host', '127.0.0.1', '--port', String(port)],
131
+ env: { LIGHTPANDA_DISABLE_TELEMETRY: 'true' },
132
+ releases: 'https://github.com/lightpanda-io/browser/releases',
133
+ })
134
+ }
135
+ }
136
+
137
+ export default Lightpanda