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.
Files changed (63) 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/CDPBrowser.js +349 -1384
  41. package/lib/helper/Decision.js +256 -0
  42. package/lib/helper/Kitesurf.js +3 -21
  43. package/lib/helper/Lightpanda.js +137 -0
  44. package/lib/helper/Obscura.js +21 -238
  45. package/lib/helper/Playwright.js +6 -4
  46. package/lib/helper/Puppeteer.js +12 -7
  47. package/lib/helper/WebDriver.js +2 -1
  48. package/lib/listener/result.js +4 -0
  49. package/lib/mocha/bdd.js +19 -1
  50. package/lib/plugin/junitReporter.js +4 -1
  51. package/lib/plugin/pause.js +4 -1
  52. package/lib/plugin/sitemap.js +228 -0
  53. package/lib/recorder.js +19 -18
  54. package/lib/result.js +16 -0
  55. package/lib/step/base.js +12 -0
  56. package/lib/utils.js +2 -10
  57. package/lib/webServer.js +193 -0
  58. package/lib/workers.js +118 -31
  59. package/package.json +4 -2
  60. package/typings/index.d.ts +60 -7
  61. package/typings/promiseBasedTypes.d.ts +772 -824
  62. package/typings/types.d.ts +866 -833
  63. package/docs/helpers/CDPBrowser.md +0 -2138
@@ -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
- Set up the MCP server in your client (Claude Desktop, Cursor, Continue, etc.):
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`:
@@ -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
- return runHook(this.config.teardown, 'teardown')
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
- await loadTests(mocha)
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
@@ -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
  }
@@ -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
 
@@ -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
- // Recursively filter tests in all suites (including nested ones)
427
- const filterSuiteTests = (suite) => {
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
- for (const suite of mocha.suite.suites) {
435
- filterSuiteTests(suite)
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