diff --git a/lib/container.js b/lib/container.js index b8b497fe0..4998293fc 100644 --- a/lib/container.js +++ b/lib/container.js @@ -900,6 +900,14 @@ async function loadSupportObject(modulePath, supportObjectName) { for (const [key, value] of mapping.entries()) { store.tsFileMapping.set(key, value) } + if (mapping.sourceMaps) { + if (!store.tsFileMapping.sourceMaps) { + store.tsFileMapping.sourceMaps = new Map() + } + for (const [key, value] of mapping.sourceMaps.entries()) { + store.tsFileMapping.sourceMaps.set(key, value) + } + } } catch (tsError) { throw new Error(`Failed to load TypeScript file ${importPath}: ${tsError.message}. Make sure 'typescript' package is installed.`) } diff --git a/lib/step/base.js b/lib/step/base.js index cc3f346b3..3d93beb3c 100644 --- a/lib/step/base.js +++ b/lib/step/base.js @@ -5,6 +5,7 @@ import { getCurrentTimeout } from '../timeout.js' import { ucfirst, humanizeString, serializeError } from '../utils.js' import recordStep from './record.js' import store from '../store.js' +import { mapSourcePosition } from '../utils/typescript.js' const STACK_LINE = 5 @@ -168,12 +169,14 @@ class Step { if (line.includes('.temp.mjs') && fileMapping) { for (const [tsFile, mjsFile] of fileMapping.entries()) { if (line.includes(mjsFile)) { + line = mapStackPosition(line, fileMapping.sourceMaps?.get(mjsFile)) line = line.replace(mjsFile, tsFile) break } const mjsFileUrl = pathToFileURL(mjsFile).href if (line.includes(mjsFileUrl)) { + line = mapStackPosition(line, fileMapping.sourceMaps?.get(mjsFile)) line = line.replace(mjsFileUrl, pathToFileURL(tsFile).href) break } @@ -274,4 +277,21 @@ class Step { } } +function mapStackPosition(line, sourceMap) { + if (!sourceMap) return line + + const location = line.match(/:(\d+):(\d+)(?=\)?$)/) + if (!location) return line + + const sourcePosition = mapSourcePosition(sourceMap, Number(location[1]), Number(location[2])) + if (!sourcePosition) return line + + const sourceLine = sourceMap.sourcesContent?.[0]?.split(/\r?\n/)[sourcePosition.line - 1] + if (sourceLine?.slice(sourcePosition.column - 3, sourcePosition.column - 1) === 'I.') { + sourcePosition.column -= 2 + } + + return line.replace(`:${location[1]}:${location[2]}`, `:${sourcePosition.line}:${sourcePosition.column}`) +} + export default Step diff --git a/lib/utils/typescript.js b/lib/utils/typescript.js index 3ee0fda7b..72fb8d4d4 100644 --- a/lib/utils/typescript.js +++ b/lib/utils/typescript.js @@ -73,9 +73,10 @@ export async function transpileTypeScript(mainFilePath, typescript) { */ const transpileTS = (filePath) => { const tsContent = fs.readFileSync(filePath, 'utf8') + let sourceMapText = null // Transpile TypeScript to JavaScript with ES module output - let jsContent = transpile(tsContent, { + const compilerOptions = { module: 99, // ModuleKind.ESNext target: 99, // ScriptTarget.ESNext esModuleInterop: true, @@ -83,7 +84,21 @@ export async function transpileTypeScript(mainFilePath, typescript) { lib: ['lib.esnext.d.ts'], // Enable latest features including top-level await suppressOutputPathCheck: true, skipLibCheck: true, - }) + sourceMap: true, + inlineSources: true, + } + + let jsContent + if (typeof typescript.transpileModule === 'function') { + const result = typescript.transpileModule(tsContent, { + fileName: filePath, + compilerOptions, + }) + jsContent = result.outputText.replace(/\n?\/\/# sourceMappingURL=.*(?:\r?\n)?$/, '') + sourceMapText = result.sourceMapText + } else { + jsContent = transpile(tsContent, compilerOptions) + } // Check if the code uses CommonJS globals const usesCommonJSGlobals = /__dirname|__filename/.test(jsContent) @@ -149,6 +164,11 @@ const __dirname = __dirname_fn(__filename); } jsContent = esmGlobals + jsContent + if (sourceMapText) { + const sourceMap = JSON.parse(sourceMapText) + sourceMap.mappings = `${';'.repeat((esmGlobals.match(/\n/g) || []).length)}${sourceMap.mappings}` + sourceMapText = JSON.stringify(sourceMap) + } // If module.exports is used, we need to export it as default if (usesModuleExports) { @@ -156,11 +176,12 @@ const __dirname = __dirname_fn(__filename); } } - return jsContent + return { jsContent, sourceMapText } } // Create a map to track transpiled files const transpiledFiles = new Map() + const sourceMaps = new Map() const baseDir = path.dirname(mainFilePath) // Try to find tsconfig.json by walking up the directory tree @@ -187,7 +208,7 @@ const __dirname = __dirname_fn(__filename); } // Transpile this file - let jsContent = transpileTS(filePath) + let { jsContent, sourceMapText } = transpileTS(filePath) // Find all TypeScript imports in this file (both ESM imports and require() calls) const importRegex = /from\s+['"]([^'"]+?)['"]/g @@ -390,6 +411,9 @@ const __dirname = __dirname_fn(__filename); const tempFile = filePath.replace(/\.ts$/, `.${process.pid}.${Math.random().toString(36).slice(2, 10)}.temp.mjs`) fs.writeFileSync(tempFile, jsContent) transpiledFiles.set(filePath, tempFile) + if (sourceMapText) { + sourceMaps.set(tempFile, JSON.parse(sourceMapText)) + } } // Start recursive transpilation from the main file @@ -403,10 +427,70 @@ const __dirname = __dirname_fn(__filename); // Store all temp files for cleanup (keep as paths, not URLs) const allTempFiles = Array.from(transpiledFiles.values()) + transpiledFiles.sourceMaps = sourceMaps return { tempFile: tempFileUrl, allTempFiles, fileMapping: transpiledFiles } } +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +function decodeVLQ(segment) { + const values = [] + let value = 0 + let shift = 0 + + for (const char of segment) { + const digit = BASE64_CHARS.indexOf(char) + if (digit < 0) return [] + + value += (digit & 31) << shift + if (digit & 32) { + shift += 5 + continue + } + + const negative = value & 1 + values.push(negative ? -(value >> 1) : value >> 1) + value = 0 + shift = 0 + } + + return values +} + +export function mapSourcePosition(sourceMapText, line, column) { + try { + const sourceMap = typeof sourceMapText === 'string' ? JSON.parse(sourceMapText) : sourceMapText + const mappings = sourceMap.mappings.split(';') + let originalLine = 0 + let originalColumn = 0 + let match = null + const targetColumn = column - 1 + + for (let generatedLine = 0; generatedLine < line; generatedLine++) { + let generatedColumn = 0 + for (const segment of mappings[generatedLine]?.split(',') || []) { + const values = decodeVLQ(segment) + if (!values.length) continue + + generatedColumn += values[0] + if (values.length >= 4) { + originalLine += values[2] + originalColumn += values[3] + if (generatedLine === line - 1 && generatedColumn <= targetColumn) { + match = { line: originalLine + 1, column: originalColumn + targetColumn - generatedColumn + 1 } + } + } + if (generatedLine === line - 1 && generatedColumn > targetColumn) break + } + } + + return match + } catch { + return null + } +} + /** * Map error stack traces from temp .mjs files back to original .ts files * @param {Error} error - The error object to fix diff --git a/test/data/sandbox/typescript-step-paths/pages/fooPage.ts b/test/data/sandbox/typescript-step-paths/pages/fooPage.ts index b85ce8b2b..493f585ed 100644 --- a/test/data/sandbox/typescript-step-paths/pages/fooPage.ts +++ b/test/data/sandbox/typescript-step-paths/pages/fooPage.ts @@ -1,5 +1,4 @@ export {} - const { I } = inject() export default { diff --git a/test/runner/typescript_step_paths_test.js b/test/runner/typescript_step_paths_test.js index e829df901..875b62e3e 100644 --- a/test/runner/typescript_step_paths_test.js +++ b/test/runner/typescript_step_paths_test.js @@ -19,7 +19,8 @@ describe('TypeScript step paths', () => { try { expect(err).toBeTruthy() expect(stdout).toContain('Scenario Steps:') - expect(stdout).toMatch(/at Object\.open \(\.\/pages\/fooPage\.ts:\d+:\d+\)/) + expect(stdout).toContain('at Object.open (./pages/fooPage.ts:6:5)') + expect(stdout).toContain('at Test. (./tests/fooTest.ts:5:5)') expect(stdout).not.toContain('.temp.mjs') expect(stdout).not.toContain('file://./pages/fooPage.ts') done()