codeceptjs 3.7.3 → 3.7.5-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 CHANGED
@@ -64,6 +64,8 @@ You don't need to worry about asynchronous nature of NodeJS or about various API
64
64
  - Also plays nice with TypeScript.
65
65
  - </> Smart locators: use names, labels, matching text, CSS or XPath to locate elements.
66
66
  - 🌐 Interactive debugging shell: pause test at any point and try different commands in a browser.
67
+ - ⚡ **Parallel testing** with dynamic test pooling for optimal load balancing and performance.
68
+ - 📊 **Built-in HTML Reporter** with interactive dashboard, step-by-step execution details, and comprehensive test analytics.
67
69
  - Easily create tests, pageobjects, stepobjects with CLI generators.
68
70
 
69
71
  ## Installation
@@ -233,6 +235,49 @@ Scenario('test title', () => {
233
235
  })
234
236
  ```
235
237
 
238
+ ## HTML Reporter
239
+
240
+ CodeceptJS includes a powerful built-in HTML Reporter that generates comprehensive, interactive test reports with detailed information about your test runs. The HTML reporter is **enabled by default** for all new projects and provides:
241
+
242
+ ### Features
243
+
244
+ - **Interactive Dashboard**: Visual statistics, pie charts, and expandable test details
245
+ - **Step-by-Step Execution**: Shows individual test steps with timing and status indicators
246
+ - **BDD/Gherkin Support**: Full support for feature files with proper scenario formatting
247
+ - **System Information**: Comprehensive environment details including browser versions
248
+ - **Advanced Filtering**: Real-time filtering by status, tags, features, and test types
249
+ - **History Tracking**: Multi-run history with trend visualization
250
+ - **Error Details**: Clean formatting of error messages and stack traces
251
+ - **Artifacts Support**: Display screenshots and other test artifacts
252
+
253
+ ### Visual Examples
254
+
255
+ #### Interactive Test Dashboard
256
+
257
+ The main dashboard provides a complete overview with interactive statistics and pie charts:
258
+
259
+ ![HTML Reporter Dashboard](docs/shared/html-reporter-main-dashboard.png)
260
+
261
+ #### Detailed Test Results
262
+
263
+ Each test shows comprehensive execution details with expandable step information:
264
+
265
+ ![HTML Reporter Test Details](docs/shared/html-reporter-test-details.png)
266
+
267
+ #### Advanced Filtering Capabilities
268
+
269
+ Real-time filtering allows quick navigation through test results:
270
+
271
+ ![HTML Reporter Filtering](docs/shared/html-reporter-filtering.png)
272
+
273
+ #### BDD/Gherkin Support
274
+
275
+ Full support for Gherkin scenarios with proper feature formatting:
276
+
277
+ ![HTML Reporter BDD Details](docs/shared/html-reporter-bdd-details.png)
278
+
279
+ The HTML reporter generates self-contained reports that can be easily shared with your team. Learn more about configuration and features in the [HTML Reporter documentation](https://codecept.io/plugins/#htmlreporter).
280
+
236
281
  ## PageObjects
237
282
 
238
283
  CodeceptJS provides the most simple way to create and use page objects in your test.
package/bin/codecept.js CHANGED
@@ -164,6 +164,8 @@ program
164
164
  .option('--tests', 'run only JS test files and skip features')
165
165
  .option('--no-timeouts', 'disable all timeouts')
166
166
  .option('-p, --plugins <k=v,k2=v2,...>', 'enable plugins, comma-separated')
167
+ .option('--shuffle', 'Shuffle the order in which test files run')
168
+ .option('--shard <index/total>', 'run only a fraction of tests (e.g., --shard 1/4)')
167
169
 
168
170
  // mocha options
169
171
  .option('--colors', 'force enabling of colors')
@@ -195,6 +197,7 @@ program
195
197
  .option('-i, --invert', 'inverts --grep matches')
196
198
  .option('-o, --override [value]', 'override current config options')
197
199
  .option('--suites', 'parallel execution of suites not single tests')
200
+ .option('--by <strategy>', 'test distribution strategy: "test" (pre-assign individual tests), "suite" (pre-assign test suites), or "pool" (dynamic distribution for optimal load balancing, recommended)')
198
201
  .option(commandFlags.debug.flag, commandFlags.debug.description)
199
202
  .option(commandFlags.verbose.flag, commandFlags.verbose.description)
200
203
  .option('--features', 'run only *.feature files and skip tests')
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Standalone test server script to replace json-server
5
+ */
6
+
7
+ const path = require('path')
8
+ const TestServer = require('../lib/test-server')
9
+
10
+ // Parse command line arguments
11
+ const args = process.argv.slice(2)
12
+ let dbFile = path.join(__dirname, '../test/data/rest/db.json')
13
+ let port = 8010
14
+ let host = '0.0.0.0'
15
+
16
+ // Simple argument parsing
17
+ for (let i = 0; i < args.length; i++) {
18
+ const arg = args[i]
19
+
20
+ if (arg === '-p' || arg === '--port') {
21
+ port = parseInt(args[++i])
22
+ } else if (arg === '--host') {
23
+ host = args[++i]
24
+ } else if (!arg.startsWith('-')) {
25
+ dbFile = path.resolve(arg)
26
+ }
27
+ }
28
+
29
+ // Create and start server
30
+ const server = new TestServer({ port, host, dbFile })
31
+
32
+ console.log(`Starting test server with db file: ${dbFile}`)
33
+
34
+ server
35
+ .start()
36
+ .then(() => {
37
+ console.log(`Test server is ready and listening on http://${host}:${port}`)
38
+ })
39
+ .catch(err => {
40
+ console.error('Failed to start test server:', err)
41
+ process.exit(1)
42
+ })
43
+
44
+ // Graceful shutdown
45
+ process.on('SIGINT', () => {
46
+ console.log('\nShutting down test server...')
47
+ server.stop().then(() => process.exit(0))
48
+ })
49
+
50
+ process.on('SIGTERM', () => {
51
+ console.log('\nShutting down test server...')
52
+ server.stop().then(() => process.exit(0))
53
+ })
package/lib/codecept.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { existsSync, readFileSync } = require('fs')
2
2
  const { globSync } = require('glob')
