Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lib/step/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()) {
Expand Down
91 changes: 79 additions & 12 deletions lib/utils/typescript.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -65,26 +66,38 @@ 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
*/
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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions test/runner/typescript_step_paths_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
46 changes: 44 additions & 2 deletions test/unit/utils/typescript_test.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
})
})