codeceptjs 4.0.1-beta.3 → 4.0.1-beta.31

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/lib/step/meta.js CHANGED
@@ -58,17 +58,24 @@ class MetaStep extends Step {
58
58
  this.status = 'queued'
59
59
  this.setArguments(Array.from(arguments).slice(1))
60
60
  let result
61
+ let hasChildSteps = false
61
62
 
62
63
  const registerStep = step => {
63
64
  this.setMetaStep(null)
64
65
  step.setMetaStep(this)
66
+ hasChildSteps = true
65
67
  }
66
68
  event.dispatcher.prependListener(event.step.before, registerStep)
69
+
70
+ // Start timing
71
+ this.startTime = Date.now()
72
+
67
73
  // Handle async and sync methods.
68
74
  if (fn.constructor.name === 'AsyncFunction') {
69
75
  result = fn
70
76
  .apply(this.context, this.args)
71
77
  .then(result => {
78
+ this.setStatus('success')
72
79
  return result
73
80
  })
74
81
  .catch(error => {
@@ -78,17 +85,27 @@ class MetaStep extends Step {
78
85
  .finally(() => {
79
86
  this.endTime = Date.now()
80
87
  event.dispatcher.removeListener(event.step.before, registerStep)
88
+ // Only emit events if no child steps were registered
89
+ if (!hasChildSteps) {
90
+ event.emit(event.step.started, this)
91
+ event.emit(event.step.finished, this)
92
+ }
81
93
  })
82
94
  } else {
83
95
  try {
84
- this.startTime = Date.now()
85
96
  result = fn.apply(this.context, this.args)
97
+ this.setStatus('success')
86
98
  } catch (error) {
87
99
  this.setStatus('failed')
88
100
  throw error
89
101
  } finally {
90
102
  this.endTime = Date.now()
91
103
  event.dispatcher.removeListener(event.step.before, registerStep)
104
+ // Only emit events if no child steps were registered
105
+ if (!hasChildSteps) {
106
+ event.emit(event.step.started, this)
107
+ event.emit(event.step.finished, this)
108
+ }
92
109
  }
93
110
  }
94
111
 
@@ -5,6 +5,7 @@ import output from '../output.js'
5
5
  import store from '../store.js'
6
6
  import { TIMEOUT_ORDER } from '../timeout.js'
7
7
  import retryStep from './retry.js'
8
+ import { fixErrorStack } from '../utils/typescript.js'
8
9
  function recordStep(step, args) {
9
10
  step.status = 'queued'
10
11
 
@@ -60,6 +61,13 @@ function recordStep(step, args) {
60
61
  recorder.catch(err => {
61
62
  step.status = 'failed'
62
63
  step.endTime = +Date.now()
64
+
65
+ // Fix error stack to point to original .ts files (lazy import to avoid circular dependency)
66
+ const fileMapping = global.container?.tsFileMapping?.()
67
+ if (fileMapping) {
68
+ fixErrorStack(err, fileMapping)
69
+ }
70
+
63
71
  event.emit(event.step.failed, step, err)
64
72
  event.emit(event.step.finished, step)
65
73
  throw err
@@ -65,9 +65,18 @@ CodeceptJS 4.x uses ES Modules (ESM) and requires a loader to run TypeScript tes
65
65
  ✅ Complete: Handles all TypeScript features
66
66
 
67
67
  ┌─────────────────────────────────────────────────────────────────────────────┐
68
- │ Option 2: ts-node/esm (Alternative - Established, Requires Config)
68
+ │ Option 2: ts-node/esm (Not Recommended - Has Module Resolution Issues)
69
69
  └─────────────────────────────────────────────────────────────────────────────┘
70
70
 
71
+ ⚠️ ts-node/esm has significant limitations and is not recommended:
72
+ - Doesn't work with "type": "module" in package.json
73
+ - Module resolution doesn't work like standard TypeScript ESM
74
+ - Import statements must use explicit file paths
75
+
76
+ We strongly recommend using tsx/cjs instead.
77
+
78
+ If you still want to use ts-node/esm:
79
+
71
80
  Installation:
72
81
  npm install --save-dev ts-node
73
82
 
@@ -84,11 +93,12 @@ CodeceptJS 4.x uses ES Modules (ESM) and requires a loader to run TypeScript tes
84
93
  "esModuleInterop": true
85
94
  },
86
95
  "ts-node": {
87
- "esm": true,
88
- "experimentalSpecifierResolution": "node"
96
+ "esm": true
89
97
  }
90
98
  }
91
99
 
100
+ 3. Do NOT use "type": "module" in package.json
101
+
92
102
  📚 Documentation: https://codecept.io/typescript
93
103
 
94
104
  Note: TypeScript config files (codecept.conf.ts) and helpers are automatically
@@ -4,10 +4,10 @@ import path from 'path'
4
4
  /**
5
5
  * Transpile TypeScript files to ES modules with CommonJS shim support
6
6
  * Handles recursive transpilation of imported TypeScript files
7
- *
7
+ *
8
8
  * @param {string} mainFilePath - Path to the main TypeScript file to transpile
9
9
  * @param {object} typescript - TypeScript compiler instance
10
- * @returns {Promise<{tempFile: string, allTempFiles: string[]}>} - Main temp file and all temp files created
10
+ * @returns {Promise<{tempFile: string, allTempFiles: string[], fileMapping: any}>} - Main temp file and all temp files created
11
11
  */
12
12
  export async function transpileTypeScript(mainFilePath, typescript) {
13
13
  const { transpile } = typescript
@@ -18,7 +18,7 @@ export async function transpileTypeScript(mainFilePath, typescript) {
18
18
  */
19
19
  const transpileTS = (filePath) => {
20
20
  const tsContent = fs.readFileSync(filePath, 'utf8')
21
-
21
+
22
22
  // Transpile TypeScript to JavaScript with ES module output
23
23
  let jsContent = transpile(tsContent, {
24
24
  module: 99, // ModuleKind.ESNext
@@ -29,16 +29,16 @@ export async function transpileTypeScript(mainFilePath, typescript) {
29
29
  suppressOutputPathCheck: true,
30
30
  skipLibCheck: true,
31
31
  })
32
-
32
+
33
33
  // Check if the code uses CommonJS globals
34
34
  const usesCommonJSGlobals = /__dirname|__filename/.test(jsContent)
35
35
  const usesRequire = /\brequire\s*\(/.test(jsContent)
36
36
  const usesModuleExports = /\b(module\.exports|exports\.)/.test(jsContent)
37
-
37
+
38
38
  if (usesCommonJSGlobals || usesRequire || usesModuleExports) {
39
39
  // Inject ESM equivalents at the top of the file
40
40
  let esmGlobals = ''
41
-
41
+
42
42
  if (usesRequire || usesModuleExports) {
43
43
  // IMPORTANT: Use the original .ts file path as the base for require()
44
44
  // This ensures dynamic require() calls work with relative paths from the original file location
@@ -81,7 +81,7 @@ const exports = module.exports;
81
81
 
82
82
  `
83
83
  }
84
-
84
+
85
85
  if (usesCommonJSGlobals) {
86
86
  // For __dirname and __filename, also use the original file path
87
87
  const originalFileUrl = `file://${filePath.replace(/\\/g, '/')}`
@@ -92,48 +92,48 @@ const __dirname = __dirname_fn(__filename);
92
92
 
93
93
  `
94
94
  }
95
-
95
+
96
96
  jsContent = esmGlobals + jsContent
97
-
97
+
98
98
  // If module.exports is used, we need to export it as default
99
99
  if (usesModuleExports) {
100
100
  jsContent += `\nexport default module.exports;\n`
101
101
  }
102
102
  }
103
-
103
+
104
104
  return jsContent
105
105
  }
106
-
106
+
107
107
  // Create a map to track transpiled files
108
108
  const transpiledFiles = new Map()
109
109
  const baseDir = path.dirname(mainFilePath)
110
-
110
+
111
111
  // Recursive function to transpile a file and all its TypeScript dependencies
112
112
  const transpileFileAndDeps = (filePath) => {
113
113
  // Already transpiled, skip
114
114
  if (transpiledFiles.has(filePath)) {
115
115
  return
116
116
  }
117
-
117
+
118
118
  // Transpile this file
119
119
  let jsContent = transpileTS(filePath)
120
-
120
+
121
121
  // Find all relative TypeScript imports in this file
122
122
  const importRegex = /from\s+['"](\..+?)(?:\.ts)?['"]/g
123
123
  let match
124
124
  const imports = []
125
-
125
+
126
126
  while ((match = importRegex.exec(jsContent)) !== null) {
127
127
  imports.push(match[1])
128
128
  }
129
-
129
+
130
130
  // Get the base directory for this file
131
131
  const fileBaseDir = path.dirname(filePath)
132
-
132
+
133
133
  // Recursively transpile each imported TypeScript file
134
134
  for (const relativeImport of imports) {
135
135
  let importedPath = path.resolve(fileBaseDir, relativeImport)
136
-
136
+
137
137
  // Handle .js extensions that might actually be .ts files
138
138
  if (importedPath.endsWith('.js')) {
139
139
  const tsVersion = importedPath.replace(/\.js$/, '.ts')
@@ -141,9 +141,14 @@ const __dirname = __dirname_fn(__filename);
141
141
  importedPath = tsVersion
142
142
  }
143
143
  }
144
-
145
- // Try adding .ts extension if file doesn't exist and no extension provided
146
- if (!path.extname(importedPath)) {
144
+
145
+ // Check for standard module extensions to determine if we should try adding .ts
146
+ const ext = path.extname(importedPath)
147
+ const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node']
148
+ const hasStandardExtension = standardExtensions.includes(ext.toLowerCase())
149
+
150
+ // If it doesn't end with .ts and doesn't have a standard extension, try adding .ts
151
+ if (!importedPath.endsWith('.ts') && !hasStandardExtension) {
147
152
  const tsPath = importedPath + '.ts'
148
153
  if (fs.existsSync(tsPath)) {
149
154
  importedPath = tsPath
@@ -156,19 +161,20 @@ const __dirname = __dirname_fn(__filename);
156
161
  }
157
162
  }
158
163
  }
159
-
164
+
160
165
  // If it's a TypeScript file, recursively transpile it and its dependencies
161
166
  if (importedPath.endsWith('.ts') && fs.existsSync(importedPath)) {
162
167
  transpileFileAndDeps(importedPath)
163
168
  }
164
169
  }
165
-
170
+
166
171
  // After all dependencies are transpiled, rewrite imports in this file
167
172
  jsContent = jsContent.replace(
168
173
  /from\s+['"](\..+?)(?:\.ts)?['"]/g,
169
174
  (match, importPath) => {
170
175
  let resolvedPath = path.resolve(fileBaseDir, importPath)
171
-
176
+ const originalExt = path.extname(importPath)
177
+
172
178
  // Handle .js extension that might be .ts
173
179
  if (resolvedPath.endsWith('.js')) {
174
180
  const tsVersion = resolvedPath.replace(/\.js$/, '.ts')
@@ -181,11 +187,13 @@ const __dirname = __dirname_fn(__filename);
181
187
  }
182
188
  return `from '${relPath}'`
183
189
  }
190
+ // Keep .js extension as-is (might be a real .js file)
191
+ return match
184
192
  }
185
-
193
+
186
194
  // Try with .ts extension
187
195
  const tsPath = resolvedPath.endsWith('.ts') ? resolvedPath : resolvedPath + '.ts'
188
-
196
+
189
197
  // If we transpiled this file, use the temp file
190
198
  if (transpiledFiles.has(tsPath)) {
191
199
  const tempFile = transpiledFiles.get(tsPath)
@@ -196,28 +204,67 @@ const __dirname = __dirname_fn(__filename);
196
204
  }
197
205
  return `from '${relPath}'`
198
206
  }
199
-
207
+
208
+ // If the import doesn't have a standard module extension (.js, .mjs, .cjs, .json)
209
+ // add .js for ESM compatibility
210
+ // This handles cases where:
211
+ // 1. Import has no real extension (e.g., "./utils" or "./helper")
212
+ // 2. Import has a non-standard extension that's part of the name (e.g., "./abstract.helper")
213
+ const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node']
214
+ const hasStandardExtension = standardExtensions.includes(originalExt.toLowerCase())
215
+
216
+ if (!hasStandardExtension) {
217
+ return match.replace(importPath, importPath + '.js')
218
+ }
219
+
200
220
  // Otherwise, keep the import as-is
201
221
  return match
202
222
  }
203
223
  )
204
-
224
+
205
225
  // Write the transpiled file with updated imports
206
226
  const tempFile = filePath.replace(/\.ts$/, '.temp.mjs')
207
227
  fs.writeFileSync(tempFile, jsContent)
208
228
  transpiledFiles.set(filePath, tempFile)
209
229
  }
210
-
230
+
211
231
  // Start recursive transpilation from the main file
212
232
  transpileFileAndDeps(mainFilePath)
213
-
233
+
214
234
  // Get the main transpiled file
215
235
  const tempJsFile = transpiledFiles.get(mainFilePath)
216
-
236
+
217
237
  // Store all temp files for cleanup
218
238
  const allTempFiles = Array.from(transpiledFiles.values())
219
-
220
- return { tempFile: tempJsFile, allTempFiles }
239
+
240
+ return { tempFile: tempJsFile, allTempFiles, fileMapping: transpiledFiles }
241
+ }
242
+
243
+ /**
244
+ * Map error stack traces from temp .mjs files back to original .ts files
245
+ * @param {Error} error - The error object to fix
246
+ * @param {Map<string, string>} fileMapping - Map of original .ts files to temp .mjs files
247
+ * @returns {Error} - Error with fixed stack trace
248
+ */
249
+ export function fixErrorStack(error, fileMapping) {
250
+ if (!error.stack || !fileMapping) return error
251
+
252
+ let stack = error.stack
253
+
254
+ // Create reverse mapping (temp.mjs -> original.ts)
255
+ const reverseMap = new Map()
256
+ for (const [tsFile, mjsFile] of fileMapping.entries()) {
257
+ reverseMap.set(mjsFile, tsFile)
258
+ }
259
+
260
+ // Replace all temp.mjs references with original .ts files
261
+ for (const [mjsFile, tsFile] of reverseMap.entries()) {
262
+ const mjsPattern = mjsFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
263
+ stack = stack.replace(new RegExp(mjsPattern, 'g'), tsFile)
264
+ }
265
+
266
+ error.stack = stack
267
+ return error
221
268
  }
222
269
 
223
270
  /**
package/lib/workers.js CHANGED
@@ -5,6 +5,7 @@ import { mkdirp } from 'mkdirp'
5
5
  import { Worker } from 'worker_threads'
6
6
  import { EventEmitter } from 'events'
7
7
  import ms from 'ms'
8
+ import merge from 'lodash.merge'
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url)
10
11
  const __dirname = dirname(__filename)
@@ -66,21 +67,21 @@ const createWorker = (workerObject, isPoolMode = false) => {
66
67
  stdout: true,
67
68
  stderr: true,
68
69
  })
69
-
70
+
70
71
  // Pipe worker stdout/stderr to main process
71
72
  if (worker.stdout) {
72
73
  worker.stdout.setEncoding('utf8')
73
- worker.stdout.on('data', (data) => {
74
+ worker.stdout.on('data', data => {
74
75
  process.stdout.write(data)
75
76
  })
76
77
  }
77
78
  if (worker.stderr) {
78
79
  worker.stderr.setEncoding('utf8')
79
- worker.stderr.on('data', (data) => {
80
+ worker.stderr.on('data', data => {
80
81
  process.stderr.write(data)
81
82
  })
82
83
  }
83
-
84
+
84
85
  worker.on('error', err => {
85
86
  console.error(`[Main] Worker Error:`, err)
86
87
  output.error(`Worker Error: ${err.stack}`)
@@ -221,13 +222,13 @@ class WorkerObject {
221
222
 
222
223
  addConfig(config) {
223
224
  const oldConfig = JSON.parse(this.options.override || '{}')
224
-
225
+
225
226
  // Remove customLocatorStrategies from both old and new config before JSON serialization
226
227
  // since functions cannot be serialized and will be lost, causing workers to have empty strategies
227
228
  const configWithoutFunctions = { ...config }
228
-
229
+
229
230
  // Clean both old and new config
230
- const cleanConfig = (cfg) => {
231
+ const cleanConfig = cfg => {
231
232
  if (cfg.helpers) {
232
233
  cfg.helpers = { ...cfg.helpers }
233
234
  Object.keys(cfg.helpers).forEach(helperName => {
@@ -239,14 +240,12 @@ class WorkerObject {
239
240
  }
240
241
  return cfg
241
242
  }
242
-
243
+
243
244
  const cleanedOldConfig = cleanConfig(oldConfig)
244
245
  const cleanedNewConfig = cleanConfig(configWithoutFunctions)
245
-
246
- const newConfig = {
247
- ...cleanedOldConfig,
248
- ...cleanedNewConfig,
249
- }
246
+
247
+ // Deep merge configurations to preserve all helpers from base config
248
+ const newConfig = merge({}, cleanedOldConfig, cleanedNewConfig)
250
249
  this.options.override = JSON.stringify(newConfig)
251
250
  }
252
251
 
@@ -280,8 +279,8 @@ class Workers extends EventEmitter {
280
279
  this.setMaxListeners(50)
281
280
  this.codeceptPromise = initializeCodecept(config.testConfig, config.options)
282
281
  this.codecept = null
283
- this.config = config // Save config
284
- this.numberOfWorkersRequested = numberOfWorkers // Save requested worker count
282
+ this.config = config // Save config
283
+ this.numberOfWorkersRequested = numberOfWorkers // Save requested worker count
285
284
  this.options = config.options || {}
286
285
  this.errors = []
287
286
  this.numberOfWorkers = 0
@@ -304,11 +303,8 @@ class Workers extends EventEmitter {
304
303
  // Initialize workers in these cases:
305
304
  // 1. Positive number requested AND no manual workers pre-spawned
306
305
  // 2. Function-based grouping (indicated by negative number) AND no manual workers pre-spawned
307
- const shouldAutoInit = this.workers.length === 0 && (
308
- (Number.isInteger(this.numberOfWorkersRequested) && this.numberOfWorkersRequested > 0) ||
309
- (this.numberOfWorkersRequested < 0 && isFunction(this.config.by))
310
- )
311
-
306
+ const shouldAutoInit = this.workers.length === 0 && ((Number.isInteger(this.numberOfWorkersRequested) && this.numberOfWorkersRequested > 0) || (this.numberOfWorkersRequested < 0 && isFunction(this.config.by)))
307
+
312
308
  if (shouldAutoInit) {
313
309
  this._initWorkers(this.numberOfWorkersRequested, this.config)
314
310
  }
@@ -319,7 +315,7 @@ class Workers extends EventEmitter {
319
315
  this.splitTestsByGroups(numberOfWorkers, config)
320
316
  // For function-based grouping, use the actual number of test groups created
321
317
  const actualNumberOfWorkers = isFunction(config.by) ? this.testGroups.length : numberOfWorkers
322
- this.workers = createWorkerObjects(this.testGroups, this.codecept.config, config.testConfig, config.options, config.selectedRuns)
318
+ this.workers = createWorkerObjects(this.testGroups, this.codecept.config, getTestRoot(config.testConfig), config.options, config.selectedRuns)
323
319
  this.numberOfWorkers = this.workers.length
324
320
  }
325
321
 
@@ -371,9 +367,9 @@ class Workers extends EventEmitter {
371
367
  * @param {Number} numberOfWorkers
372
368
  */
373
369
  createGroupsOfTests(numberOfWorkers) {
374
- // If Codecept isn't initialized yet, return empty groups as a safe fallback
375
- if (!this.codecept) return populateGroups(numberOfWorkers)
376
- const files = this.codecept.testFiles
370
+ // If Codecept isn't initialized yet, return empty groups as a safe fallback
371
+ if (!this.codecept) return populateGroups(numberOfWorkers)
372
+ const files = this.codecept.testFiles
377
373
  const mocha = Container.mocha()
378
374
  mocha.files = files
379
375
  mocha.loadFiles()
@@ -430,7 +426,7 @@ class Workers extends EventEmitter {
430
426
  for (const file of files) {
431
427
  this.testPool.push(file)
432
428
  }
433
-
429
+
434
430
  this.testPoolInitialized = true
435
431
  }
436
432
 
@@ -443,7 +439,7 @@ class Workers extends EventEmitter {
443
439
  if (!this.testPoolInitialized) {
444
440
  this._initializeTestPool()
445
441
  }
446
-
442
+
447
443
  return this.testPool.shift()
448
444
  }
449
445
 
@@ -451,9 +447,9 @@ class Workers extends EventEmitter {
451
447
  * @param {Number} numberOfWorkers
452
448
  */
453
449
  createGroupsOfSuites(numberOfWorkers) {
454
- // If Codecept isn't initialized yet, return empty groups as a safe fallback
455
- if (!this.codecept) return populateGroups(numberOfWorkers)
456
- const files = this.codecept.testFiles
450
+ // If Codecept isn't initialized yet, return empty groups as a safe fallback
451
+ if (!this.codecept) return populateGroups(numberOfWorkers)
452
+ const files = this.codecept.testFiles
457
453
  const groups = populateGroups(numberOfWorkers)
458
454
 
459
455
  const mocha = Container.mocha()
@@ -494,7 +490,7 @@ class Workers extends EventEmitter {
494
490
  recorder.startUnlessRunning()
495
491
  event.dispatcher.emit(event.workers.before)
496
492
  process.env.RUNS_WITH_WORKERS = 'true'
497
-
493
+
498
494
  // Create workers and set up message handlers immediately (not in recorder queue)
499
495
  // This prevents a race condition where workers start sending messages before handlers are attached
500
496
  const workerThreads = []
@@ -503,11 +499,11 @@ class Workers extends EventEmitter {
503
499
  this._listenWorkerEvents(workerThread)
504
500
  workerThreads.push(workerThread)
505
501
  }
506
-
502
+
507
503
  recorder.add('workers started', () => {
508
504
  // Workers are already running, this is just a placeholder step
509
505
  })
510
-
506
+
511
507
  return new Promise(resolve => {
512
508
  this.on('end', resolve)
513
509
  })
@@ -591,7 +587,7 @@ class Workers extends EventEmitter {
591
587
  // Otherwise skip - we'll emit based on finished state
592
588
  break
593
589
  case event.test.passed:
594
- // Skip individual passed events - we'll emit based on finished state
590
+ // Skip individual passed events - we'll emit based on finished state
595
591
  break
596
592
  case event.test.skipped:
597
593
  this.emit(event.test.skipped, deserializeTest(message.data))
@@ -602,15 +598,15 @@ class Workers extends EventEmitter {
602
598
  const data = message.data
603
599
  const uid = data?.uid
604
600
  const isFailed = !!data?.err || data?.state === 'failed'
605
-
601
+
606
602
  if (uid) {
607
603
  // Track states for each test UID
608
604
  if (!this._testStates) this._testStates = new Map()
609
-
605
+
610
606
  if (!this._testStates.has(uid)) {
611
607
  this._testStates.set(uid, { states: [], lastData: data })
612
608
  }
613
-
609
+
614
610
  const testState = this._testStates.get(uid)
615
611
  testState.states.push({ isFailed, data })
616
612
  testState.lastData = data
@@ -622,7 +618,7 @@ class Workers extends EventEmitter {
622
618
  this.emit(event.test.passed, deserializeTest(data))
623
619
  }
624
620
  }
625
-
621
+
626
622
  this.emit(event.test.finished, deserializeTest(data))
627
623
  }
628
624
  break
@@ -682,11 +678,10 @@ class Workers extends EventEmitter {
682
678
  // For tests with retries configured, emit all failures + final success
683
679
  // For tests without retries, emit only final state
684
680
  const lastState = states[states.length - 1]
685
-
681
+
686
682
  // Check if this test had retries by looking for failure followed by success
687
- const hasRetryPattern = states.length > 1 &&
688
- states.some((s, i) => s.isFailed && i < states.length - 1 && !states[i + 1].isFailed)
689
-
683
+ const hasRetryPattern = states.length > 1 && states.some((s, i) => s.isFailed && i < states.length - 1 && !states[i + 1].isFailed)
684
+
690
685
  if (hasRetryPattern) {
691
686
  // Emit all intermediate failures and final success for retries
692
687
  for (const state of states) {