3
+ const shuffle = require('lodash.shuffle')
3
4
  const fsPath = require('path')
4
5
  const { resolve } = require('path')
5
6
 
@@ -110,6 +111,7 @@ class Codecept {
110
111
  runHook(require('./listener/helpers'))
111
112
  runHook(require('./listener/globalTimeout'))
112
113
  runHook(require('./listener/globalRetry'))
114
+ runHook(require('./listener/retryEnhancer'))
113
115
  runHook(require('./listener/exit'))
114
116
  runHook(require('./listener/emptyRun'))
115
117
 
@@ -180,6 +182,50 @@ class Codecept {
180
182
  })
181
183
  }
182
184
  }
185
+
186
+ if (this.opts.shuffle) {
187
+ this.testFiles = shuffle(this.testFiles)
188
+ }
189
+
190
+ if (this.opts.shard) {
191
+ this.testFiles = this._applySharding(this.testFiles, this.opts.shard)
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Apply sharding to test files based on shard configuration
197
+ *
198
+ * @param {Array<string>} testFiles - Array of test file paths
199
+ * @param {string} shardConfig - Shard configuration in format "index/total" (e.g., "1/4")
200
+ * @returns {Array<string>} - Filtered array of test files for this shard
201
+ */
202
+ _applySharding(testFiles, shardConfig) {
203
+ const shardMatch = shardConfig.match(/^(\d+)\/(\d+)$/)
204
+ if (!shardMatch) {
205
+ throw new Error('Invalid shard format. Expected format: "index/total" (e.g., "1/4")')
206
+ }
207
+
208
+ const shardIndex = parseInt(shardMatch[1], 10)
209
+ const shardTotal = parseInt(shardMatch[2], 10)
210
+
211
+ if (shardTotal < 1) {
212
+ throw new Error('Shard total must be at least 1')
213
+ }
214
+
215
+ if (shardIndex < 1 || shardIndex > shardTotal) {
216
+ throw new Error(`Shard index ${shardIndex} must be between 1 and ${shardTotal}`)
217
+ }
218
+
219
+ if (testFiles.length === 0) {
220
+ return testFiles
221
+ }
222
+
223
+ // Calculate which tests belong to this shard
224
+ const shardSize = Math.ceil(testFiles.length / shardTotal)
225
+ const startIndex = (shardIndex - 1) * shardSize
226
+ const endIndex = Math.min(startIndex + shardSize, testFiles.length)
227
+
228
+ return testFiles.slice(startIndex, endIndex)
183
229
  }
184
230
 
185
231
  /**
@@ -18,6 +18,11 @@ const defaultConfig = {
18
18
  output: '',
19
19
  helpers: {},
20
20
  include: {},
21
+ plugins: {
22
+ htmlReporter: {
23
+ enabled: true,
24
+ },
25
+ },
21
26
  }
22
27
 
23
28
  const helpers = ['Playwright', 'WebDriver', 'Puppeteer', 'REST', 'GraphQL', 'Appium', 'TestCafe']
@@ -10,7 +10,22 @@ module.exports = async function (workerCount, selectedRuns, options) {
10
10
 
11
11
  const { config: testConfig, override = '' } = options
12
12
  const overrideConfigs = tryOrDefault(() => JSON.parse(override), {})
13
- const by = options.suites ? 'suite' : 'test'
13
+
14
+ // Determine test split strategy
15
+ let by = 'test' // default
16
+ if (options.by) {
17
+ // Explicit --by option takes precedence
18
+ by = options.by
19
+ } else if (options.suites) {
20
+ // Legacy --suites option
21
+ by = 'suite'
22
+ }
23
+
24
+ // Validate the by option
25
+ const validStrategies = ['test', 'suite', 'pool']
26
+ if (!validStrategies.includes(by)) {
27
+ throw new Error(`Invalid --by strategy: ${by}. Valid options are: ${validStrategies.join(', ')}`)
28
+ }
14
29
  delete options.parent
15
30
  const config = {
16
31
  by,
@@ -20,7 +20,7 @@ const stderr = ''
20
20
  // Requiring of Codecept need to be after tty.getWindowSize is available.
21
21
  const Codecept = require(process.env.CODECEPT_CLASS_PATH || '../../codecept')
22
22
 
23
- const { options, tests, testRoot, workerIndex } = workerData
23
+ const { options, tests, testRoot, workerIndex, poolMode } = workerData
24
24
 
25
25
  // hide worker output
26
26
  if (!options.debug && !options.verbose)
@@ -39,15 +39,26 @@ const codecept = new Codecept(config, options)
39
39
  codecept.init(testRoot)
40
40
  codecept.loadTests()
41
41
  const mocha = container.mocha()
42
- filterTests()
42
+
43
+ if (poolMode) {
44
+ // In pool mode, don't filter tests upfront - wait for assignments
45
+ // We'll reload test files fresh for each test request
46
+ } else {
47
+ // Legacy mode - filter tests upfront
48
+ filterTests()
49
+ }
43
50
 
44
51
  // run tests
45
52
  ;(async function () {
46
- if (mocha.suite.total()) {
53
+ if (poolMode) {
54
+ await runPoolTests()
55
+ } else if (mocha.suite.total()) {
47
56
  await runTests()
48
57
  }
49
58
  })()
50
59
 
60
+ let globalStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 }
61
+
51
62
  async function runTests() {
52
63
  try {
53
64
  await codecept.bootstrap()
@@ -64,6 +75,192 @@ async function runTests() {
64
75
  }
65
76
  }
66
77
 
78
+ async function runPoolTests() {
79
+ try {
80
+ await codecept.bootstrap()
81
+ } catch (err) {
82
+ throw new Error(`Error while running bootstrap file :${err}`)
83
+ }
84
+
85
+ initializeListeners()
86
+ disablePause()
87
+
88
+ // Accumulate results across all tests in pool mode
89
+ let consolidatedStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 }
90
+ let allTests = []
91
+ let allFailures = []
92
+ let previousStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 }
93
+
94
+ // Keep requesting tests until no more available
95
+ while (true) {
96
+ // Request a test assignment
97
+ sendToParentThread({ type: 'REQUEST_TEST', workerIndex })
98
+
99
+ const testResult = await new Promise((resolve, reject) => {
100
+ // Set up pool mode message handler
101
+ const messageHandler = async eventData => {
102
+ if (eventData.type === 'TEST_ASSIGNED') {
103
+ const testUid = eventData.test
104
+
105
+ try {
106
+ // In pool mode, we need to create a fresh Mocha instance for each test
107
+ // because Mocha instances become disposed after running tests
108
+ container.createMocha() // Create fresh Mocha instance
109
+ filterTestById(testUid)
110
+ const mocha = container.mocha()
111
+
112
+ if (mocha.suite.total() > 0) {
113
+ // Run the test and complete
114
+ await codecept.run()
115
+
116
+ // Get the results from this specific test run
117
+ const result = container.result()
118
+ const currentStats = result.stats || {}
119
+
120
+ // Calculate the difference from previous accumulated stats
121
+ const newPasses = Math.max(0, (currentStats.passes || 0) - previousStats.passes)
122
+ const newFailures = Math.max(0, (currentStats.failures || 0) - previousStats.failures)
123
+ const newTests = Math.max(0, (currentStats.tests || 0) - previousStats.tests)
124
+ const newPending = Math.max(0, (currentStats.pending || 0) - previousStats.pending)
125
+ const newFailedHooks = Math.max(0, (currentStats.failedHooks || 0) - previousStats.failedHooks)
126
+
127
+ // Add only the new results
128
+ consolidatedStats.passes += newPasses
129
+ consolidatedStats.failures += newFailures
130
+ consolidatedStats.tests += newTests
131
+ consolidatedStats.pending += newPending
132
+ consolidatedStats.failedHooks += newFailedHooks
133
+
134
+ // Update previous stats for next comparison
135
+ previousStats = { ...currentStats }
136
+
137
+ // Add new failures to consolidated collections
138
+ if (result.failures && result.failures.length > allFailures.length) {
139
+ const newFailures = result.failures.slice(allFailures.length)
140
+ allFailures.push(...newFailures)
141
+ }
142
+ }
143
+
144
+ // Signal test completed and request next
145
+ parentPort?.off('message', messageHandler)
146
+ resolve('TEST_COMPLETED')
147
+ } catch (err) {
148
+ parentPort?.off('message', messageHandler)
149
+ reject(err)
150
+ }
151
+ } else if (eventData.type === 'NO_MORE_TESTS') {
152
+ // No tests available, exit worker
153
+ parentPort?.off('message', messageHandler)
154
+ resolve('NO_MORE_TESTS')
155
+ } else {
156
+ // Handle other message types (support messages, etc.)
157
+ container.append({ support: eventData.data })
158
+ }
159
+ }
160
+
161
+ parentPort?.on('message', messageHandler)
162
+ })
163
+
164
+ // Exit if no more tests
165
+ if (testResult === 'NO_MORE_TESTS') {
166
+ break
167
+ }
168
+ }
169
+
170
+ try {
171
+ await codecept.teardown()
172
+ } catch (err) {
173
+ // Log teardown errors but don't fail
174
+ console.error('Teardown error:', err)
175
+ }
176
+
177
+ // Send final consolidated results for the entire worker
178
+ const finalResult = {
179
+ hasFailed: consolidatedStats.failures > 0,
180
+ stats: consolidatedStats,
181
+ duration: 0, // Pool mode doesn't track duration per worker
182
+ tests: [], // Keep tests empty to avoid serialization issues - stats are sufficient
183
+ failures: allFailures, // Include all failures for error reporting
184
+ }
185
+
186
+ sendToParentThread({ event: event.all.after, workerIndex, data: finalResult })
187
+ sendToParentThread({ event: event.all.result, workerIndex, data: finalResult })
188
+
189
+ // Add longer delay to ensure messages are delivered before closing
190
+ await new Promise(resolve => setTimeout(resolve, 100))
191
+
192
+ // Close worker thread when pool mode is complete
193
+ parentPort?.close()
194
+ }
195
+
196
+ function filterTestById(testUid) {
197
+ // Reload test files fresh for each test in pool mode
198
+ const files = codecept.testFiles
199
+
200
+ // Get the existing mocha instance
201
+ const mocha = container.mocha()
202
+
203
+ // Clear suites and tests but preserve other mocha settings
204
+ mocha.suite.suites = []
205
+ mocha.suite.tests = []
206
+
207
+ // Clear require cache for test files to ensure fresh loading
208
+ files.forEach(file => {
209
+ delete require.cache[require.resolve(file)]
210
+ })
211
+
212
+ // Set files and load them
213
+ mocha.files = files
214
+ mocha.loadFiles()
215
+
216
+ // Now filter to only the target test - use a more robust approach
217
+ let foundTest = false
218
+ for (const suite of mocha.suite.suites) {
219
+ const originalTests = [...suite.tests]
220
+ suite.tests = []
221
+
222
+ for (const test of originalTests) {
223
+ if (test.uid === testUid) {
224
+ suite.tests.push(test)
225
+ foundTest = true
226
+ break // Only add one matching test
227
+ }
228
+ }
229
+
230
+ // If no tests found in this suite, remove it
231
+ if (suite.tests.length === 0) {
232
+ suite.parent.suites = suite.parent.suites.filter(s => s !== suite)
233
+ }
234
+ }
235
+
236
+ // Filter out empty suites from the root
237
+ mocha.suite.suites = mocha.suite.suites.filter(suite => suite.tests.length > 0)
238
+
239
+ if (!foundTest) {
240
+ // If testUid doesn't match, maybe it's a simple test name - try fallback
241
+ mocha.suite.suites = []
242
+ mocha.suite.tests = []
243
+ mocha.loadFiles()
244
+
245
+ // Try matching by title
246
+ for (const suite of mocha.suite.suites) {
247
+ const originalTests = [...suite.tests]
248
+ suite.tests = []
249
+
250
+ for (const test of originalTests) {
251
+ if (test.title === testUid || test.fullTitle() === testUid || test.uid === testUid) {
252
+ suite.tests.push(test)
253
+ foundTest = true
254
+ break
255
+ }
256
+ }
257
+ }
258
+
259
+ // Clean up empty suites again
260
+ mocha.suite.suites = mocha.suite.suites.filter(suite => suite.tests.length > 0)
261
+ }
262
+ }
263
+
67
264
  function filterTests() {
68
265
  const files = codecept.testFiles
69
266
  mocha.files = files
@@ -102,14 +299,20 @@ function initializeListeners() {
102
299
  event.dispatcher.on(event.hook.passed, hook => sendToParentThread({ event: event.hook.passed, workerIndex, data: hook.simplify() }))
103
300
  event.dispatcher.on(event.hook.finished, hook => sendToParentThread({ event: event.hook.finished, workerIndex, data: hook.simplify() }))
104
301
 
105
- event.dispatcher.once(event.all.after, () => {
106
- sendToParentThread({ event: event.all.after, workerIndex, data: container.result().simplify() })
107
- })
108
- // all
109
- event.dispatcher.once(event.all.result, () => {
110
- sendToParentThread({ event: event.all.result, workerIndex, data: container.result().simplify() })
111
- parentPort?.close()
112
- })
302
+ if (!poolMode) {
303
+ // In regular mode, close worker after all tests are complete
304
+ event.dispatcher.once(event.all.after, () => {
305
+ sendToParentThread({ event: event.all.after, workerIndex, data: container.result().simplify() })
306
+ })
307
+ // all
308
+ event.dispatcher.once(event.all.result, () => {
309
+ sendToParentThread({ event: event.all.result, workerIndex, data: container.result().simplify() })
310
+ parentPort?.close()
311
+ })
312
+ } else {
313
+ // In pool mode, don't send result events for individual tests
314
+ // Results will be sent once when the worker completes all tests
315
+ }
113
316
  }
114
317
 
115
318
  function disablePause() {
@@ -121,7 +324,10 @@ function sendToParentThread(data) {
121
324
  }
122
325
 
123
326
  function listenToParentThread() {
124
- parentPort?.on('message', eventData => {
125
- container.append({ support: eventData.data })
126
- })
327
+ if (!poolMode) {
328
+ parentPort?.on('message', eventData => {
329
+ container.append({ support: eventData.data })
330
+ })
331
+ }
332
+ // In pool mode, message handling is done in runPoolTests()
127
333
  }
package/lib/container.js CHANGED
@@ -28,6 +28,7 @@ let container = {
28
28
  translation: {},
29
29
  /** @type {Result | null} */
30
30
  result: null,
31
+ sharedKeys: new Set() // Track keys shared via share() function
31
32
  }
