From 555162faef1d3fab3f73c137a7ab3575a6f7e1e2 Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Mon, 10 Aug 2026 12:04:43 +0300 Subject: [PATCH] fix: resolve dynamic import() and bare require/module in TypeScript configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/utils/typescript.js | 161 ++++++++++-------- .../codecept.conf.ts | 12 ++ .../common/labels.ts | 3 + .../lifecycle/teardown.ts | 12 ++ .../package.json | 5 + test/unit/utils/typescript_test.js | 35 ++++ 6 files changed, 153 insertions(+), 75 deletions(-) create mode 100644 test/data/typescript-config-dynamic-import/codecept.conf.ts create mode 100644 test/data/typescript-config-dynamic-import/common/labels.ts create mode 100644 test/data/typescript-config-dynamic-import/lifecycle/teardown.ts create mode 100644 test/data/typescript-config-dynamic-import/package.json diff --git a/lib/utils/typescript.js b/lib/utils/typescript.js index 3ee0fda7b..7fb52608d 100644 --- a/lib/utils/typescript.js +++ b/lib/utils/typescript.js @@ -85,16 +85,27 @@ export async function transpileTypeScript(mainFilePath, typescript) { skipLibCheck: true, }) - // Check if the code uses CommonJS globals + // Check if the code uses CommonJS globals. + // + // A bare `require` or `module` identifier counts, not just `require(...)` and + // `module.exports`: `if (require.main === module)` is the standard entrypoint idiom, + // and without a shim the transpiled file throws "require is not defined in ES module + // scope". Quoted occurrences (`from 'module'`) are specifiers, not references. A file + // that declares its own binding is left alone, so the shim can never redeclare it. + const references = name => new RegExp(`(? new RegExp(`\\b(?:const|let|var|function|class)\\s+${name}\\b|\\bimport\\s+${name}\\b`).test(jsContent) + const needsShim = name => references(name) && !declaresOwn(name) + const usesCommonJSGlobals = /__dirname|__filename/.test(jsContent) - const usesRequire = /\brequire\s*\(/.test(jsContent) + const usesRequire = needsShim('require') + const usesModule = needsShim('module') const usesModuleExports = /\b(module\.exports|exports\.)/.test(jsContent) - if (usesCommonJSGlobals || usesRequire || usesModuleExports) { + if (usesCommonJSGlobals || usesRequire || usesModule || usesModuleExports) { // Inject ESM equivalents at the top of the file let esmGlobals = '' - if (usesRequire || usesModuleExports) { + if (usesRequire) { // IMPORTANT: Use the original .ts file path as the base for require() // This ensures dynamic require() calls work with relative paths from the original file location const originalFileUrl = `file://${filePath.replace(/\\/g, '/')}` @@ -131,7 +142,11 @@ const require = (id) => { } }; -const module = { exports: {} }; +` + } + + if (usesModule || usesModuleExports) { + esmGlobals += `const module = { exports: {} }; const exports = module.exports; ` @@ -189,8 +204,9 @@ const __dirname = __dirname_fn(__filename); // Transpile this file let jsContent = transpileTS(filePath) - // Find all TypeScript imports in this file (both ESM imports and require() calls) + // Find all TypeScript imports in this file (static imports, dynamic import() and require() calls) const importRegex = /from\s+['"]([^'"]+?)['"]/g + const dynamicImportRegex = /\bimport\s*\(\s*['"]([^'"]+?)['"]\s*\)/g const requireRegex = /require\s*\(\s*['"]([^'"]+?)['"]\s*\)/g let match const imports = [] @@ -199,6 +215,10 @@ const __dirname = __dirname_fn(__filename); imports.push({ path: match[1], type: 'import' }) } + while ((match = dynamicImportRegex.exec(jsContent)) !== null) { + imports.push({ path: match[1], type: 'import' }) + } + while ((match = requireRegex.exec(jsContent)) !== null) { imports.push({ path: match[1], type: 'require' }) } @@ -260,85 +280,76 @@ const __dirname = __dirname_fn(__filename); } } - // After all dependencies are transpiled, rewrite imports in this file - jsContent = jsContent.replace( - /from\s+['"]([^'"]+?)['"]/g, - (match, importPath) => { - let resolvedPath = importPath - const originalExt = path.extname(importPath) + // Resolve one ESM specifier to the temp file its source was transpiled into. + // Returns null when the specifier must be left untouched — bare package names, + // or paths that resolve to nothing we emitted. + const resolveEsmSpecifier = 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 - } + // 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 null + } - // 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}'` - } - } + const toRelative = tempFile => { + const relPath = path.relative(fileBaseDir, tempFile).replace(/\\/g, '/') + return relPath.startsWith('.') ? relPath : `./${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, '/') - if (!relPath.startsWith('.')) { - return `from './${relPath}'` - } - return `from '${relPath}'` - } - 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)) { + return toRelative(transpiledFiles.get(indexPath)) } + } - // Try with .ts extension - const tsPath = resolvedPath.endsWith('.ts') ? resolvedPath : resolvedPath + '.ts' + // Handle .js extension that might be .ts + if (resolvedPath.endsWith('.js')) { + const tsVersion = resolvedPath.replace(/\.js$/, '.ts') + return transpiledFiles.has(tsVersion) ? toRelative(transpiledFiles.get(tsVersion)) : null + } - // 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, '/') - if (!relPath.startsWith('.')) { - return `from './${relPath}'` - } - return `from '${relPath}'` - } + // Try with .ts extension + const tsPath = resolvedPath.endsWith('.ts') ? resolvedPath : resolvedPath + '.ts' + if (transpiledFiles.has(tsPath)) { + return toRelative(transpiledFiles.get(tsPath)) + } - // 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}'` - } + // Try index.ts for directory imports + const indexTsPath = path.join(resolvedPath, 'index.ts') + if (transpiledFiles.has(indexTsPath)) { + return toRelative(transpiledFiles.get(indexTsPath)) + } - // 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 the import doesn't have a standard module extension, add .js for ESM compatibility + const standardExtensions = ['.js', '.mjs', '.cjs', '.json', '.node'] + if (!standardExtensions.includes(originalExt.toLowerCase())) { + return `${importPath}.js` + } - if (!hasStandardExtension) { - return match.replace(importPath, importPath + '.js') - } + return null + } - return match - } - ) + // After all dependencies are transpiled, rewrite imports in this file + jsContent = jsContent.replace(/from\s+['"]([^'"]+?)['"]/g, (match, importPath) => { + const resolved = resolveEsmSpecifier(importPath) + return resolved === null ? match : `from '${resolved}'` + }) + + // Dynamic import() resolves exactly like a static import. Without this rewrite, + // `await import('./module')` survives transpilation still pointing at a `.ts` file + // that was never emitted, and fails with ERR_MODULE_NOT_FOUND at runtime. + jsContent = jsContent.replace(/(\bimport\s*\(\s*)['"]([^'"]+?)['"](\s*\))/g, (match, open, importPath, close) => { + const resolved = resolveEsmSpecifier(importPath) + return resolved === null ? match : `${open}'${resolved}'${close}` + }) // Also rewrite require() calls to point to transpiled TypeScript files jsContent = jsContent.replace( diff --git a/test/data/typescript-config-dynamic-import/codecept.conf.ts b/test/data/typescript-config-dynamic-import/codecept.conf.ts new file mode 100644 index 000000000..7958b2669 --- /dev/null +++ b/test/data/typescript-config-dynamic-import/codecept.conf.ts @@ -0,0 +1,12 @@ +export const config = { + tests: './*_test.js', + output: './output', + name: 'typescript-config-dynamic-import', +} + +// Lifecycle hooks commonly pull heavy modules lazily. The specifier is extensionless, +// as TypeScript sources are normally written. +export async function runTeardown(): Promise { + const { teardown } = await import('./lifecycle/teardown') + return teardown() +} diff --git a/test/data/typescript-config-dynamic-import/common/labels.ts b/test/data/typescript-config-dynamic-import/common/labels.ts new file mode 100644 index 000000000..617f3eb83 --- /dev/null +++ b/test/data/typescript-config-dynamic-import/common/labels.ts @@ -0,0 +1,3 @@ +export function getSweepLabel(): string { + return 'swept' +} diff --git a/test/data/typescript-config-dynamic-import/lifecycle/teardown.ts b/test/data/typescript-config-dynamic-import/lifecycle/teardown.ts new file mode 100644 index 000000000..e68de32b0 --- /dev/null +++ b/test/data/typescript-config-dynamic-import/lifecycle/teardown.ts @@ -0,0 +1,12 @@ +import { getSweepLabel } from '../common/labels' + +export function teardown(): string { + return `teardown:${getSweepLabel()}` +} + +// Standard CommonJS entrypoint idiom: this module doubles as a CLI script. It must +// survive transpilation (no "require is not defined in ES module scope") and must not +// run when the module is merely imported. +if (require.main === module) { + console.log(teardown()) +} diff --git a/test/data/typescript-config-dynamic-import/package.json b/test/data/typescript-config-dynamic-import/package.json new file mode 100644 index 000000000..97c6f7868 --- /dev/null +++ b/test/data/typescript-config-dynamic-import/package.json @@ -0,0 +1,5 @@ +{ + "name": "typescript-config-dynamic-import", + "version": "1.0.0", + "type": "module" +} diff --git a/test/unit/utils/typescript_test.js b/test/unit/utils/typescript_test.js index 5f57ca71a..34f9109c4 100644 --- a/test/unit/utils/typescript_test.js +++ b/test/unit/utils/typescript_test.js @@ -9,6 +9,9 @@ const require = createRequire(import.meta.url) const typescript = require('typescript') const configPath = path.resolve(__dirname, '../../data/typescript-config-imports/tests/api/codecept.conf.ts') +const dynamicImportDir = path.resolve(__dirname, '../../data/typescript-config-dynamic-import') +const dynamicImportConfigPath = path.join(dynamicImportDir, 'codecept.conf.ts') +const entrypointModulePath = path.join(dynamicImportDir, 'lifecycle/teardown.ts') describe('TypeScript transpilation', () => { 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', () => { cleanupTempFiles(second.allTempFiles) } }) + + it('transpiles and rewrites modules reached through a dynamic import()', async () => { + const result = await transpileTypeScript(dynamicImportConfigPath, typescript) + + try { + // config + the dynamically imported module + that module's own static import + expect(result.allTempFiles.length).to.equal(3) + + const configModule = await import(result.tempFile) + expect(await configModule.runTeardown()).to.equal('teardown:swept') + } finally { + cleanupTempFiles(result.allTempFiles) + } + }) + + it('shims a bare `require.main === module` entrypoint guard without running it', async () => { + const result = await transpileTypeScript(entrypointModulePath, typescript) + const logged = [] + const originalLog = console.log + console.log = (...args) => logged.push(args.join(' ')) + + try { + // Importing must not throw "require is not defined in ES module scope" ... + const transpiled = await import(result.tempFile) + expect(transpiled.teardown()).to.equal('teardown:swept') + // ... and the guarded block must stay dormant, since the module was imported, not run. + expect(logged, `entrypoint block executed on import: ${logged}`).to.be.empty + } finally { + console.log = originalLog + cleanupTempFiles(result.allTempFiles) + } + }) })