diff --git a/lib/command/workers/runTests.js b/lib/command/workers/runTests.js index 8561bda18..f9d3d36ec 100644 --- a/lib/command/workers/runTests.js +++ b/lib/command/workers/runTests.js @@ -104,7 +104,9 @@ initPromise = (async function () { // important deep merge so dynamic things e.g. functions on config are not overridden config = deepMerge(baseConfig, overrideConfigs) - codecept = new Codecept(config, options) + // Pass workerIndex as child option for output.process() to display worker prefix + const optsWithChild = { ...options, child: workerIndex } + codecept = new Codecept(config, optsWithChild) await codecept.init(testRoot) codecept.loadTests() mocha = container.mocha() diff --git a/lib/config.js b/lib/config.js index dd6692694..ea9f460c7 100644 --- a/lib/config.js +++ b/lib/config.js @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import { createRequire } from 'module' import { fileExists, isFile, deepMerge, deepClone } from './utils.js' -import { transpileTypeScript, cleanupTempFiles } from './utils/typescript.js' +import { transpileTypeScript, cleanupTempFiles, fixErrorStack } from './utils/typescript.js' const defaultConfig = { output: './_output', @@ -159,12 +159,13 @@ async function loadConfigFile(configFile) { try { // Use the TypeScript transpilation utility const typescript = require('typescript') - const { tempFile, allTempFiles } = await transpileTypeScript(configFile, typescript) + const { tempFile, allTempFiles, fileMapping } = await transpileTypeScript(configFile, typescript) try { configModule = await import(tempFile) cleanupTempFiles(allTempFiles) } catch (err) { + fixErrorStack(err, fileMapping) cleanupTempFiles(allTempFiles) throw err } diff --git a/lib/container.js b/lib/container.js index e5bdb4dfb..fc5bfb2e1 100644 --- a/lib/container.js +++ b/lib/container.js @@ -5,7 +5,7 @@ import debugModule from 'debug' const debug = debugModule('codeceptjs:container') import { MetaStep } from './step.js' import { methodsOfObject, fileExists, isFunction, isAsyncFunction, installedLocally, deepMerge } from './utils.js' -import { transpileTypeScript, cleanupTempFiles } from './utils/typescript.js' +import { transpileTypeScript, cleanupTempFiles, fixErrorStack } from './utils/typescript.js' import Translation from './translation.js' import MochaFactory from './mocha/factory.js' import recorder from './recorder.js' @@ -34,6 +34,7 @@ let container = { /** @type {Result | null} */ result: null, sharedKeys: new Set(), // Track keys shared via share() function + tsFileMapping: null, // TypeScript file mapping for error stack fixing } /** @@ -88,7 +89,7 @@ class Container { container.support.I = mod } } catch (e) { - throw new Error(`Could not include object I: ${e.message}`) + throw e } } else { // Create default actor - this sets up the callback in asyncHelperPromise @@ -176,6 +177,15 @@ class Container { return container.translation } + /** + * Get TypeScript file mapping for error stack fixing + * + * @api + */ + static tsFileMapping() { + return container.tsFileMapping + } + /** * Get Mocha instance * @@ -401,18 +411,27 @@ async function requireHelperFromModule(helperName, config, HelperClass) { // Handle TypeScript files let importPath = moduleName let tempJsFile = null + let fileMapping = null const ext = path.extname(moduleName) if (ext === '.ts') { try { // Use the TypeScript transpilation utility const typescript = await import('typescript') - const { tempFile, allTempFiles } = await transpileTypeScript(importPath, typescript) + const { tempFile, allTempFiles, fileMapping: mapping } = await transpileTypeScript(importPath, typescript) debug(`Transpiled TypeScript helper: ${importPath} -> ${tempFile}`) importPath = tempFile tempJsFile = allTempFiles + fileMapping = mapping + // Store file mapping in container for runtime error fixing (merge with existing) + if (!container.tsFileMapping) { + container.tsFileMapping = new Map() + } + for (const [key, value] of mapping.entries()) { + container.tsFileMapping.set(key, value) + } } catch (tsError) { throw new Error(`Failed to load TypeScript helper ${importPath}: ${tsError.message}. Make sure 'typescript' package is installed.`) } @@ -433,6 +452,11 @@ async function requireHelperFromModule(helperName, config, HelperClass) { cleanupTempFiles(filesToClean) } } catch (err) { + // Fix error stack to point to original .ts files + if (fileMapping) { + fixErrorStack(err, fileMapping) + } + // Clean up temp files before rethrowing if (tempJsFile) { const filesToClean = Array.isArray(tempJsFile) ? tempJsFile : [tempJsFile] @@ -731,6 +755,7 @@ async function loadSupportObject(modulePath, supportObjectName) { // Use dynamic import for both ESM and CJS modules let importPath = modulePath let tempJsFile = null + let fileMapping = null if (typeof importPath === 'string') { const ext = path.extname(importPath) @@ -740,7 +765,7 @@ async function loadSupportObject(modulePath, supportObjectName) { try { // Use the TypeScript transpilation utility const typescript = await import('typescript') - const { tempFile, allTempFiles } = await transpileTypeScript(importPath, typescript) + const { tempFile, allTempFiles, fileMapping: mapping } = await transpileTypeScript(importPath, typescript) debug(`Transpiled TypeScript file: ${importPath} -> ${tempFile}`) @@ -748,6 +773,14 @@ async function loadSupportObject(modulePath, supportObjectName) { importPath = tempFile // Store temp files list in a way that cleanup can access them tempJsFile = allTempFiles + fileMapping = mapping + // Store file mapping in container for runtime error fixing (merge with existing) + if (!container.tsFileMapping) { + container.tsFileMapping = new Map() + } + for (const [key, value] of mapping.entries()) { + container.tsFileMapping.set(key, value) + } } catch (tsError) { throw new Error(`Failed to load TypeScript file ${importPath}: ${tsError.message}. Make sure 'typescript' package is installed.`) } @@ -761,6 +794,11 @@ async function loadSupportObject(modulePath, supportObjectName) { try { obj = await import(importPath) } catch (importError) { + // Fix error stack to point to original .ts files + if (fileMapping) { + fixErrorStack(importError, fileMapping) + } + // Clean up temp files if created before rethrowing if (tempJsFile) { const filesToClean = Array.isArray(tempJsFile) ? tempJsFile : [tempJsFile] @@ -809,7 +847,9 @@ async function loadSupportObject(modulePath, supportObjectName) { throw new Error(`Support object "${supportObjectName}" should be an object, class, or function, but got ${typeof actualObj}`) } catch (err) { - throw new Error(`Could not include object ${supportObjectName} from module '${modulePath}'\n${err.message}\n${err.stack}`) + const newErr = new Error(`Could not include object ${supportObjectName} from module '${modulePath}': ${err.message}`) + newErr.stack = err.stack + throw newErr } } diff --git a/lib/step/base.js b/lib/step/base.js index f698f3ea6..1000c048e 100644 --- a/lib/step/base.js +++ b/lib/step/base.js @@ -147,9 +147,22 @@ class Step { line() { const lines = this.stack.split('\n') if (lines[STACK_LINE]) { - return lines[STACK_LINE].trim() + let line = lines[STACK_LINE].trim() .replace(global.codecept_dir || '', '.') .trim() + + // Map .temp.mjs back to original .ts files using container's tsFileMapping + const fileMapping = global.container?.tsFileMapping?.() + if (line.includes('.temp.mjs') && fileMapping) { + for (const [tsFile, mjsFile] of fileMapping.entries()) { + if (line.includes(mjsFile)) { + line = line.replace(mjsFile, tsFile) + break + } + } + } + + return line } return '' } diff --git a/lib/step/record.js b/lib/step/record.js index fdebe5681..3964eddda 100644 --- a/lib/step/record.js +++ b/lib/step/record.js @@ -5,6 +5,7 @@ import output from '../output.js' import store from '../store.js' import { TIMEOUT_ORDER } from '../timeout.js' import retryStep from './retry.js' +import { fixErrorStack } from '../utils/typescript.js' function recordStep(step, args) { step.status = 'queued' @@ -60,6 +61,13 @@ function recordStep(step, args) { recorder.catch(err => { step.status = 'failed' step.endTime = +Date.now() + + // Fix error stack to point to original .ts files (lazy import to avoid circular dependency) + const fileMapping = global.container?.tsFileMapping?.() + if (fileMapping) { + fixErrorStack(err, fileMapping) + } + event.emit(event.step.failed, step, err) event.emit(event.step.finished, step) throw err diff --git a/lib/utils/typescript.js b/lib/utils/typescript.js index c19badfc3..11d9fea05 100644 --- a/lib/utils/typescript.js +++ b/lib/utils/typescript.js @@ -4,10 +4,10 @@ import path from 'path' /** * Transpile TypeScript files to ES modules with CommonJS shim support * Handles recursive transpilation of imported TypeScript files - * + * * @param {string} mainFilePath - Path to the main TypeScript file to transpile * @param {object} typescript - TypeScript compiler instance - * @returns {Promise<{tempFile: string, allTempFiles: string[]}>} - Main temp file and all temp files created + * @returns {Promise<{tempFile: string, allTempFiles: string[], fileMapping: any}>} - Main temp file and all temp files created */ export async function transpileTypeScript(mainFilePath, typescript) { const { transpile } = typescript @@ -18,7 +18,7 @@ export async function transpileTypeScript(mainFilePath, typescript) { */ const transpileTS = (filePath) => { const tsContent = fs.readFileSync(filePath, 'utf8') - + // Transpile TypeScript to JavaScript with ES module output let jsContent = transpile(tsContent, { module: 99, // ModuleKind.ESNext @@ -29,16 +29,16 @@ export async function transpileTypeScript(mainFilePath, typescript) { suppressOutputPathCheck: true, skipLibCheck: true, }) - + // Check if the code uses CommonJS globals const usesCommonJSGlobals = /__dirname|__filename/.test(jsContent) const usesRequire = /\brequire\s*\(/.test(jsContent) const usesModuleExports = /\b(module\.exports|exports\.)/.test(jsContent) - + if (usesCommonJSGlobals || usesRequire || usesModuleExports) { // Inject ESM equivalents at the top of the file let esmGlobals = '' - + if (usesRequire || usesModuleExports) { // IMPORTANT: Use the original .ts file path as the base for require() // This ensures dynamic require() calls work with relative paths from the original file location @@ -81,7 +81,7 @@ const exports = module.exports; ` } - + if (usesCommonJSGlobals) { // For __dirname and __filename, also use the original file path const originalFileUrl = `file://${filePath.replace(/\\/g, '/')}` @@ -92,48 +92,48 @@ const __dirname = __dirname_fn(__filename); ` } - + jsContent = esmGlobals + jsContent - + // If module.exports is used, we need to export it as default if (usesModuleExports) { jsContent += `\nexport default module.exports;\n` } } - + return jsContent } - + // Create a map to track transpiled files const transpiledFiles = new Map() const baseDir = path.dirname(mainFilePath) - + // Recursive function to transpile a file and all its TypeScript dependencies const transpileFileAndDeps = (filePath) => { // Already transpiled, skip if (transpiledFiles.has(filePath)) { return } - + // Transpile this file let jsContent = transpileTS(filePath) - + // Find all relative TypeScript imports in this file const importRegex = /from\s+['"](\..+?)(?:\.ts)?['"]/g let match const imports = [] - + while ((match = importRegex.exec(jsContent)) !== null) { imports.push(match[1]) } - + // Get the base directory for this file const fileBaseDir = path.dirname(filePath) - + // Recursively transpile each imported TypeScript file for (const relativeImport of imports) { let importedPath = path.resolve(fileBaseDir, relativeImport) - + // Handle .js extensions that might actually be .ts files if (importedPath.endsWith('.js')) { const tsVersion = importedPath.replace(/\.js$/, '.ts') @@ -141,12 +141,12 @@ const __dirname = __dirname_fn(__filename); importedPath = tsVersion } } - + // Check for standard module extensions to determine if we should try adding .ts const ext = path.extname(importedPath) const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node'] const hasStandardExtension = standardExtensions.includes(ext.toLowerCase()) - + // If it doesn't end with .ts and doesn't have a standard extension, try adding .ts if (!importedPath.endsWith('.ts') && !hasStandardExtension) { const tsPath = importedPath + '.ts' @@ -161,20 +161,20 @@ const __dirname = __dirname_fn(__filename); } } } - + // If it's a TypeScript file, recursively transpile it and its dependencies if (importedPath.endsWith('.ts') && fs.existsSync(importedPath)) { transpileFileAndDeps(importedPath) } } - + // After all dependencies are transpiled, rewrite imports in this file jsContent = jsContent.replace( /from\s+['"](\..+?)(?:\.ts)?['"]/g, (match, importPath) => { let resolvedPath = path.resolve(fileBaseDir, importPath) const originalExt = path.extname(importPath) - + // Handle .js extension that might be .ts if (resolvedPath.endsWith('.js')) { const tsVersion = resolvedPath.replace(/\.js$/, '.ts') @@ -190,10 +190,10 @@ const __dirname = __dirname_fn(__filename); // Keep .js extension as-is (might be a real .js file) return match } - + // Try with .ts extension const tsPath = resolvedPath.endsWith('.ts') ? resolvedPath : resolvedPath + '.ts' - + // If we transpiled this file, use the temp file if (transpiledFiles.has(tsPath)) { const tempFile = transpiledFiles.get(tsPath) @@ -204,7 +204,7 @@ const __dirname = __dirname_fn(__filename); } return `from '${relPath}'` } - + // If the import doesn't have a standard module extension (.js, .mjs, .cjs, .json) // add .js for ESM compatibility // This handles cases where: @@ -212,32 +212,59 @@ const __dirname = __dirname_fn(__filename); // 2. Import has a non-standard extension that's part of the name (e.g., "./abstract.helper") const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node'] const hasStandardExtension = standardExtensions.includes(originalExt.toLowerCase()) - + if (!hasStandardExtension) { return match.replace(importPath, importPath + '.js') } - + // Otherwise, keep the import as-is return match } ) - + // Write the transpiled file with updated imports const tempFile = filePath.replace(/\.ts$/, '.temp.mjs') fs.writeFileSync(tempFile, jsContent) transpiledFiles.set(filePath, tempFile) } - + // Start recursive transpilation from the main file transpileFileAndDeps(mainFilePath) - + // Get the main transpiled file const tempJsFile = transpiledFiles.get(mainFilePath) - + // Store all temp files for cleanup const allTempFiles = Array.from(transpiledFiles.values()) - - return { tempFile: tempJsFile, allTempFiles } + + return { tempFile: tempJsFile, allTempFiles, fileMapping: transpiledFiles } +} + +/** + * Map error stack traces from temp .mjs files back to original .ts files + * @param {Error} error - The error object to fix + * @param {Map} fileMapping - Map of original .ts files to temp .mjs files + * @returns {Error} - Error with fixed stack trace + */ +export function fixErrorStack(error, fileMapping) { + if (!error.stack || !fileMapping) return error + + let stack = error.stack + + // Create reverse mapping (temp.mjs -> original.ts) + const reverseMap = new Map() + for (const [tsFile, mjsFile] of fileMapping.entries()) { + reverseMap.set(mjsFile, tsFile) + } + + // Replace all temp.mjs references with original .ts files + for (const [mjsFile, tsFile] of reverseMap.entries()) { + const mjsPattern = mjsFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + stack = stack.replace(new RegExp(mjsPattern, 'g'), tsFile) + } + + error.stack = stack + return error } /** diff --git a/lib/workers.js b/lib/workers.js index 80dca4e1b..8fa9e9dd6 100644 --- a/lib/workers.js +++ b/lib/workers.js @@ -604,7 +604,7 @@ class Workers extends EventEmitter { if (!this._testStates) this._testStates = new Map() if (!this._testStates.has(uid)) { - this._testStates.set(uid, { states: [], lastData: data }) + this._testStates.set(uid, { states: [], lastData: data, workerIndex: message.workerIndex }) } const testState = this._testStates.get(uid) @@ -674,7 +674,10 @@ class Workers extends EventEmitter { // Emit states for all tracked tests before emitting results if (this._testStates) { - for (const [uid, { states, lastData }] of this._testStates) { + for (const [uid, { states, lastData, workerIndex }] of this._testStates) { + // Set correct worker index for output + output.process(workerIndex) + // For tests with retries configured, emit all failures + final success // For tests without retries, emit only final state const lastState = states[states.length - 1] diff --git a/typings/index.d.ts b/typings/index.d.ts index aba11e7a2..50e3fcc95 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -519,7 +519,7 @@ declare namespace CodeceptJS { retry(retries?: number): HookConfig } - function addStep(step: string, fn: Function): Promise + function addStep(step: string | RegExp, fn: Function): Promise } type TryTo = (fn: () => Promise | T) => Promise