Skip to content
Closed
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
35 changes: 20 additions & 15 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,27 +156,32 @@ async function loadConfigFile(configFile) {
try {
// For .ts files, try to compile and load as JavaScript
if (extensionName === '.ts') {
let transpileError = null
let tempFile = null
let allTempFiles = null
let fileMapping = null

try {
// Use the TypeScript transpilation utility
const typescript = require('typescript')
const { tempFile, allTempFiles, fileMapping } = await transpileTypeScript(configFile, typescript)

try {
configModule = await import(tempFile)
cleanupTempFiles(allTempFiles)
} catch (err) {
const result = await transpileTypeScript(configFile, typescript)
tempFile = result.tempFile
allTempFiles = result.allTempFiles
fileMapping = result.fileMapping

configModule = await import(tempFile)
cleanupTempFiles(allTempFiles)
} catch (err) {
transpileError = err
if (fileMapping) {
fixErrorStack(err, fileMapping)
cleanupTempFiles(allTempFiles)
throw err
}
} catch (tsError) {
// If TypeScript compilation fails, fallback to ts-node
try {
require('ts-node/register')
configModule = require(configFile)
} catch (tsNodeError) {
throw new Error(`Failed to load TypeScript config: ${tsError.message}`)
if (allTempFiles) {
cleanupTempFiles(allTempFiles)
}
// Throw immediately with the actual error - don't fall back to ts-node
// as it will mask the real error with "Unexpected token 'export'"
throw err
}
} else {
// Try ESM import first for JS files
Expand Down
211 changes: 188 additions & 23 deletions lib/utils/typescript.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
import fs from 'fs'
import path from 'path'
import { pathToFileURL } from 'url'

/**
* Load tsconfig.json if it exists
* @param {string} tsConfigPath - Path to tsconfig.json
* @returns {object|null} - Parsed tsconfig or null
*/
function loadTsConfig(tsConfigPath) {
if (!fs.existsSync(tsConfigPath)) {
return null
}

try {
const tsConfigContent = fs.readFileSync(tsConfigPath, 'utf8')
return JSON.parse(tsConfigContent)
} catch (err) {
return null
}
}

/**
* Resolve TypeScript path alias to actual file path
* @param {string} importPath - Import path with alias (e.g., '#config/urls')
* @param {object} tsConfig - Parsed tsconfig.json
* @param {string} configDir - Directory containing tsconfig.json
* @returns {string|null} - Resolved file path or null if not an alias
*/
function resolveTsPathAlias(importPath, tsConfig, configDir) {
if (!tsConfig || !tsConfig.compilerOptions || !tsConfig.compilerOptions.paths) {
return null
}

const paths = tsConfig.compilerOptions.paths

for (const [pattern, targets] of Object.entries(paths)) {
if (!targets || targets.length === 0) {
continue
}

const patternRegex = new RegExp(
'^' + pattern.replace(/\*/g, '(.*)') + '$'
)
const match = importPath.match(patternRegex)

if (match) {
const wildcard = match[1] || ''
const target = targets[0]
const resolvedTarget = target.replace(/\*/g, wildcard)

return path.resolve(configDir, resolvedTarget)
}
}

return null
}

/**
* Transpile TypeScript files to ES modules with CommonJS shim support
Expand Down Expand Up @@ -108,6 +163,22 @@ const __dirname = __dirname_fn(__filename);
const transpiledFiles = new Map()
const baseDir = path.dirname(mainFilePath)

// Try to find tsconfig.json by walking up the directory tree
let tsConfigPath = path.join(baseDir, 'tsconfig.json')
let configDir = baseDir
let searchDir = baseDir

while (!fs.existsSync(tsConfigPath) && searchDir !== path.dirname(searchDir)) {
searchDir = path.dirname(searchDir)
tsConfigPath = path.join(searchDir, 'tsconfig.json')
if (fs.existsSync(tsConfigPath)) {
configDir = searchDir
break
}
}

const tsConfig = loadTsConfig(tsConfigPath)

// Recursive function to transpile a file and all its TypeScript dependencies
const transpileFileAndDeps = (filePath) => {
// Already transpiled, skip
Expand All @@ -118,21 +189,36 @@ const __dirname = __dirname_fn(__filename);
// Transpile this file
let jsContent = transpileTS(filePath)

// Find all relative TypeScript imports in this file
const importRegex = /from\s+['"](\.[^'"]+?)(?:\.ts)?['"]/g
// Find all TypeScript imports in this file (both ESM imports and require() calls)
const importRegex = /from\s+['"]([^'"]+?)['"]/g
const requireRegex = /require\s*\(\s*['"]([^'"]+?)['"]\s*\)/g
let match
const imports = []

while ((match = importRegex.exec(jsContent)) !== null) {
imports.push(match[1])
imports.push({ path: match[1], type: 'import' })
}

while ((match = requireRegex.exec(jsContent)) !== null) {
imports.push({ path: match[1], type: 'require' })
}

// Get the base directory for this file
const fileBaseDir = path.dirname(filePath)

// Recursively transpile each imported TypeScript file
for (const relativeImport of imports) {
let importedPath = path.resolve(fileBaseDir, relativeImport)
for (const { path: importPath } of imports) {
let importedPath = importPath

// Check if this is a path alias
const resolvedAlias = resolveTsPathAlias(importPath, tsConfig, configDir)
if (resolvedAlias) {
importedPath = resolvedAlias
} else if (importPath.startsWith('.')) {
importedPath = path.resolve(fileBaseDir, importPath)
} else {
continue
}

// Handle .js extensions that might actually be .ts files
if (importedPath.endsWith('.js')) {
Expand All @@ -153,11 +239,17 @@ const __dirname = __dirname_fn(__filename);
if (fs.existsSync(tsPath)) {
importedPath = tsPath
} else {
// Try .js extension as well
const jsPath = importedPath + '.js'
if (fs.existsSync(jsPath)) {
// Skip .js files, they don't need transpilation
continue
// Try index.ts for directory imports
const indexTsPath = path.join(importedPath, 'index.ts')
if (fs.existsSync(indexTsPath)) {
importedPath = indexTsPath
} else {
// Try .js extension as well
const jsPath = importedPath + '.js'
if (fs.existsSync(jsPath)) {
// Skip .js files, they don't need transpilation
continue
}
}
}
}
Expand All @@ -170,24 +262,45 @@ const __dirname = __dirname_fn(__filename);

// After all dependencies are transpiled, rewrite imports in this file
jsContent = jsContent.replace(
/from\s+['"](\.[^'"]+?)(?:\.ts)?['"]/g,
/from\s+['"]([^'"]+?)['"]/g,
(match, importPath) => {
let resolvedPath = path.resolve(fileBaseDir, importPath)
let resolvedPath = importPath
const originalExt = path.extname(importPath)

// Check if this is a path alias
const resolvedAlias = resolveTsPathAlias(importPath, tsConfig, configDir)
if (resolvedAlias) {
resolvedPath = resolvedAlias
} else if (importPath.startsWith('.')) {
resolvedPath = path.resolve(fileBaseDir, importPath)
} else {
return match
}

// If resolved path is a directory, try index.ts
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.ts')
if (fs.existsSync(indexPath) && transpiledFiles.has(indexPath)) {
const tempFile = transpiledFiles.get(indexPath)
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
if (!relPath.startsWith('.')) {
return `from './${relPath}'`
}
return `from '${relPath}'`
}
}

// Handle .js extension that might be .ts
if (resolvedPath.endsWith('.js')) {
const tsVersion = resolvedPath.replace(/\.js$/, '.ts')
if (transpiledFiles.has(tsVersion)) {
const tempFile = transpiledFiles.get(tsVersion)
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
// Ensure the path starts with ./
if (!relPath.startsWith('.')) {
return `from './${relPath}'`
}
return `from '${relPath}'`
}
// Keep .js extension as-is (might be a real .js file)
return match
}

Expand All @@ -198,26 +311,75 @@ const __dirname = __dirname_fn(__filename);
if (transpiledFiles.has(tsPath)) {
const tempFile = transpiledFiles.get(tsPath)
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
// Ensure the path starts with ./
if (!relPath.startsWith('.')) {
return `from './${relPath}'`
}
return `from '${relPath}'`
}

// If the import doesn't have a standard module extension (.js, .mjs, .cjs, .json)
// add .js for ESM compatibility
// This handles cases where:
// 1. Import has no real extension (e.g., "./utils" or "./helper")
// 2. Import has a non-standard extension that's part of the name (e.g., "./abstract.helper")
// Try index.ts for directory imports
const indexTsPath = path.join(resolvedPath, 'index.ts')
if (transpiledFiles.has(indexTsPath)) {
const tempFile = transpiledFiles.get(indexTsPath)
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
if (!relPath.startsWith('.')) {
return `from './${relPath}'`
}
return `from '${relPath}'`
}

// If the import doesn't have a standard module extension, add .js for ESM compatibility
const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node']
const hasStandardExtension = standardExtensions.includes(originalExt.toLowerCase())

if (!hasStandardExtension) {
return match.replace(importPath, importPath + '.js')
}

// Otherwise, keep the import as-is
return match
}
)

// Also rewrite require() calls to point to transpiled TypeScript files
jsContent = jsContent.replace(
/require\s*\(\s*['"]([^'"]+?)['"]\s*\)/g,
(match, requirePath) => {
let resolvedPath = requirePath

// Check if this is a path alias
const resolvedAlias = resolveTsPathAlias(requirePath, tsConfig, configDir)
if (resolvedAlias) {
resolvedPath = resolvedAlias
} else if (requirePath.startsWith('.')) {
resolvedPath = path.resolve(fileBaseDir, requirePath)
} else {
return match
}

// Handle .js extension that might be .ts
if (resolvedPath.endsWith('.js')) {
const tsVersion = resolvedPath.replace(/\.js$/, '.ts')
if (transpiledFiles.has(tsVersion)) {
const tempFile = transpiledFiles.get(tsVersion)
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
const finalPath = relPath.startsWith('.') ? relPath : './' + relPath
return `require('${finalPath}')`
}
return match
}

// Try with .ts extension
const tsPath = resolvedPath.endsWith('.ts') ? resolvedPath : resolvedPath + '.ts'

// If we transpiled this file, use the temp file
if (transpiledFiles.has(tsPath)) {
const tempFile = transpiledFiles.get(tsPath)
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
const finalPath = relPath.startsWith('.') ? relPath : './' + relPath
return `require('${finalPath}')`
}

// Otherwise, keep the require as-is
return match
}
)
Expand All @@ -234,10 +396,13 @@ const __dirname = __dirname_fn(__filename);
// Get the main transpiled file
const tempJsFile = transpiledFiles.get(mainFilePath)

// Store all temp files for cleanup
// Convert to file:// URL for dynamic import() (required on Windows)
const tempFileUrl = pathToFileURL(tempJsFile).href

// Store all temp files for cleanup (keep as paths, not URLs)
const allTempFiles = Array.from(transpiledFiles.values())

return { tempFile: tempJsFile, allTempFiles, fileMapping: transpiledFiles }
return { tempFile: tempFileUrl, allTempFiles, fileMapping: transpiledFiles }
}

/**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codeceptjs",
"version": "4.0.2-beta.17",
"version": "4.0.2-beta.19",
"type": "module",
"description": "Supercharged End 2 End Testing Framework for NodeJS",
"keywords": [
Expand Down