Skip to content

Commit 555162f

Browse files
committed
fix: resolve dynamic import() and bare require/module in TypeScript configs
A `.ts` config is transpiled to a temp `.mjs` and its import tree is transpiled with it, but two things were missed, and both surface only at runtime. Dynamic `import('./module')` was invisible to the transpiler: the dependency scan and the rewrite pass matched `from '...'` and `require('...')` only, so a lazily imported module was never emitted and the specifier still pointed at a `.ts` path — ERR_MODULE_NOT_FOUND. Static and dynamic specifiers now share one resolver, so both follow the same ESM resolution. The CommonJS shim was gated on `require(` and `module.exports`, so the standard `if (require.main === module)` entrypoint idiom got no shim and the transpiled file threw "require is not defined in ES module scope". Detection now counts bare `require` / `module` identifiers, ignores quoted occurrences such as `from 'module'`, and skips files that declare their own binding — which also stops the shim redeclaring a user's own `const require = createRequire(...)`. Unit suite: 769 -> 771 passing, 0 failing.
1 parent b4865fb commit 555162f

6 files changed

Lines changed: 153 additions & 75 deletions

File tree

lib/utils/typescript.js

Lines changed: 86 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,27 @@ export async function transpileTypeScript(mainFilePath, typescript) {
8585
skipLibCheck: true,
8686
})
8787

