Skip to content

Commit eb162b1

Browse files
committed
fix(typescript): map step line/column back to .ts source via inline sourcemaps
1 parent 744dba8 commit eb162b1

5 files changed

Lines changed: 136 additions & 15 deletions

File tree

‎lib/step/base.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getCurrentTimeout } from '../timeout.js'
55
import { ucfirst, humanizeString, serializeError } from '../utils.js'
66
import recordStep from './record.js'
77
import store from '../store.js'
8+
import { mapTranspiledStackFrame } from '../utils/typescript.js'
89

910
const STACK_LINE = 5
1011

@@ -163,7 +164,13 @@ class Step {
163164
if (lines[STACK_LINE]) {
164165
let line = lines[STACK_LINE].trim()
165166

166-
// Map .temp.mjs back to original .ts files using container's tsFileMapping
167+
// Map .temp.mjs frames back to the original .ts source position using the
168+
// inline source map emitted during transpilation (#5772)
169+
if (line.includes('.temp.mjs')) {
170+
line = mapTranspiledStackFrame(line) || line
171+
}
172+
173+
// Fallback: map .temp.mjs back to original .ts files using container's tsFileMapping
167174
const fileMapping = store.tsFileMapping
168175
if (line.includes('.temp.mjs') && fileMapping) {
169176
for (const [tsFile, mjsFile] of fileMapping.entries()) {

‎lib/utils/typescript.js‎

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from 'fs'
22
import path from 'path'
3-
import { pathToFileURL } from 'url'
3+
import { pathToFileURL, fileURLToPath } from 'url'
4+
import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'
45

56
/**
67
* Load tsconfig.json if it exists
@@ -65,26 +66,38 @@ function resolveTsPathAlias(importPath, tsConfig, configDir) {
6566
* @returns {Promise<{tempFile: string, allTempFiles: string[], fileMapping: any}>} - Main temp file and all temp files created
6667
*/
6768
export async function transpileTypeScript(mainFilePath, typescript) {
68-
const { transpile } = typescript
69-
7069
/**
7170
* Transpile a single TypeScript file to JavaScript
7271
* Injects CommonJS shims (require, module, exports, __dirname, __filename) as needed
7372
*/
7473
const transpileTS = (filePath) => {
7574
const tsContent = fs.readFileSync(filePath, 'utf8')
7675

77-
// Transpile TypeScript to JavaScript with ES module output
78-
let jsContent = transpile(tsContent, {
79-
module: 99, // ModuleKind.ESNext
80-
target: 99, // ScriptTarget.ESNext
81-
esModuleInterop: true,
82-
allowSyntheticDefaultImports: true,
83-
lib: ['lib.esnext.d.ts'], // Enable latest features including top-level await
84-
suppressOutputPathCheck: true,
85-
skipLibCheck: true,
76+
// Transpile TypeScript to JavaScript with ES module output.
77+
// transpileModule (unlike transpile) can emit a source map, which we inline
78+
// so stack frames can later be mapped back to the original .ts positions.
79+
const { outputText, sourceMapText } = typescript.transpileModule(tsContent, {
80+
compilerOptions: {
81+
module: 99, // ModuleKind.ESNext
82+
target: 99, // ScriptTarget.ESNext
83+
esModuleInterop: true,
84+
allowSyntheticDefaultImports: true,
85+
lib: ['lib.esnext.d.ts'], // Enable latest features including top-level await
86+
suppressOutputPathCheck: true,
87+
skipLibCheck: true,
88+
sourceMap: true,
89+
inlineSources: true,
90+
},
91+
fileName: path.resolve(filePath),
8692
})
8793

94+
// transpileModule appends an external sourceMappingURL comment; we inline the map instead
95+
let jsContent = outputText.replace(/\/\/# sourceMappingURL=.*(\r?\n)?$/, '')
96+
const sourceMap = JSON.parse(sourceMapText)
97+
// transpileModule records only the base file name; store the absolute path
98+
// so mapped stack frames can be resolved back to the source file
99+
sourceMap.sources = [path.resolve(filePath)]
100+
88101
// Check if the code uses CommonJS globals
89102
const usesCommonJSGlobals = /__dirname|__filename/.test(jsContent)
90103
const usesRequire = /\brequire\s*\(/.test(jsContent)
@@ -150,12 +163,22 @@ const __dirname = __dirname_fn(__filename);
150163

151164
jsContent = esmGlobals + jsContent
152165

166+
// The injected shim shifts every generated line down; reflect that in the
167+
// source map by prepending one empty mapping entry per shim line.
168+
const shimLineCount = esmGlobals.split('\n').length - 1
169+
sourceMap.mappings = `${';'.repeat(shimLineCount)}${sourceMap.mappings}`
170+
153171
// If module.exports is used, we need to export it as default
154172
if (usesModuleExports) {
155173
jsContent += `\nexport default module.exports;\n`
156174
}
157175
}
158176

177+
// Append the source map inline so stack frames in the temp file can be
178+
// mapped back to the original .ts positions (see mapTranspiledStackFrame)
179+
const inlineMap = Buffer.from(JSON.stringify(sourceMap), 'utf8').toString('base64')
180+
jsContent += `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${inlineMap}\n`
181+
159182
return jsContent
160183
}
161184

@@ -407,6 +430,50 @@ const __dirname = __dirname_fn(__filename);
407430
return { tempFile: tempFileUrl, allTempFiles, fileMapping: transpiledFiles }
408431
}
409432

433+
const sourceMapCache = new Map()
434+
435+
function getInlineSourceMap(tempFilePath) {
436+
if (sourceMapCache.has(tempFilePath)) return sourceMapCache.get(tempFilePath)
437+
438+
let traceMap = null
439+
try {
440+
const content = fs.readFileSync(tempFilePath, 'utf8')
441+
const match = /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,([A-Za-z0-9+/=]+)\s*$/.exec(content)
442+
if (match) {
443+
traceMap = new TraceMap(JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')))
444+
}
445+
} catch {
446+
// No usable inline source map; callers fall back to path-only mapping
447+
}
448+
449+
sourceMapCache.set(tempFilePath, traceMap)
450+
return traceMap
451+
}
452+
453+
/**
454+
* Map a stack frame pointing into a transpiled `.temp.mjs` file back to the
455+
* original TypeScript source position, using the inline source map emitted
456+
* during transpilation.
457+
*
458+
* @param {string} frame - Single stack frame line, e.g. `at Object.open (file:///path/foo.123.temp.mjs:4:11)`
459+
* @returns {string|null} - Rewritten frame with the original source position, or null when mapping is not possible
460+
*/
461+
export function mapTranspiledStackFrame(frame) {
462+
const match = /\((file:\/\/)?([^()]*?\.temp\.mjs):(\d+):(\d+)\)/.exec(frame)
463+
if (!match) return null
464+
465+
const [, isFileUrl, fileRef, genLine, genColumn] = match
466+
const tempFilePath = isFileUrl ? fileURLToPath(`${isFileUrl}${fileRef}`) : fileRef
467+
const traceMap = getInlineSourceMap(tempFilePath)
468+
if (!traceMap) return null
469+
470+
// V8 reports 1-based columns, trace-mapping expects 0-based
471+
const pos = originalPositionFor(traceMap, { line: Number(genLine), column: Number(genColumn) - 1 })
472+
if (!pos || pos.line == null || !pos.source) return null
473+
474+
return frame.replace(match[0], `(${pos.source}:${pos.line}:${pos.column + 1})`)
475+
}
476+
410477
/**
411478
* Map error stack traces from temp .mjs files back to original .ts files
412479
* @param {Error} error - The error object to fix

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"@cucumber/cucumber-expressions": "18",
9898
"@cucumber/gherkin": "38.0.0",
9999
"@cucumber/messages": "32.3.1",
100+
"@jridgewell/trace-mapping": "^0.3.31",
100101
"@modelcontextprotocol/sdk": "^1.26.0",
101102
"@xmldom/xmldom": "^0.9.12",
102103
"acorn": "8.15.0",
@@ -133,6 +134,7 @@
133134
"parse-function": "5.6.10",
134135
"parse5": "7.3.0",
135136
"promise-retry": "1.1.1",
137+
"regexp-tree": "^0.1.27",
136138
"sprintf-js": "1.1.3",
137139
"uuid": "11.1.0",
138140
"ws": "^8.21.2",

‎test/runner/typescript_step_paths_test.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ describe('TypeScript step paths', () => {
2020
expect(err).toBeTruthy()
2121
expect(stdout).toContain('Scenario Steps:')
2222
expect(stdout).toMatch(/at Object\.open \(\.\/pages\/fooPage\.ts:\d+:\d+\)/)
23+
// #5772: line/column must come from the .ts source, not the transpiled .temp.mjs
24+
// (I.doThing is on line 7 of pages/fooPage.ts)
25+
expect(stdout).toMatch(/at Object\.open \(\.\/pages\/fooPage\.ts:7:7\)/)
2326
expect(stdout).not.toContain('.temp.mjs')
2427
expect(stdout).not.toContain('file://./pages/fooPage.ts')
2528
done()

‎test/unit/utils/typescript_test.js‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { expect } from 'chai'
2-
import { fileURLToPath } from 'url'
2+
import { fileURLToPath, pathToFileURL } from 'url'
33
import path from 'path'
4+
import fs from 'fs'
5+
import os from 'os'
46
import { createRequire } from 'module'
5-
import { transpileTypeScript, cleanupTempFiles } from '../../../lib/utils/typescript.js'
7+
import { transpileTypeScript, mapTranspiledStackFrame, cleanupTempFiles } from '../../../lib/utils/typescript.js'
68

79
const __dirname = path.dirname(fileURLToPath(import.meta.url))
810
const require = createRequire(import.meta.url)
@@ -33,4 +35,44 @@ describe('TypeScript transpilation', () => {
3335
cleanupTempFiles(second.allTempFiles)
3436
}
3537
})
38+
39+
it('maps transpiled stack frames back to the original .ts positions (#5772)', async () => {
40+
const pageObject = path.resolve(__dirname, '../../data/sandbox/typescript-step-paths/pages/fooPage.ts')
41+
const { tempFile, allTempFiles } = await transpileTypeScript(pageObject, typescript)
42+
43+
try {
44+
// I.doThing('from page') is on line 7 of fooPage.ts; the transpiled
45+
// .temp.mjs reports it at 4:11
46+
const frame = `at Object.open (${tempFile}:4:11)`
47+
expect(mapTranspiledStackFrame(frame)).to.equal(`at Object.open (${pageObject}:7:7)`)
48+
} finally {
49+
cleanupTempFiles(allTempFiles)
50+
}
51+
})
52+
53+
it('accounts for injected CommonJS shims when mapping positions', async () => {
54+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codecept-ts-'))
55+
const tsFile = path.join(tmpDir, 'shimmed.ts')
56+
fs.writeFileSync(tsFile, 'const path = require("path");\nexport default {\n open() {\n const p = path.join("a", "b");\n return p;\n },\n};\n')
57+
58+
const { tempFile, allTempFiles } = await transpileTypeScript(tsFile, typescript)
59+
try {
60+
const tempPath = fileURLToPath(tempFile)
61+
const tempLines = fs.readFileSync(tempPath, 'utf8').split('\n')
62+
const tempLine = tempLines.findIndex(l => l.includes('path.join')) + 1
63+
expect(tempLine).to.be.greaterThan(4) // shim injection shifted lines down
64+
65+
const frame = `at Object.open (${tempFile}:${tempLine}:15)`
66+
const mapped = mapTranspiledStackFrame(frame)
67+
expect(mapped).to.match(/\(.*shimmed\.ts:4:\d+\)/)
68+
} finally {
69+
cleanupTempFiles(allTempFiles)
70+
fs.rmSync(tmpDir, { recursive: true, force: true })
71+
}
72+
})
73+
74+
it('returns null for frames that cannot be mapped', () => {
75+
expect(mapTranspiledStackFrame('at Object.open (/some/file.js:1:1)')).to.be.null
76+
expect(mapTranspiledStackFrame('at Object.open (file:///does/not/exist.123.temp.mjs:1:1)')).to.be.null
77+
})
3678
})

0 commit comments

Comments
 (0)