From eb162b143146d91155fe28552387259c707df4b2 Mon Sep 17 00:00:00 2001 From: azdemir <54518050+azdemir@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:34:57 +0000 Subject: [PATCH] fix(typescript): map step line/column back to .ts source via inline sourcemaps --- lib/step/base.js | 9 ++- lib/utils/typescript.js | 91 ++++++++++++++++++++--- package.json | 2 + test/runner/typescript_step_paths_test.js | 3 + test/unit/utils/typescript_test.js | 46 +++++++++++- 5 files changed, 136 insertions(+), 15 deletions(-) diff --git a/lib/step/base.js b/lib/step/base.js index cc3f346b3..5ecbbd177 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 { mapTranspiledStackFrame } from '../utils/typescript.js' const STACK_LINE = 5 @@ -163,7 +164,13 @@ class Step { if (lines[STACK_LINE]) { let line = lines[STACK_LINE].trim() - // Map .temp.mjs back to original .ts files using container's tsFileMapping + // Map .temp.mjs frames back to the original .ts source position using the + // inline source map emitted during transpilation (#5772) + if (line.includes('.temp.mjs')) { + line = mapTranspiledStackFrame(line) || line + } + + // Fallback: map .temp.mjs back to original .ts files using container's tsFileMapping const fileMapping = store.tsFileMapping if (line.includes('.temp.mjs') && fileMapping) { for (const [tsFile, mjsFile] of fileMapping.entries()) { diff --git a/lib/utils/typescript.js b/lib/utils/typescript.js index 3ee0fda7b..e6aeeadec 100644 --- a/lib/utils/typescript.js +++ b/lib/utils/typescript.js @@ -1,6 +1,7 @@ import fs from 'fs' import path from 'path' -import { pathToFileURL } from 'url' +import { pathToFileURL, fileURLToPath } from 'url' +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping' /** * Load tsconfig.json if it exists @@ -65,8 +66,6 @@ function resolveTsPathAlias(importPath, tsConfig, configDir) { * @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 - /** * Transpile a single TypeScript file to JavaScript * Injects CommonJS shims (require, module, exports, __dirname, __filename) as needed @@ -74,17 +73,31 @@ 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 - target: 99, // ScriptTarget.ESNext - esModuleInterop: true, - allowSyntheticDefaultImports: true, - lib: ['lib.esnext.d.ts'], // Enable latest features including top-level await - suppressOutputPathCheck: true, - skipLibCheck: true, + // Transpile TypeScript to JavaScript with ES module output. + // transpileModule (unlike transpile) can emit a source map, which we inline + // so stack frames can later be mapped back to the original .ts positions. + const { outputText, sourceMapText } = typescript.transpileModule(tsContent, { + compilerOptions: { + module: 99, // ModuleKind.ESNext + target: 99, // ScriptTarget.ESNext + esModuleInterop: true, + allowSyntheticDefaultImports: true, + lib: ['lib.esnext.d.ts'], // Enable latest features including top-level await + suppressOutputPathCheck: true, + skipLibCheck: true, + sourceMap: true, + inlineSources: true, + }, + fileName: path.resolve(filePath), }) + // transpileModule appends an external sourceMappingURL comment; we inline the map instead + let jsContent = outputText.replace(/\/\/# sourceMappingURL=.*(\r?\n)?$/, '') + const sourceMap = JSON.parse(sourceMapText) + // transpileModule records only the base file name; store the absolute path + // so mapped stack frames can be resolved back to the source file + sourceMap.sources = [path.resolve(filePath)] + // Check if the code uses CommonJS globals const usesCommonJSGlobals = /__dirname|__filename/.test(jsContent) const usesRequire = /\brequire\s*\(/.test(jsContent) @@ -150,12 +163,22 @@ const __dirname = __dirname_fn(__filename); jsContent = esmGlobals + jsContent + // The injected shim shifts every generated line down; reflect that in the + // source map by prepending one empty mapping entry per shim line. + const shimLineCount = esmGlobals.split('\n').length - 1 + sourceMap.mappings = `${';'.repeat(shimLineCount)}${sourceMap.mappings}` + // If module.exports is used, we need to export it as default if (usesModuleExports) { jsContent += `\nexport default module.exports;\n` } } + // Append the source map inline so stack frames in the temp file can be + // mapped back to the original .ts positions (see mapTranspiledStackFrame) + const inlineMap = Buffer.from(JSON.stringify(sourceMap), 'utf8').toString('base64') + jsContent += `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${inlineMap}\n` + return jsContent } @@ -407,6 +430,50 @@ const __dirname = __dirname_fn(__filename); return { tempFile: tempFileUrl, allTempFiles, fileMapping: transpiledFiles } } +const sourceMapCache = new Map() + +function getInlineSourceMap(tempFilePath) { + if (sourceMapCache.has(tempFilePath)) return sourceMapCache.get(tempFilePath) + + let traceMap = null + try { + const content = fs.readFileSync(tempFilePath, 'utf8') + const match = /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,([A-Za-z0-9+/=]+)\s*$/.exec(content) + if (match) { + traceMap = new TraceMap(JSON.parse(Buffer.from(match[1], 'base64').toString('utf8'))) + } + } catch { + // No usable inline source map; callers fall back to path-only mapping + } + + sourceMapCache.set(tempFilePath, traceMap) + return traceMap +} + +/** + * Map a stack frame pointing into a transpiled `.temp.mjs` file back to the + * original TypeScript source position, using the inline source map emitted + * during transpilation. + * + * @param {string} frame - Single stack frame line, e.g. `at Object.open (file:///path/foo.123.temp.mjs:4:11)` + * @returns {string|null} - Rewritten frame with the original source position, or null when mapping is not possible + */ +export function mapTranspiledStackFrame(frame) { + const match = /\((file:\/\/)?([^()]*?\.temp\.mjs):(\d+):(\d+)\)/.exec(frame) + if (!match) return null + + const [, isFileUrl, fileRef, genLine, genColumn] = match + const tempFilePath = isFileUrl ? fileURLToPath(`${isFileUrl}${fileRef}`) : fileRef + const traceMap = getInlineSourceMap(tempFilePath) + if (!traceMap) return null + + // V8 reports 1-based columns, trace-mapping expects 0-based + const pos = originalPositionFor(traceMap, { line: Number(genLine), column: Number(genColumn) - 1 }) + if (!pos || pos.line == null || !pos.source) return null + + return frame.replace(match[0], `(${pos.source}:${pos.line}:${pos.column + 1})`) +} + /** * Map error stack traces from temp .mjs files back to original .ts files * @param {Error} error - The error object to fix diff --git a/package.json b/package.json index 4b40a3cc6..3fc4414c7 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@cucumber/cucumber-expressions": "18", "@cucumber/gherkin": "38.0.0", "@cucumber/messages": "32.3.1", + "@jridgewell/trace-mapping": "^0.3.31", "@modelcontextprotocol/sdk": "^1.26.0", "@xmldom/xmldom": "^0.9.12", "acorn": "8.15.0", @@ -133,6 +134,7 @@ "parse-function": "5.6.10", "parse5": "7.3.0", "promise-retry": "1.1.1", + "regexp-tree": "^0.1.27", "sprintf-js": "1.1.3", "uuid": "11.1.0", "ws": "^8.21.2", diff --git a/test/runner/typescript_step_paths_test.js b/test/runner/typescript_step_paths_test.js index e829df901..f27e8908e 100644 --- a/test/runner/typescript_step_paths_test.js +++ b/test/runner/typescript_step_paths_test.js @@ -20,6 +20,9 @@ describe('TypeScript step paths', () => { expect(err).toBeTruthy() expect(stdout).toContain('Scenario Steps:') expect(stdout).toMatch(/at Object\.open \(\.\/pages\/fooPage\.ts:\d+:\d+\)/) + // #5772: line/column must come from the .ts source, not the transpiled .temp.mjs + // (I.doThing is on line 7 of pages/fooPage.ts) + expect(stdout).toMatch(/at Object\.open \(\.\/pages\/fooPage\.ts:7:7\)/) expect(stdout).not.toContain('.temp.mjs') expect(stdout).not.toContain('file://./pages/fooPage.ts') done() diff --git a/test/unit/utils/typescript_test.js b/test/unit/utils/typescript_test.js index 5f57ca71a..8e14af084 100644 --- a/test/unit/utils/typescript_test.js +++ b/test/unit/utils/typescript_test.js @@ -1,8 +1,10 @@ import { expect } from 'chai' -import { fileURLToPath } from 'url' +import { fileURLToPath, pathToFileURL } from 'url' import path from 'path' +import fs from 'fs' +import os from 'os' import { createRequire } from 'module' -import { transpileTypeScript, cleanupTempFiles } from '../../../lib/utils/typescript.js' +import { transpileTypeScript, mapTranspiledStackFrame, cleanupTempFiles } from '../../../lib/utils/typescript.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) @@ -33,4 +35,44 @@ describe('TypeScript transpilation', () => { cleanupTempFiles(second.allTempFiles) } }) + + it('maps transpiled stack frames back to the original .ts positions (#5772)', async () => { + const pageObject = path.resolve(__dirname, '../../data/sandbox/typescript-step-paths/pages/fooPage.ts') + const { tempFile, allTempFiles } = await transpileTypeScript(pageObject, typescript) + + try { + // I.doThing('from page') is on line 7 of fooPage.ts; the transpiled + // .temp.mjs reports it at 4:11 + const frame = `at Object.open (${tempFile}:4:11)` + expect(mapTranspiledStackFrame(frame)).to.equal(`at Object.open (${pageObject}:7:7)`) + } finally { + cleanupTempFiles(allTempFiles) + } + }) + + it('accounts for injected CommonJS shims when mapping positions', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codecept-ts-')) + const tsFile = path.join(tmpDir, 'shimmed.ts') + fs.writeFileSync(tsFile, 'const path = require("path");\nexport default {\n open() {\n const p = path.join("a", "b");\n return p;\n },\n};\n') + + const { tempFile, allTempFiles } = await transpileTypeScript(tsFile, typescript) + try { + const tempPath = fileURLToPath(tempFile) + const tempLines = fs.readFileSync(tempPath, 'utf8').split('\n') + const tempLine = tempLines.findIndex(l => l.includes('path.join')) + 1 + expect(tempLine).to.be.greaterThan(4) // shim injection shifted lines down + + const frame = `at Object.open (${tempFile}:${tempLine}:15)` + const mapped = mapTranspiledStackFrame(frame) + expect(mapped).to.match(/\(.*shimmed\.ts:4:\d+\)/) + } finally { + cleanupTempFiles(allTempFiles) + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('returns null for frames that cannot be mapped', () => { + expect(mapTranspiledStackFrame('at Object.open (/some/file.js:1:1)')).to.be.null + expect(mapTranspiledStackFrame('at Object.open (file:///does/not/exist.123.temp.mjs:1:1)')).to.be.null + }) })