codeceptjs 4.2.0 → 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.
- package/README.md +2 -0
- package/bin/codecept.js +11 -0
- package/docs/agents.md +26 -1
- package/docs/ai.md +57 -0
- package/docs/alternative-browsers.md +175 -148
- package/docs/assertions.md +74 -0
- package/docs/bootstrap.md +2 -0
- package/docs/commands.md +45 -0
- package/docs/configuration.md +37 -0
- package/docs/debugging.md +1 -1
- package/docs/helpers/ApiDataFactory.md +20 -20
- package/docs/helpers/Appium.md +1615 -670
- package/docs/helpers/Decision.md +146 -0
- package/docs/helpers/GraphQL.md +31 -31
- package/docs/helpers/GraphQLDataFactory.md +20 -20
- package/docs/helpers/Kitesurf.md +1808 -25
- package/docs/helpers/Lightpanda.md +139 -0
- package/docs/helpers/Obscura.md +1799 -74
- package/docs/helpers/Playwright.md +490 -428
- package/docs/helpers/Puppeteer.md +317 -255
- package/docs/helpers/REST.md +43 -43
- package/docs/helpers/WebDriver.md +237 -175
- package/docs/installation.md +16 -0
- package/docs/mcp.md +7 -1
- package/docs/parallel.md +35 -1
- package/docs/plugins/pause.md +2 -1
- package/docs/plugins/sitemap.md +48 -0
- package/docs/plugins.md +4 -0
- package/lib/ai.js +81 -2
- package/lib/codecept.js +32 -2
- package/lib/command/init.js +22 -0
- package/lib/command/initAgent.js +78 -0
- package/lib/command/run-multiple.js +3 -0
- package/lib/command/run-workers.js +10 -3
- package/lib/command/run.js +3 -2
- package/lib/command/utils.js +15 -0
- package/lib/command/workers/runTests.js +10 -10
- package/lib/container.js +4 -2
- package/lib/effects.js +19 -1
- package/lib/helper/CDPBrowser.js +349 -1384
- package/lib/helper/Decision.js +256 -0
- package/lib/helper/Kitesurf.js +3 -21
- package/lib/helper/Lightpanda.js +137 -0
- package/lib/helper/Obscura.js +21 -238
- package/lib/helper/Playwright.js +6 -4
- package/lib/helper/Puppeteer.js +12 -7
- package/lib/helper/WebDriver.js +2 -1
- package/lib/listener/result.js +4 -0
- package/lib/mocha/bdd.js +19 -1
- package/lib/plugin/junitReporter.js +4 -1
- package/lib/plugin/pause.js +4 -1
- package/lib/plugin/sitemap.js +228 -0
- package/lib/recorder.js +19 -18
- package/lib/result.js +16 -0
- package/lib/step/base.js +12 -0
- package/lib/utils.js +2 -10
- package/lib/webServer.js +193 -0
- package/lib/workers.js +118 -31
- package/package.json +4 -2
- package/typings/index.d.ts +60 -7
- package/typings/promiseBasedTypes.d.ts +772 -824
- package/typings/types.d.ts +866 -833
- package/docs/helpers/CDPBrowser.md +0 -2138
package/docs/installation.md
CHANGED
|
@@ -99,6 +99,22 @@ export const config = {
|
|
|
99
99
|
|
|
100
100
|
See [Alternative Browser Engines](/alternative-browsers) for connection modes and limitations, and the [Obscura helper reference](/helpers/Obscura) for every option.
|
|
101
101
|
|
|
102
|
+
## Lightpanda (experimental)
|
|
103
|
+
|
|
104
|
+
Lightpanda is a headless browser built for automation. Install it following the [official instructions](https://lightpanda.io/docs/open-source/installation), then enable the helper:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
export const config = {
|
|
108
|
+
helpers: {
|
|
109
|
+
Lightpanda: {
|
|
110
|
+
url: 'http://localhost:3000',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
See [Alternative Browser Engines](/alternative-browsers) for running an existing Playwright suite on Lightpanda.
|
|
117
|
+
|
|
102
118
|
## Appium (mobile)
|
|
103
119
|
|
|
104
120
|
Native iOS and Android testing. Appium speaks the WebDriver protocol, so CodeceptJS drives it through `webdriverio`:
|
package/docs/mcp.md
CHANGED
|
@@ -27,7 +27,13 @@ Two ways to launch the server:
|
|
|
27
27
|
|
|
28
28
|
## Configuration
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
For Claude Code, Codex, Cursor, or OpenCode, one command registers the server and installs the CodeceptJS skills:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx codeceptjs init:agent claude
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To set up the MCP server in another client (Claude Desktop, Continue, etc.), add it to the client config:
|
|
31
37
|
|
|
32
38
|
### Basic
|
|
33
39
|
|
package/docs/parallel.md
CHANGED
|
@@ -20,7 +20,7 @@ For anything more specific — your own grouping, a config per group, several br
|
|
|
20
20
|
npx codeceptjs run-workers 4
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Steps are not streamed to the console in this mode — output from separate threads can't be interleaved cleanly. While workers run, CodeceptJS sets `process.env.RUNS_WITH_WORKERS=true`, so plugins and helpers can branch on it. All `run` options work here too: `--grep "@smoke"`, `-c codecept.conf.js`, `--debug`, and the rest.
|
|
23
|
+
Steps are not streamed to the console in this mode — output from separate threads can't be interleaved cleanly. While workers run, CodeceptJS sets `process.env.RUNS_WITH_WORKERS=true`, so plugins and helpers can branch on it. All `run` options work here too: `--grep "@smoke"`, `-c codecept.conf.js`, `--debug`, and the rest. With `--grep`, only matching tests are spread across workers, so a narrow pattern may start fewer workers than requested.
|
|
24
24
|
|
|
25
25
|
By default, workers are created with a staggered delay of 200ms to prevent CPU spikes and stagger browser initializations. You can adjust this via `workerInitializationDelay` in your configuration.
|
|
26
26
|
|
|
@@ -40,6 +40,40 @@ npx codeceptjs run-workers 4 --by pool
|
|
|
40
40
|
|
|
41
41
|
`--suites` is shorthand for `--by suite`.
|
|
42
42
|
|
|
43
|
+
### Serial tests
|
|
44
|
+
|
|
45
|
+
Some tests can't share the stage: they change global settings, reset data, or toggle a feature flag that other tests read. Mark them with a tag and set the `serial` pattern in config or pass it with `--serial`:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
// codecept.conf.js
|
|
49
|
+
export const config = {
|
|
50
|
+
serial: '@serial',
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
Scenario('change account timezone @serial', ({ I }) => {
|
|
56
|
+
// ...
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`run-workers` keeps matching tests out of the parallel workers. When those workers finish, one more worker starts and runs the serial tests one by one. It is still a single run: `bootstrapAll`, `teardownAll` and `event.workers.result` fire once, and the exit code covers both parts.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Test distribution:
|
|
64
|
+
Worker 1: 14 tests
|
|
65
|
+
Worker 2: 13 tests
|
|
66
|
+
Worker 3: 4 tests (serial)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Pass `--serial` to set or override the pattern for a single run:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npx codeceptjs run-workers 3 --serial "@serial"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`serial` is matched against the full test title the same way `--grep` is, so a tag on a `Feature` makes all its scenarios serial. It works with every `--by` strategy and combines with `--grep`: only serial tests that also match grep are run. With [multiple browsers](#multiple-browsers) each profile gets its own serial worker, started one after another.
|
|
76
|
+
|
|
43
77
|
### Multiple browsers
|
|
44
78
|
|
|
45
79
|
Define browser profiles under `multiple` in `codecept.conf.js`:
|
package/docs/plugins/pause.md
CHANGED
|
@@ -25,7 +25,8 @@ plugins: {
|
|
|
25
25
|
|
|
26
26
|
#### `on=` modes
|
|
27
27
|
|
|
28
|
-
* **fail** — pause when a step fails (default)
|
|
28
|
+
* **fail** — pause when a step fails (default). Failures inside `tryTo` and
|
|
29
|
+
non-final `retryTo` attempts are ignored
|
|
29
30
|
* **test** — pause after each test
|
|
30
31
|
* **step** — pause before the first step (interactive walk-through)
|
|
31
32
|
* **file** — pause when execution reaches `path=...[;line=...]`
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
permalink: /plugins/sitemap
|
|
3
|
+
editLink: false
|
|
4
|
+
sidebar: auto
|
|
5
|
+
title: sitemap
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<!-- Generated by documentation.js. Update this documentation by updating the source code. -->
|
|
9
|
+
|
|
10
|
+
## sitemap
|
|
11
|
+
|
|
12
|
+
Collects all pages visited while tests are running and saves them as a [sitemap][1] XML file.
|
|
13
|
+
|
|
14
|
+
The plugin listens to browser navigation events, so it doesn't slow down test steps.
|
|
15
|
+
Page loads and SPA route changes (`history.pushState`) of the top-level page are recorded, iframes are ignored.
|
|
16
|
+
Every unique `http(s)` URL is added to the sitemap. URL fragments (`#...`) are dropped.
|
|
17
|
+
|
|
18
|
+
Supported helpers: Playwright, Puppeteer, and WebDriver with BiDi protocol enabled (default).
|
|
19
|
+
The sitemap is written once all tests finish.
|
|
20
|
+
|
|
21
|
+
#### Configuration
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
"plugins": {
|
|
25
|
+
"sitemap": {
|
|
26
|
+
"enabled": true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Possible config options:
|
|
32
|
+
|
|
33
|
+
* `file`: path to the sitemap file, relative to the output directory. Default: `sitemap.xml`, saved as `output/sitemap.xml`.
|
|
34
|
+
* `stripQuery`: remove query strings so `/posts?page=1` and `/posts?page=2` become one `/posts` entry. Set to `false` to keep them. Default: true.
|
|
35
|
+
|
|
36
|
+
CLI examples:
|
|
37
|
+
|
|
38
|
+
npx codeceptjs run -p sitemap
|
|
39
|
+
npx codeceptjs run -p sitemap:file=visited.xml
|
|
40
|
+
npx codeceptjs run -p sitemap:stripQuery=false
|
|
41
|
+
|
|
42
|
+
Works with `run-workers`: URLs from all workers are merged into a single sitemap.
|
|
43
|
+
|
|
44
|
+
### Parameters
|
|
45
|
+
|
|
46
|
+
* `config` **any**
|
|
47
|
+
|
|
48
|
+
[1]: https://www.sitemaps.org/protocol.html
|
package/docs/plugins.md
CHANGED
|
@@ -81,6 +81,10 @@ Saves screenshots from the browser at points triggered by `on=`.
|
|
|
81
81
|
|
|
82
82
|
Saves a screenshot when a test fails.
|
|
83
83
|
|
|
84
|
+
## [sitemap](/plugins/sitemap)
|
|
85
|
+
|
|
86
|
+
Collects all pages visited while tests are running and saves them as a [sitemap][1] XML file.
|
|
87
|
+
|
|
84
88
|
## [stepTimeout](/plugins/stepTimeout)
|
|
85
89
|
|
|
86
90
|
Set timeout for test steps globally.
|
package/lib/ai.js
CHANGED
|
@@ -3,7 +3,7 @@ const debug = debugModule('codeceptjs:ai')
|
|
|
3
3
|
import output from './output.js'
|
|
4
4
|
import event from './event.js'
|
|
5
5
|
import { removeNonInteractiveElements, minifyHtml, splitByChunks } from './html.js'
|
|
6
|
-
import { generateText } from 'ai'
|
|
6
|
+
import { generateText, experimental_decide as decide, APICallError } from 'ai'
|
|
7
7
|
import { fileURLToPath } from 'url'
|
|
8
8
|
import path from 'path'
|
|
9
9
|
import { fileExists, resolveImportModulePath } from './utils.js'
|
|
@@ -59,7 +59,7 @@ class AiAssistant {
|
|
|
59
59
|
debug('Enabling AI assistant')
|
|
60
60
|
this.isEnabled = true
|
|
61
61
|
|
|
62
|
-
const { html, prompts, ...aiConfig } = config
|
|
62
|
+
const { html, prompts, decisionModel, ...aiConfig } = config
|
|
63
63
|
|
|
64
64
|
this.config = Object.assign(this.config, aiConfig)
|
|
65
65
|
this.htmlConfig = Object.assign(defaultHtmlConfig, html)
|
|
@@ -271,4 +271,83 @@ function parseCodeBlocks(response) {
|
|
|
271
271
|
return modifiedSnippets.filter(snippet => !!snippet)
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
const defaultDecisionConfig = {
|
|
275
|
+
confidence: 0.7,
|
|
276
|
+
timeout: 15000,
|
|
277
|
+
maxLength: 12000,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
class DecisionConnectionError extends Error {}
|
|
281
|
+
|
|
282
|
+
class DecisionAI {
|
|
283
|
+
constructor(config = {}) {
|
|
284
|
+
this.config = { ...defaultDecisionConfig, ...config }
|
|
285
|
+
|
|
286
|
+
const { confidence } = this.config
|
|
287
|
+
if (!(confidence > 0 && confidence < 1)) throw new Error(`ai.decisionModel.confidence must be between 0 and 1, got ${confidence}`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
checkModel(option) {
|
|
291
|
+
if (this.config[option]) return
|
|
292
|
+
|
|
293
|
+
const noModelErrorMessage = `
|
|
294
|
+
No decision model is set in ai.decisionModel.${option} config.
|
|
295
|
+
|
|
296
|
+
[!] Configure decision model with OpenRouter provider:
|
|
297
|
+
|
|
298
|
+
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
|
299
|
+
|
|
300
|
+
const openrouter = createOpenRouter({
|
|
301
|
+
apiKey: process.env.OPENROUTER_API_KEY,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
ai: {
|
|
305
|
+
decisionModel: {
|
|
306
|
+
model: openrouter.evaluationModel('typesafe/jev-1.13'),
|
|
307
|
+
visualModel: openrouter.evaluationModel('cloudflare/clef'),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
See https://openrouter.ai/models?output_modalities=decisions for all decision models.
|
|
312
|
+
`.trim()
|
|
313
|
+
|
|
314
|
+
throw new Error(noModelErrorMessage)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async decide(option, state, statements) {
|
|
318
|
+
this.checkModel(option)
|
|
319
|
+
const model = this.config[option]
|
|
320
|
+
|
|
321
|
+
const questions = Object.fromEntries(statements.map((statement, i) => [`q${i}`, { type: 'boolean', instructions: statement }]))
|
|
322
|
+
debug('Decision request', model.modelId, statements)
|
|
323
|
+
|
|
324
|
+
const controller = new AbortController()
|
|
325
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeout)
|
|
326
|
+
|
|
327
|
+
let result
|
|
328
|
+
try {
|
|
329
|
+
result = await decide({
|
|
330
|
+
model,
|
|
331
|
+
state,
|
|
332
|
+
questions,
|
|
333
|
+
maxRetries: 0,
|
|
334
|
+
abortSignal: controller.signal,
|
|
335
|
+
})
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (controller.signal.aborted) throw new DecisionConnectionError(`Decision model ${model.modelId} did not respond in ${this.config.timeout}ms`)
|
|
338
|
+
if (APICallError.isInstance(err) && !err.statusCode) throw new DecisionConnectionError(`Decision model ${model.modelId} request failed: ${err.message}`)
|
|
339
|
+
if (APICallError.isInstance(err)) throw new Error(`Decision model ${model.modelId} responded with ${err.statusCode}: ${err.responseBody || err.message}`)
|
|
340
|
+
throw err
|
|
341
|
+
} finally {
|
|
342
|
+
clearTimeout(timer)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
debug('Decision response', result.answers, result.usage)
|
|
346
|
+
|
|
347
|
+
return Object.keys(questions).map(id => result.answers[id].probability)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export { DecisionAI, DecisionConnectionError }
|
|
352
|
+
|
|
274
353
|
export default new AiAssistant()
|
package/lib/codecept.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolve } from 'path'
|
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'url'
|
|
7
7
|
import { dirname } from 'path'
|
|
8
8
|
import { createRequire } from 'module'
|
|
9
|
+
import { isMainThread } from 'worker_threads'
|
|
9
10
|
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url)
|
|
11
12
|
const __dirname = dirname(__filename)
|
|
@@ -23,6 +24,8 @@ import loadTests from './mocha/loadTests.js'
|
|
|
23
24
|
import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js'
|
|
24
25
|
import recorder from './recorder.js'
|
|
25
26
|
import store from './store.js'
|
|
27
|
+
import Result from './result.js'
|
|
28
|
+
import WebServer from './webServer.js'
|
|
26
29
|
|
|
27
30
|
import storeListener from './listener/store.js'
|
|
28
31
|
import stepsListener from './listener/steps.js'
|
|
@@ -159,6 +162,7 @@ class Codecept {
|
|
|
159
162
|
* @returns {Promise<void>}
|
|
160
163
|
*/
|
|
161
164
|
async bootstrap() {
|
|
165
|
+
if (isMainThread && !this.opts?.child) await WebServer.startAll(this.config.webServer)
|
|
162
166
|
return runHook(this.config.bootstrap, 'bootstrap')
|
|
163
167
|
}
|
|
164
168
|
|
|
@@ -168,7 +172,11 @@ class Codecept {
|
|
|
168
172
|
* @returns {Promise<void>}
|
|
169
173
|
*/
|
|
170
174
|
async teardown() {
|
|
171
|
-
|
|
175
|
+
try {
|
|
176
|
+
return await runHook(this.config.teardown, 'teardown')
|
|
177
|
+
} finally {
|
|
178
|
+
await WebServer.stopAll()
|
|
179
|
+
}
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
/**
|
|
@@ -264,6 +272,21 @@ class Codecept {
|
|
|
264
272
|
return testFiles.slice(startIndex, endIndex)
|
|
265
273
|
}
|
|
266
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Keeps only tests that failed in the previous run when `--last-failed` is set.
|
|
277
|
+
*
|
|
278
|
+
* @param {Object} mocha Mocha instance with loaded tests
|
|
279
|
+
*/
|
|
280
|
+
filterLastFailedTests(mocha) {
|
|
281
|
+
if (!this.opts.lastFailed) return
|
|
282
|
+
const failedTestUids = new Set(Result.loadFailedTests().map(test => test.uid))
|
|
283
|
+
const filterSuite = suite => {
|
|
284
|
+
suite.tests = suite.tests.filter(test => failedTestUids.has(test.uid))
|
|
285
|
+
suite.suites.forEach(filterSuite)
|
|
286
|
+
}
|
|
287
|
+
filterSuite(mocha.suite)
|
|
288
|
+
}
|
|
289
|
+
|
|
267
290
|
/**
|
|
268
291
|
* Run a specific test or all loaded tests.
|
|
269
292
|
*
|
|
@@ -314,7 +337,13 @@ class Codecept {
|
|
|
314
337
|
})
|
|
315
338
|
}
|
|
316
339
|
|
|
317
|
-
|
|
340
|
+
// Worker threads pre-load and filter tests before run(); reloading duplicates Gherkin suites.
|
|
341
|
+
if (!mocha.suite.total()) {
|
|
342
|
+
await loadTests(mocha)
|
|
343
|
+
this.filterLastFailedTests(mocha)
|
|
344
|
+
} else {
|
|
345
|
+
mocha.lazyLoadFiles(true)
|
|
346
|
+
}
|
|
318
347
|
|
|
319
348
|
return new Promise((resolve, reject) => {
|
|
320
349
|
const done = async (failures) => {
|
|
@@ -322,6 +351,7 @@ class Codecept {
|
|
|
322
351
|
event.emit(event.all.after, this)
|
|
323
352
|
// Wait for any recorder tasks added by event.all.after handlers
|
|
324
353
|
await recorder.promise()
|
|
354
|
+
if (!this.opts.child && !store.dryRun && container.result().tests.length) container.result().save()
|
|
325
355
|
// Set exit code based on test failures
|
|
326
356
|
if (failures) {
|
|
327
357
|
process.exitCode = 1
|
package/lib/command/init.js
CHANGED
|
@@ -12,6 +12,7 @@ import { fileExists, beautify, installedLocally } from '../utils.js'
|
|
|
12
12
|
import { getTestRoot } from './utils.js'
|
|
13
13
|
import generateDefinitions from './definitions.js'
|
|
14
14
|
import { test as generateTest } from './generate.js'
|
|
15
|
+
import initAgent, { agents } from './initAgent.js'
|
|
15
16
|
const isLocal = installedLocally()
|
|
16
17
|
|
|
17
18
|
const defaultConfig = {
|
|
@@ -318,6 +319,27 @@ export default async function (initPath, options = {}) {
|
|
|
318
319
|
print(generateDefinitionsManually)
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
let agentName = options.agent
|
|
323
|
+
if (!agentName && !skipPrompts) {
|
|
324
|
+
const answer = await inquirer.prompt([
|
|
325
|
+
{
|
|
326
|
+
name: 'agent',
|
|
327
|
+
type: 'list',
|
|
328
|
+
message: 'Set up an AI coding agent?',
|
|
329
|
+
choices: [...Object.keys(agents), 'none'],
|
|
330
|
+
default: 'none',
|
|
331
|
+
},
|
|
332
|
+
])
|
|
333
|
+
agentName = answer?.agent
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (agentName && agentName !== 'none') {
|
|
337
|
+
await initAgent(agentName, { yes: true })
|
|
338
|
+
} else {
|
|
339
|
+
print()
|
|
340
|
+
print(`Using an AI agent? Run: ${colors.green('npx codeceptjs init:agent')}`)
|
|
341
|
+
}
|
|
342
|
+
|
|
321
343
|
print('')
|
|
322
344
|
success(' Almost ready... Next step:')
|
|
323
345
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import inquirer from 'inquirer'
|
|
4
|
+
import { execSync } from 'child_process'
|
|
5
|
+
|
|
6
|
+
import output from '../output.js'
|
|
7
|
+
|
|
8
|
+
const { print, success, error } = output
|
|
9
|
+
|
|
10
|
+
export const agents = {
|
|
11
|
+
claude: {
|
|
12
|
+
skills: 'claude-code',
|
|
13
|
+
mcp: 'claude mcp add codeceptjs -- npx codeceptjs-mcp',
|
|
14
|
+
},
|
|
15
|
+
codex: {
|
|
16
|
+
skills: 'codex',
|
|
17
|
+
mcp: 'codex mcp add codeceptjs -- npx codeceptjs-mcp',
|
|
18
|
+
},
|
|
19
|
+
cursor: {
|
|
20
|
+
skills: 'cursor',
|
|
21
|
+
file: '.cursor/mcp.json',
|
|
22
|
+
key: 'mcpServers',
|
|
23
|
+
server: { command: 'npx', args: ['codeceptjs-mcp'] },
|
|
24
|
+
},
|
|
25
|
+
opencode: {
|
|
26
|
+
skills: 'opencode',
|
|
27
|
+
file: 'opencode.json',
|
|
28
|
+
key: 'mcp',
|
|
29
|
+
server: { type: 'local', command: ['npx', 'codeceptjs-mcp'], enabled: true },
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default async function (agentName, options = {}) {
|
|
34
|
+
if (!agentName && !options.yes) {
|
|
35
|
+
const answer = await inquirer.prompt([{ name: 'agent', type: 'list', message: 'Which coding agent do you use?', choices: Object.keys(agents) }])
|
|
36
|
+
agentName = answer.agent
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const agent = agents[agentName]
|
|
40
|
+
if (!agent) {
|
|
41
|
+
error(`Agent must be one of: ${Object.keys(agents).join(', ')}`)
|
|
42
|
+
process.exitCode = 1
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let ready = true
|
|
47
|
+
try {
|
|
48
|
+
if (agent.mcp) {
|
|
49
|
+
execSync(agent.mcp, { stdio: 'inherit' })
|
|
50
|
+
} else {
|
|
51
|
+
addServerToFile(agent)
|
|
52
|
+
print(`Added codeceptjs MCP server to ${agent.file}`)
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
ready = false
|
|
56
|
+
error(`Could not register the codeceptjs MCP server: ${err.message}`)
|
|
57
|
+
if (agent.mcp) print(`Run: ${agent.mcp}`)
|
|
58
|
+
if (agent.file) print(`Add to "${agent.key}" in ${agent.file}: ${JSON.stringify({ codeceptjs: agent.server })}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const skills = `npx skills add codeceptjs/skills -a ${agent.skills} -y`
|
|
62
|
+
try {
|
|
63
|
+
execSync(skills, { stdio: 'inherit' })
|
|
64
|
+
} catch (err) {
|
|
65
|
+
ready = false
|
|
66
|
+
error(`Could not install skills. Run: ${skills}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (ready) success(`Start ${agentName} in this project. CodeceptJS skills and MCP are ready.`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function addServerToFile(agent) {
|
|
73
|
+
let config = {}
|
|
74
|
+
if (fs.existsSync(agent.file)) config = JSON.parse(fs.readFileSync(agent.file, 'utf8'))
|
|
75
|
+
config[agent.key] = { ...config[agent.key], codeceptjs: agent.server }
|
|
76
|
+
fs.mkdirSync(path.dirname(agent.file), { recursive: true })
|
|
77
|
+
fs.writeFileSync(agent.file, `${JSON.stringify(config, null, 2)}\n`)
|
|
78
|
+
}
|
|
@@ -4,6 +4,7 @@ import crypto from 'crypto'
|
|
|
4
4
|
import { fileURLToPath } from 'url'
|
|
5
5
|
|
|
6
6
|
import runHook from '../hooks.js'
|
|
7
|
+
import WebServer from '../webServer.js'
|
|
7
8
|
import event from '../event.js'
|
|
8
9
|
import { createRuns } from './run-multiple/collection.js'
|
|
9
10
|
import { clearString, replaceValueDeep } from '../utils.js'
|
|
@@ -67,6 +68,7 @@ export default async function (selectedRuns, options) {
|
|
|
67
68
|
fail('No runs provided. Use --all option to run all configured runs')
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
await WebServer.startAll(config.webServer)
|
|
70
72
|
await runHook(config.bootstrapAll, 'bootstrapAll')
|
|
71
73
|
|
|
72
74
|
event.emit(event.multiple.before, null)
|
|
@@ -110,6 +112,7 @@ export default async function (selectedRuns, options) {
|
|
|
110
112
|
return childProcessesPromise.then(async () => {
|
|
111
113
|
// fire hook
|
|
112
114
|
await runHook(config.teardownAll, 'teardownAll')
|
|
115
|
+
await WebServer.stopAll()
|
|
113
116
|
event.emit(event.multiple.after, null)
|
|
114
117
|
})
|
|
115
118
|
}
|
|
@@ -5,7 +5,9 @@ import store from '../store.js'
|
|
|
5
5
|
import event from '../event.js'
|
|
6
6
|
import Workers from '../workers.js'
|
|
7
7
|
import Codecept from '../codecept.js'
|
|
8
|
+
import Config from '../config.js'
|
|
8
9
|
import { getMachineInfo } from './info.js'
|
|
10
|
+
import { getConfig, getTestRoot, createOutputDir, hasLastFailedTests } from './utils.js'
|
|
9
11
|
|
|
10
12
|
export default async function (workerCount, selectedRuns, options) {
|
|
11
13
|
process.env.profile = options.profile
|
|
@@ -36,10 +38,17 @@ export default async function (workerCount, selectedRuns, options) {
|
|
|
36
38
|
selectedRuns,
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
if (options.lastFailed) {
|
|
42
|
+
await getConfig(testConfig)
|
|
43
|
+
const outputDir = createOutputDir(Config.append(overrideConfigs), getTestRoot(testConfig))
|
|
44
|
+
if (!(await hasLastFailedTests(outputDir))) return
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
const numberOfWorkers = parseInt(workerCount, 10)
|
|
40
48
|
|
|
41
49
|
output.print(`CodeceptJS v${Codecept.version()} ${output.standWithUkraine()}`)
|
|
42
50
|
output.print(`Running tests in ${output.styles.bold(numberOfWorkers)} workers...`)
|
|
51
|
+
if (options.verbose || options.debug) store.debugMode = true
|
|
43
52
|
store.hasWorkers = true
|
|
44
53
|
store.workerMode = true
|
|
45
54
|
process.env.RUNS_WITH_WORKERS = 'true'
|
|
@@ -55,7 +64,7 @@ export default async function (workerCount, selectedRuns, options) {
|
|
|
55
64
|
output.print('Test distribution:')
|
|
56
65
|
workerObjects.forEach((worker, index) => {
|
|
57
66
|
const testCount = worker.tests.length
|
|
58
|
-
output.print(` Worker ${index + 1}: ${testCount} test${testCount !== 1 ? 's' : ''}`)
|
|
67
|
+
output.print(` Worker ${index + 1}: ${testCount} test${testCount !== 1 ? 's' : ''}${worker.serial ? ' (serial)' : ''}`)
|
|
59
68
|
})
|
|
60
69
|
output.print()
|
|
61
70
|
|
|
@@ -76,8 +85,6 @@ export default async function (workerCount, selectedRuns, options) {
|
|
|
76
85
|
})
|
|
77
86
|
|
|
78
87
|
try {
|
|
79
|
-
if (options.verbose || options.debug) store.debugMode = true
|
|
80
|
-
|
|
81
88
|
if (options.verbose) {
|
|
82
89
|
await getMachineInfo()
|
|
83
90
|
}
|
package/lib/command/run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getConfig, printError, getTestRoot, createOutputDir, autoExit } from './utils.js'
|
|
1
|
+
import { getConfig, printError, getTestRoot, createOutputDir, autoExit, hasLastFailedTests } from './utils.js'
|
|
2
2
|
import Config from '../config.js'
|
|
3
3
|
import store from '../store.js'
|
|
4
4
|
import Codecept from '../codecept.js'
|
|
@@ -21,7 +21,8 @@ export default async function (test, options) {
|
|
|
21
21
|
config = Config.append(JSON.parse(options.override))
|
|
22
22
|
}
|
|
23
23
|
const testRoot = getTestRoot(configFile)
|
|
24
|
-
createOutputDir(config, testRoot)
|
|
24
|
+
const outputDir = createOutputDir(config, testRoot)
|
|
25
|
+
if (options.lastFailed && !(await hasLastFailedTests(outputDir))) return
|
|
25
26
|
|
|
26
27
|
const codecept = new Codecept(config, options)
|
|
27
28
|
|
package/lib/command/utils.js
CHANGED
|
@@ -105,6 +105,21 @@ export const createOutputDir = (config, testRoot) => {
|
|
|
105
105
|
output.print(`creating output directory: ${outputDir}`)
|
|
106
106
|
mkdirp.sync(outputDir)
|
|
107
107
|
}
|
|
108
|
+
return outputDir
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function hasLastFailedTests(outputDir) {
|
|
112
|
+
const { default: Result } = await import('../result.js')
|
|
113
|
+
let failedTests
|
|
114
|
+
try {
|
|
115
|
+
failedTests = Result.loadFailedTests(outputDir)
|
|
116
|
+
} catch (err) {
|
|
117
|
+
output.error(err.message)
|
|
118
|
+
process.exitCode = 1
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
if (!failedTests.length) output.print('No failed tests in the last run')
|
|
122
|
+
return failedTests.length > 0
|
|
108
123
|
}
|
|
109
124
|
|
|
110
125
|
export async function autoExit() {
|
|
@@ -17,7 +17,7 @@ let stdout = ''
|
|
|
17
17
|
|
|
18
18
|
const stderr = ''
|
|
19
19
|
|
|
20
|
-
const { options, tests, testRoot, workerIndex, poolMode } = workerData
|
|
20
|
+
const { options, tests, skipTests = [], testRoot, workerIndex, poolMode } = workerData
|
|
21
21
|
|
|
22
22
|
// Global error handlers to catch critical errors but not test failures
|
|
23
23
|
process.on('uncaughtException', (err) => {
|
|
@@ -287,6 +287,8 @@ async function runPoolTests() {
|
|
|
287
287
|
// Load only the assigned test file
|
|
288
288
|
mocha.files = [testIdentifier]
|
|
289
289
|
await loadTests(mocha)
|
|
290
|
+
codecept.filterLastFailedTests(mocha)
|
|
291
|
+
filterSuites(mocha.suite.suites, test => !skipTests.includes(test.uid))
|
|
290
292
|
|
|
291
293
|
if (mocha.suite.total() > 0) {
|
|
292
294
|
// Run only the tests in the current mocha suite
|
|
@@ -423,16 +425,14 @@ async function filterTests() {
|
|
|
423
425
|
mocha.files = files
|
|
424
426
|
await loadTests(mocha)
|
|
425
427
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
suite.tests = suite.tests.filter(test => tests.indexOf(test.uid) >= 0)
|
|
429
|
-
for (const childSuite of suite.suites) {
|
|
430
|
-
filterSuiteTests(childSuite)
|
|
431
|
-
}
|
|
432
|
-
}
|
|
428
|
+
filterSuites(mocha.suite.suites, test => tests.indexOf(test.uid) >= 0)
|
|
429
|
+
}
|
|
433
430
|
|
|
434
|
-
|
|
435
|
-
|
|
431
|
+
// Recursively filter tests in all suites (including nested ones)
|
|
432
|
+
function filterSuites(suites, keep) {
|
|
433
|
+
for (const suite of suites) {
|
|
434
|
+
suite.tests = suite.tests.filter(keep)
|
|
435
|
+
filterSuites(suite.suites, keep)
|
|
436
436
|
}
|
|
437
437
|
}
|
|
438
438
|
|
package/lib/container.js
CHANGED
|
@@ -778,8 +778,8 @@ async function loadGherkinStepsAsync(paths) {
|
|
|
778
778
|
// Import BDD module to access step file tracking functions and step DSL
|
|
779
779
|
const bddModule = await import('./mocha/bdd.js')
|
|
780
780
|
|
|
781
|
-
global.Before = fn => event.dispatcher.on(event.test.started, fn)
|
|
782
|
-
global.After = fn => event.dispatcher.on(event.test.finished, fn)
|
|
781
|
+
global.Before = global.GherkinBefore = fn => event.dispatcher.on(event.test.started, fn)
|
|
782
|
+
global.After = global.GherkinAfter = fn => event.dispatcher.on(event.test.finished, fn)
|
|
783
783
|
global.Fail = fn => event.dispatcher.on(event.test.failed, fn)
|
|
784
784
|
|
|
785
785
|
// Scope-inject Given/When/Then/And while loading step files so they work
|
|
@@ -818,7 +818,9 @@ async function loadGherkinStepsAsync(paths) {
|
|
|
818
818
|
}
|
|
819
819
|
|
|
820
820
|
delete global.Before
|
|
821
|
+
delete global.GherkinBefore
|
|
821
822
|
delete global.After
|
|
823
|
+
delete global.GherkinAfter
|
|
822
824
|
delete global.Fail
|
|
823
825
|
if (injectStepDsl) {
|
|
824
826
|
delete global.Given
|