88-
// Check if the code uses CommonJS globals
88+
// Check if the code uses CommonJS globals.
89+
//
90+
// A bare `require` or `module` identifier counts, not just `require(...)` and
91+
// `module.exports`: `if (require.main === module)` is the standard entrypoint idiom,
92+
// and without a shim the transpiled file throws "require is not defined in ES module
93+
// scope". Quoted occurrences (`from 'module'`) are specifiers, not references. A file
94+
// that declares its own binding is left alone, so the shim can never redeclare it.
95+
const references = name => new RegExp(`(?<!['"\`])\\b${name}\\b(?!['"\`])`).test(jsContent)
96+
const declaresOwn = name => new RegExp(`\\b(?:const|let|var|function|class)\\s+${name}\\b|\\bimport\\s+${name}\\b`).test(jsContent)
97+
const needsShim = name => references(name) && !declaresOwn(name)
98+
8999
const usesCommonJSGlobals = /__dirname|__filename/.test(jsContent)
90-
const usesRequire = /\brequire\s*\(/.test(jsContent)
100+
const usesRequire = needsShim('require')
101+
const usesModule = needsShim('module')
91102
const usesModuleExports = /\b(module\.exports|exports\.)/.test(jsContent)
92103

93-
if (usesCommonJSGlobals || usesRequire || usesModuleExports) {
104+
if (usesCommonJSGlobals || usesRequire || usesModule || usesModuleExports) {
94105
// Inject ESM equivalents at the top of the file
95106
let esmGlobals = ''
96107

97-
if (usesRequire || usesModuleExports) {
108+
if (usesRequire) {
98109
// IMPORTANT: Use the original .ts file path as the base for require()
99110
// This ensures dynamic require() calls work with relative paths from the original file location
100111
const originalFileUrl = `file://${filePath.replace(/\\/g, '/')}`
@@ -131,7 +142,11 @@ const require = (id) => {
131142
}
132143
};
133144
134-
const module = { exports: {} };
145+
`
146+
}
147+
148+
if (usesModule || usesModuleExports) {
149+
esmGlobals += `const module = { exports: {} };
135150
const exports = module.exports;
136151
137152
`
@@ -189,8 +204,9 @@ const __dirname = __dirname_fn(__filename);
189204
// Transpile this file
190205
let jsContent = transpileTS(filePath)
191206

192-
// Find all TypeScript imports in this file (both ESM imports and require() calls)
207+
// Find all TypeScript imports in this file (static imports, dynamic import() and require() calls)
193208
const importRegex = /from\s+['"]([^'"]+?)['"]/g
209+
const dynamicImportRegex = /\bimport\s*\(\s*['"]([^'"]+?)['"]\s*\)/g
194210
const requireRegex = /require\s*\(\s*['"]([^'"]+?)['"]\s*\)/g
195211
let match
196212
const imports = []
@@ -199,6 +215,10 @@ const __dirname = __dirname_fn(__filename);
199215
imports.push({ path: match[1], type: 'import' })
200216
}
201217

218+
while ((match = dynamicImportRegex.exec(jsContent)) !== null) {
219+
imports.push({ path: match[1], type: 'import' })
220+
}
221+
202222
while ((match = requireRegex.exec(jsContent)) !== null) {
203223
imports.push({ path: match[1], type: 'require' })
204224
}
@@ -260,85 +280,76 @@ const __dirname = __dirname_fn(__filename);
260280
}
261281
}
262282

263-
// After all dependencies are transpiled, rewrite imports in this file
264-
jsContent = jsContent.replace(
265-
/from\s+['"]([^'"]+?)['"]/g,
266-
(match, importPath) => {
267-
let resolvedPath = importPath
268-
const originalExt = path.extname(importPath)
283+
// Resolve one ESM specifier to the temp file its source was transpiled into.
284+
// Returns null when the specifier must be left untouched — bare package names,
285+
// or paths that resolve to nothing we emitted.
286+
const resolveEsmSpecifier = importPath => {
287+
let resolvedPath = importPath
288+
const originalExt = path.extname(importPath)
269289

270-
// Check if this is a path alias
271-
const resolvedAlias = resolveTsPathAlias(importPath, tsConfig, configDir)
272-
if (resolvedAlias) {
273-
resolvedPath = resolvedAlias
274-
} else if (importPath.startsWith('.')) {
275-
resolvedPath = path.resolve(fileBaseDir, importPath)
276-
} else {
277-
return match
278-
}
290+
// Check if this is a path alias
291+
const resolvedAlias = resolveTsPathAlias(importPath, tsConfig, configDir)
292+
if (resolvedAlias) {
293+
resolvedPath = resolvedAlias
294+
} else if (importPath.startsWith('.')) {
295+
resolvedPath = path.resolve(fileBaseDir, importPath)
296+
} else {
297+
return null
298+
}
279299

280-
// If resolved path is a directory, try index.ts
281-
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
282-
const indexPath = path.join(resolvedPath, 'index.ts')
283-
if (fs.existsSync(indexPath) && transpiledFiles.has(indexPath)) {
284-
const tempFile = transpiledFiles.get(indexPath)
285-
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
286-
if (!relPath.startsWith('.')) {
287-
return `from './${relPath}'`
288-
}
289-
return `from '${relPath}'`
290-
}
291-
}
300+
const toRelative = tempFile => {
301+
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
302+
return relPath.startsWith('.') ? relPath : `./${relPath}`
303+
}
292304

293-
// Handle .js extension that might be .ts
294-
if (resolvedPath.endsWith('.js')) {
295-
const tsVersion = resolvedPath.replace(/\.js$/, '.ts')
296-
if (transpiledFiles.has(tsVersion)) {
297-
const tempFile = transpiledFiles.get(tsVersion)
298-
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
299-
if (!relPath.startsWith('.')) {
300-
return `from './${relPath}'`
301-
}
302-
return `from '${relPath}'`
303-
}
304-
return match
305+
// If resolved path is a directory, try index.ts
306+
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
307+
const indexPath = path.join(resolvedPath, 'index.ts')
308+
if (fs.existsSync(indexPath) && transpiledFiles.has(indexPath)) {
309+
return toRelative(transpiledFiles.get(indexPath))
305310
}
311+
}
306312

307-
// Try with .ts extension
308-
const tsPath = resolvedPath.endsWith('.ts') ? resolvedPath : resolvedPath + '.ts'
313+
// Handle .js extension that might be .ts
314+
if (resolvedPath.endsWith('.js')) {
315+
const tsVersion = resolvedPath.replace(/\.js$/, '.ts')
316+
return transpiledFiles.has(tsVersion) ? toRelative(transpiledFiles.get(tsVersion)) : null
317+
}
309318

310-
// If we transpiled this file, use the temp file
311-
if (transpiledFiles.has(tsPath)) {
312-
const tempFile = transpiledFiles.get(tsPath)
313-
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
314-
if (!relPath.startsWith('.')) {
315-
return `from './${relPath}'`
316-
}
317-
return `from '${relPath}'`
318-
}
319+
// Try with .ts extension
320+
const tsPath = resolvedPath.endsWith('.ts') ? resolvedPath : resolvedPath + '.ts'
321+
if (transpiledFiles.has(tsPath)) {
322+
return toRelative(transpiledFiles.get(tsPath))
323+
}
319324

320-
// Try index.ts for directory imports
321-
const indexTsPath = path.join(resolvedPath, 'index.ts')
322-
if (transpiledFiles.has(indexTsPath)) {
323-
const tempFile = transpiledFiles.get(indexTsPath)
324-
const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/')
325-
if (!relPath.startsWith('.')) {
326-
return `from './${relPath}'`
327-
}
328-
return `from '${relPath}'`
329-
}
325+
// Try index.ts for directory imports
326+
const indexTsPath = path.join(resolvedPath, 'index.ts')
327+
if (transpiledFiles.has(indexTsPath)) {
328+
return toRelative(transpiledFiles.get(indexTsPath))
329+
}
330330

331-
// If the import doesn't have a standard module extension, add .js for ESM compatibility
332-
const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node']
333-
const hasStandardExtension = standardExtensions.includes(originalExt.toLowerCase())
331+
// If the import doesn't have a standard module extension, add .js for ESM compatibility
332+
const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node']
333+
if (!standardExtensions.includes(originalExt.toLowerCase())) {
334+
return `${importPath}.js`
335+
}
334336

335-
if (!hasStandardExtension) {
336-
return match.replace(importPath, importPath + '.js')
337-
}
337+
return null
338+
}
338339

339-
return match
340-
}
341-
)
340+
// After all dependencies are transpiled, rewrite imports in this file
341+
jsContent = jsContent.replace(/from\s+['"]([^'"]+?)['"]/g, (match, importPath) => {
342+
const resolved = resolveEsmSpecifier(importPath)
343+
return resolved === null ? match : `from '${resolved}'`
344+
})
345+
346+
// Dynamic import() resolves exactly like a static import. Without this rewrite,
347+
// `await import('./module')` survives transpilation still pointing at a `.ts` file
348+
// that was never emitted, and fails with ERR_MODULE_NOT_FOUND at runtime.
349+
jsContent = jsContent.replace(/(\bimport\s*\(\s*)['"]([^'"]+?)['"](\s*\))/g, (match, open, importPath, close) => {
350+
const resolved = resolveEsmSpecifier(importPath)
351+
return resolved === null ? match : `${open}'${resolved}'${close}`
352+
})
342353

343354
// Also rewrite require() calls to point to transpiled TypeScript files
344355
jsContent = jsContent.replace(
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const config = {
2+
tests: './*_test.js',
3+
output: './output',
4+
name: 'typescript-config-dynamic-import',
5+
}
6+
7+
// Lifecycle hooks commonly pull heavy modules lazily. The specifier is extensionless,
8+
// as TypeScript sources are normally written.
9+
export async function runTeardown(): Promise<string> {
10+
const { teardown } = await import('./lifecycle/teardown')
11+
return teardown()
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function getSweepLabel(): string {
2+
return 'swept'
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getSweepLabel } from '../common/labels'
2+
3+
export function teardown(): string {
4+
return `teardown:${getSweepLabel()}`
5+
}
6+
7+
// Standard CommonJS entrypoint idiom: this module doubles as a CLI script. It must
8+
// survive transpilation (no "require is not defined in ES module scope") and must not
9+
// run when the module is merely imported.
10+
if (require.main === module) {
11+
console.log(teardown())
12+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "typescript-config-dynamic-import",
3+
"version": "1.0.0",
4+
"type": "module"
5+
}

test/unit/utils/typescript_test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ const require = createRequire(import.meta.url)
99
const typescript = require('typescript')
1010

1111
const configPath = path.resolve(__dirname, '../../data/typescript-config-imports/tests/api/codecept.conf.ts')
12+
const dynamicImportDir = path.resolve(__dirname, '../../data/typescript-config-dynamic-import')
13+
const dynamicImportConfigPath = path.join(dynamicImportDir, 'codecept.conf.ts')
14+
const entrypointModulePath = path.join(dynamicImportDir, 'lifecycle/teardown.ts')
1215

1316
describe('TypeScript transpilation', () => {
1417
it('uses unique temp file names per invocation so concurrent run-multiple workers do not delete each other (#5642)', async () => {
@@ -33,4 +36,36 @@ describe('TypeScript transpilation', () => {
3336
cleanupTempFiles(second.allTempFiles)
3437
}
3538
})
39+
40+
it('transpiles and rewrites modules reached through a dynamic import()', async () => {
41+
const result = await transpileTypeScript(dynamicImportConfigPath, typescript)
42+
43+
try {
44+
// config + the dynamically imported module + that module's own static import
45+
expect(result.allTempFiles.length).to.equal(3)
46+
47+
const configModule = await import(result.tempFile)
48+
expect(await configModule.runTeardown()).to.equal('teardown:swept')
49+
} finally {
50+
cleanupTempFiles(result.allTempFiles)
51+
}
52+
})
53+
54+
it('shims a bare `require.main === module` entrypoint guard without running it', async () => {
55+
const result = await transpileTypeScript(entrypointModulePath, typescript)
56+
const logged = []
57+
const originalLog = console.log
58+
console.log = (...args) => logged.push(args.join(' '))
59+
60+
try {
61+
// Importing must not throw "require is not defined in ES module scope" ...
62+
const transpiled = await import(result.tempFile)
63+
expect(transpiled.teardown()).to.equal('teardown:swept')
64+
// ... and the guarded block must stay dormant, since the module was imported, not run.
65+
expect(logged, `entrypoint block executed on import: ${logged}`).to.be.empty
66+
} finally {
67+
console.log = originalLog
68+
cleanupTempFiles(result.allTempFiles)
69+
}
70+
})
3671
})

0 commit comments

Comments
 (0)