32
33
 
33
34
  /**
@@ -174,6 +175,7 @@ class Container {
174
175
  container.translation = loadTranslation()
175
176
  container.proxySupport = createSupportObjects(newSupport)
176
177
  container.plugins = newPlugins
178
+ container.sharedKeys = new Set() // Clear shared keys
177
179
  asyncHelperPromise = Promise.resolve()
178
180
  store.actor = null
179
181
  debug('container cleared')
@@ -197,7 +199,13 @@ class Container {
197
199
  * @param {Object} options - set {local: true} to not share among workers
198
200
  */
199
201
  static share(data, options = {}) {
200
- Container.append({ support: data })
202
+ // Instead of using append which replaces the entire container,
203
+ // directly update the support object to maintain proxy references
204
+ Object.assign(container.support, data)
205
+
206
+ // Track which keys were explicitly shared
207
+ Object.keys(data).forEach(key => container.sharedKeys.add(key))
208
+
201
209
  if (!options.local) {
202
210
  WorkerStorage.share(data)
203
211
  }
@@ -396,10 +404,11 @@ function createSupportObjects(config) {
396
404
  {},
397
405
  {
398
406
  has(target, key) {
399
- return keys.includes(key)
407
+ return keys.includes(key) || container.sharedKeys.has(key)
400
408
  },
401
409
  ownKeys() {
402
- return keys
410
+ // Return both original config keys and explicitly shared keys
411
+ return [...new Set([...keys, ...container.sharedKeys])]
403
412
  },
404
413
  getOwnPropertyDescriptor(target, prop) {
405
414
  return {
@@ -409,6 +418,10 @@ function createSupportObjects(config) {
409
418
  }
410
419
  },
411
420
  get(target, key) {
421
+ // First check if this is an explicitly shared property
422
+ if (container.sharedKeys.has(key) && key in container.support) {
423
+ return container.support[key]
424
+ }
412
425
  return lazyLoad(key)
413
426
  },
414
427
  },