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
8 changes: 8 additions & 0 deletions lib/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`)
}
Expand Down
20 changes: 20 additions & 0 deletions 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 { mapSourcePosition } from '../utils/typescript.js'

const STACK_LINE = 5

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
92 changes: 88 additions & 4 deletions lib/utils/typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,32 @@ 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,
allowSyntheticDefaultImports: true,
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)
Expand Down Expand Up @@ -149,18 +164,24 @@ 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) {
jsContent += `\nexport default module.exports;\n`
}
}

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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion test/data/sandbox/typescript-step-paths/pages/fooPage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export {}

const { I } = inject()

export default {
Expand Down
3 changes: 2 additions & 1 deletion test/runner/typescript_step_paths_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<anonymous> (./tests/fooTest.ts:5:5)')
expect(stdout).not.toContain('.temp.mjs')
expect(stdout).not.toContain('file://./pages/fooPage.ts')
done()
Expand Down
Loading