[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(vm): support `require(esm)` in vm pools

Vladimir Sheremet (Jul 25, 2026, 12:25 PM +0200) b038533b a31f86af

+812 -72
+2
docs/config/pool.md
··· 25 25 26 26 This makes tests run faster, but the VM module is unstable when running [ESM code](https://github.com/nodejs/node/issues/37648). Your tests will [leak memory](https://github.com/nodejs/node/issues/33439) - to battle that, consider manually editing [`vmMemoryLimit`](/config/vmmemorylimit) value. 27 27 28 + On Node.js 24.9 and later, `require()` of an ES module is supported inside vm pools, mirroring [Node's own `require(esm)`](https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require). Calling `require()` on an ES module whose graph contains top-level `await` throws `ERR_REQUIRE_ASYNC_MODULE` - use `await import()` for those files. 29 + 28 30 ::: warning 29 31 Running code in a sandbox has some advantages (faster tests), but also comes with a number of disadvantages. 30 32
+177 -5
test/e2e/test/vm-threads.test.ts
··· 104 104 // was unflagged. The fix uses the _resolveFilename conditions option which 105 105 // is only available on Node 22.12+. Node 20 is unfixable and reaches EOL 106 106 // April 2026. 107 - const nodeMajor = Number(process.versions.node.split('.')[0]) 107 + // On Node 24.9+ the vm pools support require(esm), so the condition resolves 108 + // to the ESM entry there — matching Node's own require() behaviour. 109 + const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number) 110 + const supportsRequireEsm = nodeMajor > 24 || (nodeMajor === 24 && nodeMinor >= 9) 111 + const moduleSyncEntry = supportsRequireEsm ? 'esm' : 'cjs' 108 112 test.skipIf(nodeMajor < 22)('can require package with module-sync exports condition', async () => { 109 113 const { stderr, exitCode } = await runInlineTests({ 110 114 // .mjs module-sync entry ··· 138 142 139 143 const require = createRequire(import.meta.url) 140 144 141 - test('require loads cjs entry for module-sync package (.mjs)', () => { 145 + test('require loads the right entry for module-sync package (.mjs)', () => { 142 146 const mod = require('module-sync-mjs') 143 - expect(mod.value).toBe('cjs') 147 + expect(mod.value).toBe('${moduleSyncEntry}') 144 148 }) 145 149 146 - test('require loads cjs entry for module-sync package (.js with type: module)', () => { 150 + test('require loads the right entry for module-sync package (.js with type: module)', () => { 147 151 const mod = require('module-sync-js') 148 - expect(mod.value).toBe('cjs') 152 + expect(mod.value).toBe('${moduleSyncEntry}') 149 153 }) 150 154 `, 151 155 }, { ··· 155 159 expect(stderr).toBe('') 156 160 expect(exitCode).toBe(0) 157 161 }) 162 + 163 + // https://nodejs.org/api/esm.html#commonjs-namespaces 164 + test.for(['vmThreads', 'vmForks'] as const)( 165 + '%s provides the "module.exports" export on CommonJS namespaces', 166 + async (pool) => { 167 + const { stderr, exitCode } = await runVitest({ 168 + root: './fixtures/vm-cjs-namespace', 169 + pool, 170 + }) 171 + 172 + expect(stderr).toBe('') 173 + expect(exitCode).toBe(0) 174 + }, 175 + ) 176 + 177 + // require(esm) needs the synchronous module graph APIs added in Node 24.9 178 + // (SourceTextModule#hasAsyncGraph/linkRequests/instantiate) 179 + test.skipIf(!supportsRequireEsm).for(['vmThreads', 'vmForks'] as const)( 180 + '%s supports require() of ES modules on Node 24.9+', 181 + async (pool) => { 182 + const { stderr, exitCode } = await runInlineTests({ 183 + 'package.json': '{}', 184 + 'esm-scope/package.json': JSON.stringify({ type: 'module' }), 185 + 'esm-scope/dep.mjs': 'export const dep = "dep"', 186 + 'esm-scope/dep.cjs': 'module.exports = { fromCjs: "cjs" }', 187 + 'esm-scope/entry.mjs': [ 188 + 'import { sep } from "node:path"', 189 + 'import { dep } from "./dep.mjs"', 190 + 'import cjs from "./dep.cjs"', 191 + 'export const value = ["entry", dep, cjs.fromCjs].join(":")', 192 + 'export const hasBuiltin = typeof sep === "string"', 193 + 'export default "default-export"', 194 + ].join('\n'), 195 + 'esm-scope/scoped.js': 'export const value = "js-in-esm-scope"', 196 + 'esm-scope/tla.mjs': 'export const value = await Promise.resolve("tla")', 197 + 'esm-scope/tla-dep.mjs': [ 198 + 'import { value } from "./tla.mjs"', 199 + 'export const wrapped = value', 200 + ].join('\n'), 201 + 'esm-scope/module-exports.mjs': [ 202 + 'const answer = 42', 203 + 'export { answer as "module.exports" }', 204 + ].join('\n'), 205 + 'esm-scope/cjs-wrapper.mjs': 'export * from "./dep.cjs"', 206 + 'esm-scope/cycle-a.mjs': [ 207 + 'import { b } from "./cycle-b.mjs"', 208 + 'export const a = "a"', 209 + 'export const seenB = b', 210 + ].join('\n'), 211 + 'esm-scope/cycle-b.mjs': [ 212 + 'import { a } from "./cycle-a.mjs"', 213 + 'export const b = "b"', 214 + 'export function readA() { return a }', 215 + ].join('\n'), 216 + 'esm-scope/data.json': '{"answer": 42}', 217 + 'esm-scope/imports-json.mjs': [ 218 + 'import data from "./data.json" with { type: "json" }', 219 + 'export const answer = data.answer', 220 + ].join('\n'), 221 + 'cjs-scope/package.json': '{}', 222 + 'cjs-scope/esm-syntax.js': 'export const value = "detected"', 223 + 'node_modules/esm-pkg/package.json': JSON.stringify({ 224 + name: 'esm-pkg', 225 + exports: './index.mjs', 226 + }), 227 + 'node_modules/esm-pkg/index.mjs': 'export const state = { name: "esm-pkg" }', 228 + 'node_modules/esm-pkg-2/package.json': JSON.stringify({ 229 + name: 'esm-pkg-2', 230 + exports: './index.mjs', 231 + }), 232 + 'node_modules/esm-pkg-2/index.mjs': 'export const state = { name: "esm-pkg-2" }', 233 + 'require-esm.test.js': ` 234 + import { createRequire } from 'node:module' 235 + import { expect, test } from 'vitest' 236 + 237 + const require = createRequire(import.meta.url) 238 + 239 + test('loads an ES module graph with esm, cjs and builtin dependencies', () => { 240 + const ns = require('./esm-scope/entry.mjs') 241 + expect(ns.value).toBe('entry:dep:cjs') 242 + expect(ns.hasBuiltin).toBe(true) 243 + expect(ns.default).toBe('default-export') 244 + }) 245 + 246 + test('require() of the same module is cached', () => { 247 + expect(require('./esm-scope/entry.mjs')).toBe(require('./esm-scope/entry.mjs')) 248 + }) 249 + 250 + test('loads a .js file from a "type": "module" scope', () => { 251 + expect(require('./esm-scope/scoped.js').value).toBe('js-in-esm-scope') 252 + }) 253 + 254 + test('supports circular imports', () => { 255 + const ns = require('./esm-scope/cycle-a.mjs') 256 + expect(ns.a).toBe('a') 257 + expect(ns.seenB).toBe('b') 258 + }) 259 + 260 + test('an export named "module.exports" defines the require() result', () => { 261 + expect(require('./esm-scope/module-exports.mjs')).toBe(42) 262 + }) 263 + 264 + test('export * from a cjs file carries "module.exports" through require()', () => { 265 + // the cjs namespace exposes its raw exports as a "module.exports" 266 + // named export, which export * re-exports (unlike default) - so 267 + // requiring the ESM wrapper returns the raw cjs exports object 268 + expect(require('./esm-scope/cjs-wrapper.mjs')).toBe(require('./esm-scope/dep.cjs')) 269 + }) 270 + 271 + test('require() of json keeps returning the plain object', () => { 272 + expect(require('./esm-scope/data.json')).toEqual({ answer: 42 }) 273 + }) 274 + 275 + test('static json imports work inside a required ES module', () => { 276 + expect(require('./esm-scope/imports-json.mjs').answer).toBe(42) 277 + }) 278 + 279 + test('top-level await throws ERR_REQUIRE_ASYNC_MODULE', () => { 280 + let error 281 + try { 282 + require('./esm-scope/tla.mjs') 283 + } 284 + catch (caught) { 285 + error = caught 286 + } 287 + expect(error).toBeDefined() 288 + expect(error.code).toBe('ERR_REQUIRE_ASYNC_MODULE') 289 + expect(error.message).toContain('top-level await') 290 + }) 291 + 292 + test('top-level await in a dependency throws ERR_REQUIRE_ASYNC_MODULE', () => { 293 + let error 294 + try { 295 + require('./esm-scope/tla-dep.mjs') 296 + } 297 + catch (caught) { 298 + error = caught 299 + } 300 + expect(error).toBeDefined() 301 + expect(error.code).toBe('ERR_REQUIRE_ASYNC_MODULE') 302 + }) 303 + 304 + test('falls back to ESM for a .js file with ESM syntax in a CJS scope', () => { 305 + const ns = require('./cjs-scope/esm-syntax.js') 306 + expect(ns.value).toBe('detected') 307 + expect(require('./cjs-scope/esm-syntax.js')).toBe(ns) 308 + }) 309 + 310 + test('import() after require() reuses the same module', async () => { 311 + const required = require('esm-pkg') 312 + const imported = await import('esm-pkg') 313 + expect(imported.state).toBe(required.state) 314 + }) 315 + 316 + test('require() after import() reuses the same module', async () => { 317 + const imported = await import('esm-pkg-2') 318 + const required = require('esm-pkg-2') 319 + expect(required.state).toBe(imported.state) 320 + }) 321 + `, 322 + }, { 323 + pool, 324 + }) 325 + 326 + expect(stderr).toBe('') 327 + expect(exitCode).toBe(0) 328 + }, 329 + )
+3 -1
test/unit/test/interop.test.ts
··· 13 13 "test": "hello", 14 14 } 15 15 `) 16 - if (task.file.projectName === 'vmThreads' || nodeMajor < 23) { 16 + // vm pools always provide the 'module.exports' export on CJS namespaces; 17 + // native Node.js added it in v23.0.0 18 + if (task.file.projectName !== 'vmThreads' && nodeMajor < 23) { 17 19 expect(esModuleFalse).toMatchInlineSnapshot(` 18 20 { 19 21 "__esModule": false,
+14
test/unit/test/parse-cjs-conditions.test.ts
··· 65 65 expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) 66 66 }) 67 67 68 + it('includes module-sync when require(esm) is supported', () => { 69 + const result = parseCjsConditions([], undefined, true) 70 + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'module-sync'])) 71 + }) 72 + 73 + it('keeps user-specified module-sync when require(esm) is supported', () => { 74 + const result = parseCjsConditions( 75 + ['--conditions=module-sync', '--conditions=custom'], 76 + undefined, 77 + true, 78 + ) 79 + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'module-sync', 'custom'])) 80 + }) 81 + 68 82 it('ignores unrelated execArgv entries', () => { 69 83 const result = parseCjsConditions( 70 84 ['--experimental-vm-modules', '-e', 'console.log("hi")'],
+142 -6
packages/vitest/src/runtime/external-executor.ts
··· 11 11 import { extname, normalize } from 'pathe' 12 12 import { CommonjsExecutor } from './vm/commonjs-executor' 13 13 import { EsmExecutor } from './vm/esm-executor' 14 + import { 15 + createRequireAsyncModuleError, 16 + hasEsmSyntax, 17 + supportsSyncEsmEvaluate, 18 + } from './vm/utils' 14 19 import { ViteExecutor } from './vm/vite-executor' 15 20 16 21 const { existsSync } = fs ··· 51 56 exists?: boolean 52 57 } 53 58 59 + // how the sync require(esm) graph walker should treat a resolved module: 60 + // 'ready' modules are complete synthetic modules (builtins, CJS files), 61 + // 'source'/'json' carry the raw content for the walker to build itself 62 + export type SyncModuleDisposition 63 + = | { kind: 'ready'; module: VMModule } 64 + | { kind: 'json'; code: string } 65 + | { kind: 'source'; code: string } 66 + 54 67 // TODO: improve Node.js strict mode support in #2854 55 68 export class ExternalModulesExecutor { 56 69 private cjs: CommonjsExecutor ··· 78 91 fileMap: options.fileMap, 79 92 codeCache: options.codeCache, 80 93 interopDefault: options.interopDefault, 94 + shouldRequireAsEsm: this.shouldRequireAsEsm, 95 + requireEsm: this.requireEsm, 81 96 }) 82 97 this.vite = new ViteExecutor({ 83 98 esmExecutor: this.esm, ··· 100 115 101 116 createRequire(identifier: string): NodeJS.Require { 102 117 return this.cjs.createRequire(identifier) 118 + } 119 + 120 + #esmSyntaxCache = new Map<string, boolean>() 121 + 122 + // require() dispatches to the sync ESM loader only for files that are 123 + // explicitly marked as ESM (.mjs or a "type": "module" package scope). 124 + // JSON files keep the CJS json loader for Node require() parity — the 125 + // extension wins over the package scope. 126 + private shouldRequireAsEsm = (resolvedPath: string): boolean => { 127 + if (!supportsSyncEsmEvaluate) { 128 + return false 129 + } 130 + const information = this.getModuleInformation(resolvedPath) 131 + if (information.type !== 'module' || information.path.endsWith('.json')) { 132 + return false 133 + } 134 + if (information.path.endsWith('.mjs')) { 135 + return true 136 + } 137 + // A .js file in an ESM package scope may still contain plain CJS code — 138 + // Node evaluates it as ESM with injected CJS module variables (module, 139 + // require, __filename), which a vm SourceTextModule cannot emulate. 140 + // Files without ESM syntax keep loading through the CJS executor; a 141 + // false negative here is corrected by its ESM-syntax fallback. 142 + let syntax = this.#esmSyntaxCache.get(information.path) 143 + if (syntax == null) { 144 + syntax = hasEsmSyntax(this.fs.readFile(information.path)) 145 + this.#esmSyntaxCache.set(information.path, syntax) 146 + } 147 + return syntax 148 + } 149 + 150 + private requireEsm = (resolvedPath: string): unknown => { 151 + const { url } = this.getModuleInformation(resolvedPath) 152 + const module = this.esm.requireEsModuleSync(url) 153 + const namespace = module.namespace as Record<string, unknown> 154 + // Node parity: an ES module can define its own require() result with an 155 + // export named "module.exports" 156 + return 'module.exports' in namespace 157 + ? namespace['module.exports'] 158 + : namespace 159 + } 160 + 161 + public resolveSyncSpecifier = ( 162 + specifier: string, 163 + referencer: string, 164 + ): string => { 165 + const resolved = this.resolve(specifier, referencer) as 166 + | string 167 + | Promise<string> 168 + if (resolved instanceof Promise) { 169 + throw createRequireAsyncModuleError( 170 + referencer, 171 + `"${specifier}" cannot be resolved synchronously`, 172 + ) 173 + } 174 + return resolved 175 + } 176 + 177 + // the sync counterpart of `createModule`, used by the require(esm) graph 178 + // walker. `forceEsmSource` loads a 'commonjs'-typed file as ES module 179 + // source — the CJS executor requests this after its parser rejected a .js 180 + // file that contains ESM syntax. 181 + public materializeSyncModule = ( 182 + identifier: string, 183 + forceEsmSource: boolean, 184 + ): SyncModuleDisposition => { 185 + const information = this.getModuleInformation(identifier) 186 + const { type, path } = information 187 + this.assertModuleExists(information) 188 + 189 + switch (type) { 190 + case 'builtin': 191 + return { 192 + kind: 'ready', 193 + module: this.cjs.getCoreSyntheticModule(identifier), 194 + } 195 + case 'module': 196 + case 'commonjs': { 197 + if (type === 'commonjs' && !forceEsmSource) { 198 + return { 199 + kind: 'ready', 200 + module: this.cjs.getCjsSyntheticModule(path, identifier), 201 + } 202 + } 203 + if (path.endsWith('.json')) { 204 + return { kind: 'json', code: this.fs.readFile(path) } 205 + } 206 + return { kind: 'source', code: this.fs.readFile(path) } 207 + } 208 + case 'data': 209 + // data: URIs are materialized by the ESM executor before it consults 210 + // the external executor 211 + throw new Error( 212 + `[vitest] Unexpected data: module ${identifier} in the sync module walker. This is a bug in Vitest.`, 213 + ) 214 + case 'vite': 215 + throw createRequireAsyncModuleError( 216 + identifier, 217 + 'the module is transformed by Vite, which is asynchronous', 218 + ) 219 + case 'wasm': 220 + throw createRequireAsyncModuleError( 221 + identifier, 222 + 'WebAssembly modules cannot be loaded synchronously', 223 + ) 224 + case 'network': 225 + throw createRequireAsyncModuleError( 226 + identifier, 227 + 'network modules cannot be loaded synchronously', 228 + ) 229 + default: { 230 + const _deadend: never = type 231 + return _deadend 232 + } 233 + } 103 234 } 104 235 105 236 // dynamic import can be used in both ESM and CJS, so we have it in the executor ··· 212 343 return { type, path: pathUrl, url: fileUrl } 213 344 } 214 345 215 - private createModule(identifier: string): VMModule | Promise<VMModule> { 216 - const information = this.getModuleInformation(identifier) 217 - const { type, url, path } = information 218 - 219 - // create ERR_MODULE_NOT_FOUND on our own since latest NodeJS's import.meta.resolve doesn't throw on non-existing namespace or path 220 - // https://github.com/nodejs/node/pull/49038 346 + // create ERR_MODULE_NOT_FOUND on our own since latest NodeJS's import.meta.resolve doesn't throw on non-existing namespace or path 347 + // https://github.com/nodejs/node/pull/49038 348 + private assertModuleExists(information: ModuleInformation): void { 349 + const { type, path } = information 221 350 if (type === 'module' || type === 'commonjs' || type === 'wasm') { 222 351 information.exists ??= existsSync(path) 223 352 } ··· 226 355 (error as any).code = 'ERR_MODULE_NOT_FOUND' 227 356 throw error 228 357 } 358 + } 359 + 360 + private createModule(identifier: string): VMModule | Promise<VMModule> { 361 + const information = this.getModuleInformation(identifier) 362 + const { type, url, path } = information 363 + 364 + this.assertModuleExists(information) 229 365 230 366 switch (type) { 231 367 case 'data':
+30
test/e2e/fixtures/vm-cjs-namespace/cjs-namespace.test.js
··· 1 + import { createRequire } from 'node:module' 2 + import * as fs from 'node:fs' 3 + import { expect, test } from 'vitest' 4 + import * as raw from './src/external/raw-cjs.cjs' 5 + import * as collision from './src/external/collision-cjs.cjs' 6 + 7 + const require = createRequire(import.meta.url) 8 + 9 + // https://nodejs.org/api/esm.html#commonjs-namespaces 10 + test('cjs namespace provides the raw exports as "module.exports"', () => { 11 + expect(raw['module.exports']).toEqual({ a: 1 }) 12 + expect(raw['module.exports']).toBe(require('./src/external/raw-cjs.cjs')) 13 + expect(raw.default).toBe(raw['module.exports']) 14 + expect(raw.a).toBe(1) 15 + }) 16 + 17 + test('dynamic import provides the same "module.exports" export', async () => { 18 + const ns = await import('./src/external/raw-cjs.cjs') 19 + expect(ns['module.exports']).toBe(require('./src/external/raw-cjs.cjs')) 20 + }) 21 + 22 + test('synthetic "module.exports" shadows a real property of that name', () => { 23 + expect(collision['module.exports']).toBe(require('./src/external/collision-cjs.cjs')) 24 + expect(collision['module.exports']['module.exports']).toBe('shadowed') 25 + expect(collision.x).toBe(1) 26 + }) 27 + 28 + test('builtin namespaces do not provide "module.exports"', () => { 29 + expect('module.exports' in fs).toBe(false) 30 + })
+12
test/e2e/fixtures/vm-cjs-namespace/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + 3 + export default defineConfig({ 4 + test: { 5 + // pool is provided by the running test (vmThreads or vmForks) 6 + server: { 7 + deps: { 8 + external: [/src\/external/], 9 + }, 10 + }, 11 + }, 12 + })
+120 -18
packages/vitest/src/runtime/vm/commonjs-executor.ts
··· 4 4 import { Module as _Module, createRequire, isBuiltin } from 'node:module' 5 5 import vm from 'node:vm' 6 6 import { basename, dirname, extname } from 'pathe' 7 - import { interopCommonJsModule, SyntheticModule } from './utils' 7 + import { 8 + interopCommonJsModule, 9 + supportsSyncEsmEvaluate, 10 + SyntheticModule, 11 + } from './utils' 8 12 9 13 interface CommonjsExecutorOptions { 10 14 fileMap: FileMap ··· 12 16 interopDefault?: boolean 13 17 context: vm.Context 14 18 importModuleDynamically: ImportModuleDynamically 19 + // resolves to `true` for files that are explicitly marked as ESM; 20 + // require() of those dispatches to `requireEsm` (Node 24.9+) 21 + shouldRequireAsEsm?: (resolvedPath: string) => boolean 22 + requireEsm?: (resolvedPath: string) => unknown 15 23 } 16 24 17 25 const _require = createRequire(import.meta.url) 18 26 19 27 interface PrivateNodeModule extends NodeJS.Module { 20 28 _compile: (code: string, filename: string) => void 29 + } 30 + 31 + // Thrown when the CJS parser rejects a .js file that may contain ESM syntax. 32 + // `loadCommonJSModule` catches it and retries the file as an ES module, 33 + // mirroring Node's own require() ESM-syntax fallback for .js files without 34 + // an ESM package scope. The original error is in `cause`. 35 + class CjsParseError extends SyntaxError { 36 + override name = 'CjsParseError' 37 + constructor(cause: Error) { 38 + super(cause.message, { cause }) 39 + } 21 40 } 22 41 23 42 const requiresCache = new WeakMap<NodeJS.Module, NodeJS.Require>() ··· 38 57 private codeCache: CodeCache | undefined 39 58 private Module: typeof _Module 40 59 private interopDefault: boolean | undefined 60 + private shouldRequireAsEsm: ((resolvedPath: string) => boolean) | undefined 61 + private requireEsm: ((resolvedPath: string) => unknown) | undefined 62 + // .js files that the ESM-syntax fallback already loaded as ES modules, 63 + // so later require() calls skip the guaranteed-to-fail CJS parse 64 + private esmSyntaxFallbackFiles = new Set<string>() 41 65 42 66 constructor(options: CommonjsExecutorOptions) { 43 67 this.context = options.context 44 68 this.fs = options.fileMap 45 69 this.codeCache = options.codeCache 46 70 this.interopDefault = options.interopDefault 71 + this.shouldRequireAsEsm = options.shouldRequireAsEsm 72 + this.requireEsm = options.requireEsm 47 73 48 74 const primitives = vm.runInContext( 49 75 '({ Object, Array, Error })', ··· 115 141 const cjsModule = Module.wrap(code) 116 142 const codeCache = executor.codeCache 117 143 const cachedData = codeCache?.get(filename, cjsModule) 118 - const script = new vm.Script(cjsModule, { 119 - filename, 120 - cachedData, 121 - importModuleDynamically: options.importModuleDynamically, 122 - } as any) 144 + let script: vm.Script 145 + try { 146 + script = new vm.Script(cjsModule, { 147 + filename, 148 + cachedData, 149 + importModuleDynamically: options.importModuleDynamically, 150 + } as any) 151 + } 152 + catch (error) { 153 + if ( 154 + error instanceof SyntaxError 155 + && executor.canFallbackToEsm(filename) 156 + ) { 157 + throw new CjsParseError(error) 158 + } 159 + throw error 160 + } 123 161 if (cachedData && script.cachedDataRejected) { 124 162 codeCache!.delete(filename) 125 163 } ··· 219 257 CommonjsExecutor.cjsConditions = parseCjsConditions( 220 258 process.execArgv, 221 259 process.env.NODE_OPTIONS, 260 + supportsSyncEsmEvaluate, 222 261 ) 223 262 } 224 263 return CommonjsExecutor.cjsConditions ··· 239 278 const ext = extname(resolved) 240 279 if (ext === '.node' || isBuiltin(resolved)) { 241 280 return this.requireCoreModule(resolved) 281 + } 282 + if (this.shouldRequireAsEsm?.(resolved)) { 283 + return this.requireEsm!(resolved) 242 284 } 243 285 const module = new this.Module(resolved) 244 286 return this.loadCommonJSModule(module, resolved) ··· 282 324 return cached.exports 283 325 } 284 326 327 + if (this.esmSyntaxFallbackFiles.has(filename)) { 328 + return this.requireEsm!(filename) as Record<string, unknown> 329 + } 330 + 285 331 const extension = this.findLongestRegisteredExtension(filename) 286 332 const loader = this.extensions[extension] || this.extensions['.js'] 287 - loader(module, filename) 333 + try { 334 + loader(module, filename) 335 + } 336 + catch (error) { 337 + if (error instanceof CjsParseError) { 338 + return this.fallbackRequireEsm(filename, error) 339 + } 340 + throw error 341 + } 288 342 289 343 return module.exports 344 + } 345 + 346 + private canFallbackToEsm(filename: string): boolean { 347 + return ( 348 + supportsSyncEsmEvaluate 349 + && this.requireEsm != null 350 + // mirrors Node's ESM-syntax detection scope: explicit extensions 351 + // (.mjs/.cjs) already picked their loader 352 + && extname(filename) === '.js' 353 + ) 354 + } 355 + 356 + private fallbackRequireEsm( 357 + filename: string, 358 + parseError: CjsParseError, 359 + ): Record<string, unknown> { 360 + let exports: unknown 361 + try { 362 + exports = this.requireEsm!(filename) 363 + } 364 + catch (esmError) { 365 + // both parsers rejected the file — surface the original CJS error 366 + if (esmError instanceof SyntaxError) { 367 + throw parseError.cause 368 + } 369 + throw esmError 370 + } 371 + this.esmSyntaxFallbackFiles.add(filename) 372 + return exports as Record<string, unknown> 290 373 } 291 374 292 375 private findLongestRegisteredExtension(filename: string) { ··· 334 417 this.interopDefault, 335 418 exports, 336 419 ) 337 - const module = new SyntheticModule([...keys, 'default'], function () { 338 - for (const key of keys) { 339 - this.setExport(key, moduleExports[key]) 340 - } 341 - this.setExport('default', defaultExport) 342 - }, { context: this.context, identifier }) 420 + const module = new SyntheticModule( 421 + [...keys, 'default', 'module.exports'], 422 + function () { 423 + for (const key of keys) { 424 + this.setExport(key, moduleExports[key]) 425 + } 426 + this.setExport('default', defaultExport) 427 + // the raw module.exports value, mirroring the handling of the 428 + // 'module.exports' export name in require(esm) interop: 429 + // https://nodejs.org/api/esm.html#commonjs-namespaces 430 + this.setExport('module.exports', exports) 431 + }, 432 + { context: this.context, identifier }, 433 + ) 343 434 this.moduleCache.set(identifier, module) 344 435 return module 345 436 } ··· 393 484 if (ext === '.node' || isBuiltin(identifier)) { 394 485 return this.requireCoreModule(identifier) 395 486 } 487 + if (this.shouldRequireAsEsm?.(identifier)) { 488 + return this.requireEsm!(identifier) 489 + } 396 490 const module = new this.Module(identifier) 397 491 return this.loadCommonJSModule(module, identifier) 398 492 } ··· 416 510 } 417 511 418 512 // The "module-sync" exports condition (added in Node 22.12/20.19 when 419 - // require(esm) was unflagged) can resolve to ESM files that our CJS 420 - // vm.Script executor cannot handle. We exclude it by passing explicit 421 - // CJS conditions to require.resolve (Node 22.12+). 513 + // require(esm) was unflagged) can resolve to ESM files. When require(esm) 514 + // is supported (Node 24.9+), the condition is included, matching Node. 515 + // Otherwise our CJS vm.Script executor cannot handle the resolved ESM 516 + // files, so the condition is excluded by passing explicit CJS conditions 517 + // to require.resolve (Node 22.12+). 422 518 // Must be a Set because Node's internal resolver calls conditions.has(). 423 - // User-specified --conditions/-C flags are respected, except module-sync. 424 519 export function parseCjsConditions( 425 520 execArgv: string[], 426 521 nodeOptions?: string, 522 + requireEsmSupported = false, 427 523 ): Set<string> { 428 524 const conditions = ['node', 'require', 'node-addons'] 525 + if (requireEsmSupported) { 526 + conditions.push('module-sync') 527 + } 429 528 const args = [ 430 529 ...execArgv, 431 530 ...(nodeOptions?.split(/\s+/) ?? []), ··· 440 539 conditions.push(args[++i]) 441 540 } 442 541 } 443 - return new Set(conditions.filter(c => c !== 'module-sync')) 542 + if (!requireEsmSupported) { 543 + return new Set(conditions.filter(c => c !== 'module-sync')) 544 + } 545 + return new Set(conditions) 444 546 }
+243 -41
packages/vitest/src/runtime/vm/esm-executor.ts
··· 1 1 import type vm from 'node:vm' 2 - import type { ExternalModulesExecutor } from '../external-executor' 3 - import type { VMModule } from './types' 2 + import type { ExternalModulesExecutor, SyncModuleDisposition } from '../external-executor' 3 + import type { VMModule, VMSourceTextModule, VMSyntheticModule } from './types' 4 4 import { dirname } from 'node:path' 5 5 import { fileURLToPath } from 'node:url' 6 - import { SourceTextModule, SyntheticModule } from './utils' 6 + import { 7 + createConcurrentRequireError, 8 + createRequireAsyncModuleError, 9 + SourceTextModule, 10 + SyntheticModule, 11 + } from './utils' 7 12 8 13 interface EsmExecutorOptions { 9 14 context: vm.Context ··· 11 16 12 17 const dataURIRegex 13 18 = /^data:(?<mime>text\/javascript|application\/json|application\/wasm)(?:;(?<encoding>charset=utf-8|base64))?,(?<code>.*)$/ 19 + 20 + function parseDataUri(identifier: string): { mime: string; code: string | Buffer } { 21 + const match = identifier.match(dataURIRegex) 22 + if (!match || !match.groups) { 23 + throw new Error('Invalid data URI') 24 + } 25 + const { mime, encoding } = match.groups 26 + let code: string | Buffer = match.groups.code 27 + if (mime === 'application/wasm') { 28 + if (!encoding) { 29 + throw new Error('Missing data URI encoding') 30 + } 31 + if (encoding !== 'base64') { 32 + throw new Error(`Invalid data URI encoding: ${encoding}`) 33 + } 34 + return { mime, code: Buffer.from(code, 'base64') } 35 + } 36 + if (!encoding || encoding === 'charset=utf-8') { 37 + code = decodeURIComponent(code) 38 + } 39 + else if (encoding === 'base64') { 40 + code = Buffer.from(code, 'base64').toString() 41 + } 42 + else { 43 + throw new Error(`Invalid data URI encoding: ${encoding}`) 44 + } 45 + return { mime, code } 46 + } 14 47 15 48 export class EsmExecutor { 16 49 private moduleCache = new Map<string, VMModule | Promise<VMModule>>() ··· 73 106 this.moduleCache.set(fileURL, m) 74 107 return m 75 108 } 109 + const m = this.createSourceTextModule(fileURL, code) 110 + this.moduleCache.set(fileURL, m) 111 + return m 112 + } 113 + 114 + private createSourceTextModule( 115 + fileURL: string, 116 + code: string, 117 + ): VMSourceTextModule { 76 118 const codeCache = this.executor.codeCache 77 119 const cachedData = codeCache?.get(fileURL, code) 78 120 const m = new SourceTextModule(code, { ··· 99 141 if (!cachedData) { 100 142 codeCache?.store(fileURL, code, () => m.createCachedData()) 101 143 } 102 - this.moduleCache.set(fileURL, m) 103 144 return m 145 + } 146 + 147 + // Loads an ES module graph synchronously for require(esm), mirroring Node's 148 + // own behaviour on Node 24.9+. The graph is collected into a local scratch 149 + // map first and committed to the module cache only after the whole graph is 150 + // proven to be synchronously evaluable, so a failed require() does not 151 + // poison the cache for a later import() of the same file. 152 + public requireEsModuleSync(rootIdentifier: string): VMModule { 153 + const cachedRoot = this.moduleCache.get(rootIdentifier) 154 + if (cachedRoot) { 155 + return this.reuseSyncModule(rootIdentifier, cachedRoot) 156 + } 157 + 158 + interface ScratchEntry { 159 + module: VMModule 160 + // dependency identifiers, one per module request; only present on 161 + // source-text modules built by this walk 162 + deps?: string[] 163 + // whether this walk owns the module and should commit it to the cache 164 + // (false for cache hits and modules owned by the CJS executor) 165 + commit: boolean 166 + } 167 + 168 + const scratch = new Map<string, ScratchEntry>() 169 + const worklist: string[] = [rootIdentifier] 170 + 171 + while (worklist.length > 0) { 172 + const identifier = worklist.pop()! 173 + if (scratch.has(identifier)) { 174 + continue 175 + } 176 + 177 + const cached = this.moduleCache.get(identifier) 178 + if (cached) { 179 + scratch.set(identifier, { 180 + module: this.reuseSyncModule(identifier, cached), 181 + commit: false, 182 + }) 183 + continue 184 + } 185 + 186 + const disposition = identifier.startsWith('data:') 187 + ? this.materializeSyncDataModule(identifier) 188 + : this.executor.materializeSyncModule( 189 + identifier, 190 + identifier === rootIdentifier, 191 + ) 192 + 193 + if (disposition.kind === 'ready') { 194 + scratch.set(identifier, { module: disposition.module, commit: false }) 195 + continue 196 + } 197 + 198 + if (disposition.kind === 'json') { 199 + scratch.set(identifier, { 200 + module: this.createJsonModule(identifier, disposition.code), 201 + commit: true, 202 + }) 203 + continue 204 + } 205 + 206 + const module = this.createSourceTextModule(identifier, disposition.code) 207 + if ( 208 + typeof module.hasTopLevelAwait === 'function' 209 + && module.hasTopLevelAwait() 210 + ) { 211 + throw createRequireAsyncModuleError( 212 + identifier, 213 + 'the module uses top-level await', 214 + ) 215 + } 216 + if ( 217 + module.moduleRequests == null 218 + || typeof module.linkRequests !== 'function' 219 + ) { 220 + throw createRequireAsyncModuleError( 221 + identifier, 222 + 'this version of Node.js cannot load ES modules synchronously', 223 + ) 224 + } 225 + const deps: string[] = [] 226 + for (const request of module.moduleRequests) { 227 + const depIdentifier = this.executor.resolveSyncSpecifier( 228 + request.specifier, 229 + identifier, 230 + ) 231 + deps.push(depIdentifier) 232 + if (!scratch.has(depIdentifier)) { 233 + worklist.push(depIdentifier) 234 + } 235 + } 236 + scratch.set(identifier, { module, deps, commit: true }) 237 + } 238 + 239 + for (const entry of scratch.values()) { 240 + if (entry.deps) { 241 + entry.module.linkRequests!( 242 + entry.deps.map(dep => scratch.get(dep)!.module), 243 + ) 244 + } 245 + } 246 + 247 + const root = scratch.get(rootIdentifier)! 248 + if (root.deps && typeof root.module.instantiate === 'function') { 249 + root.module.instantiate() 250 + } 251 + 252 + if ( 253 + typeof root.module.hasAsyncGraph === 'function' 254 + && root.module.hasAsyncGraph() 255 + ) { 256 + // top-level await is rejected per module during the walk, so this is a 257 + // defensive check that an async graph never reaches the sync evaluate 258 + let culprit = rootIdentifier 259 + for (const [identifier, entry] of scratch) { 260 + if ( 261 + typeof entry.module.hasTopLevelAwait === 'function' 262 + && entry.module.hasTopLevelAwait() 263 + ) { 264 + culprit = identifier 265 + break 266 + } 267 + } 268 + throw createRequireAsyncModuleError( 269 + rootIdentifier, 270 + culprit === rootIdentifier 271 + ? 'the module uses top-level await' 272 + : `its dependency uses top-level await (${culprit})`, 273 + ) 274 + } 275 + 276 + for (const [identifier, entry] of scratch) { 277 + if (entry.commit && !this.moduleCache.has(identifier)) { 278 + this.moduleCache.set(identifier, entry.module) 279 + } 280 + } 281 + 282 + // with no top-level await in the graph, evaluate() fulfills synchronously 283 + // and an evaluation error lands on `status`/`error`, not on the promise 284 + root.module.evaluate().catch(() => {}) 285 + 286 + if (root.module.status === 'errored') { 287 + throw root.module.error 288 + } 289 + if (root.module.status !== 'evaluated') { 290 + throw new Error( 291 + `[vitest] Expected synchronous evaluation to complete for ${rootIdentifier}, but module status is "${root.module.status}". This is a bug in Vitest.`, 292 + ) 293 + } 294 + return root.module 295 + } 296 + 297 + // A cached module is reusable by the sync walker only when it is settled: 298 + // anything else (a pending Promise or a module in 'unlinked' → 'evaluating') 299 + // is a concurrent import() mid-flight that a synchronous require() can 300 + // neither await nor safely link against. 301 + private reuseSyncModule( 302 + identifier: string, 303 + cached: VMModule | Promise<VMModule>, 304 + ): VMModule { 305 + if (cached instanceof Promise) { 306 + throw createConcurrentRequireError(identifier) 307 + } 308 + if (cached.status === 'errored') { 309 + throw cached.error 310 + } 311 + if (cached.status !== 'evaluated') { 312 + throw createConcurrentRequireError(identifier) 313 + } 314 + return cached 315 + } 316 + 317 + private materializeSyncDataModule(identifier: string): SyncModuleDisposition { 318 + const { mime, code } = parseDataUri(identifier) 319 + if (mime === 'application/wasm') { 320 + throw createRequireAsyncModuleError( 321 + identifier, 322 + 'WebAssembly modules cannot be loaded synchronously', 323 + ) 324 + } 325 + if (mime === 'application/json') { 326 + return { kind: 'json', code: code as string } 327 + } 328 + return { kind: 'source', code: code as string } 329 + } 330 + 331 + private createJsonModule(identifier: string, code: string): VMModule { 332 + return new SyntheticModule( 333 + ['default'], 334 + function (this: VMSyntheticModule) { 335 + this.setExport('default', JSON.parse(code)) 336 + }, 337 + { context: this.context, identifier }, 338 + ) 104 339 } 105 340 106 341 public async createWebAssemblyModule(fileUrl: string, getCode: () => Buffer<ArrayBuffer>): Promise<VMModule> { ··· 198 433 return cached 199 434 } 200 435 201 - const match = identifier.match(dataURIRegex) 202 - 203 - if (!match || !match.groups) { 204 - throw new Error('Invalid data URI') 205 - } 206 - 207 - const mime = match.groups.mime 208 - const encoding = match.groups.encoding 436 + const { mime, code } = parseDataUri(identifier) 209 437 210 438 if (mime === 'application/wasm') { 211 - if (!encoding) { 212 - throw new Error('Missing data URI encoding') 213 - } 214 - 215 - if (encoding !== 'base64') { 216 - throw new Error(`Invalid data URI encoding: ${encoding}`) 217 - } 218 - 219 439 const module = this.loadWebAssemblyModule( 220 - Buffer.from(match.groups.code, 'base64'), 440 + code as Buffer<ArrayBuffer>, 221 441 identifier, 222 442 ) 223 443 this.moduleCache.set(identifier, module) 224 444 return module 225 445 } 226 446 227 - let code = match.groups.code 228 - if (!encoding || encoding === 'charset=utf-8') { 229 - code = decodeURIComponent(code) 230 - } 231 - else if (encoding === 'base64') { 232 - code = Buffer.from(code, 'base64').toString() 233 - } 234 - else { 235 - throw new Error(`Invalid data URI encoding: ${encoding}`) 236 - } 237 - 238 447 if (mime === 'application/json') { 239 - const module = new SyntheticModule( 240 - ['default'], 241 - function () { 242 - const obj = JSON.parse(code) 243 - this.setExport('default', obj) 244 - }, 245 - { context: this.context, identifier }, 246 - ) 448 + const module = this.createJsonModule(identifier, code as string) 247 449 this.moduleCache.set(identifier, module) 248 450 return module 249 451 } 250 452 251 - return this.createEsModule(identifier, () => code) 453 + return this.createEsModule(identifier, () => code as string) 252 454 } 253 455 } 254 456
+12
packages/vitest/src/runtime/vm/types.ts
··· 20 20 | 'evaluating' 21 21 | 'evaluated' 22 22 | 'errored' 23 + export interface VMModuleRequest { 24 + specifier: string 25 + attributes: Record<string, string> 26 + phase?: string 27 + } 23 28 export declare class VMModule { 24 29 dependencySpecifiers: readonly string[] 25 30 error: any ··· 29 34 status: ModuleStatus 30 35 evaluate(options?: ModuleEvaluateOptions): Promise<void> 31 36 link(linker: ModuleLinker): Promise<void> 37 + // synchronous module graph APIs, only available on Node 24.9+ 38 + // (see `supportsSyncEsmEvaluate` in ./utils.ts) 39 + hasAsyncGraph?: () => boolean 40 + hasTopLevelAwait?: () => boolean 41 + moduleRequests?: readonly VMModuleRequest[] 42 + linkRequests?: (modules: readonly VMModule[]) => void 43 + instantiate?: () => void 32 44 } 33 45 interface SyntheticModuleOptions { 34 46 /**
+54 -1
packages/vitest/src/runtime/vm/utils.ts
··· 1 1 import type { VMSourceTextModule, VMSyntheticModule } from './types' 2 2 import vm from 'node:vm' 3 + import { initSync, parse } from 'es-module-lexer' 3 4 4 5 export function interopCommonJsModule( 5 6 interopDefault: boolean | undefined, ··· 26 27 const moduleKeys = Object.keys(mod) 27 28 const allKeys = new Set([...defaultKets, ...moduleKeys]) 28 29 allKeys.delete('default') 30 + // the namespace always provides its own synthetic 'module.exports' 31 + // export, shadowing a real property of that name (Node parity) 32 + allKeys.delete('module.exports') 29 33 return { 30 34 keys: Array.from(allKeys), 31 35 moduleExports: new Proxy(mod, { ··· 38 42 } 39 43 40 44 return { 41 - keys: Object.keys(mod).filter(key => key !== 'default'), 45 + keys: Object.keys(mod).filter( 46 + key => key !== 'default' && key !== 'module.exports', 47 + ), 42 48 moduleExports: mod, 43 49 defaultExport: mod, 44 50 } ··· 53 59 .SyntheticModule 54 60 export const SourceTextModule: typeof VMSourceTextModule = (vm as any) 55 61 .SourceTextModule 62 + 63 + // `SourceTextModule#hasAsyncGraph` marks the Node 24.9+ vm APIs required to 64 + // load an ES module graph synchronously (`moduleRequests`, `linkRequests`, 65 + // `instantiate`, synchronously-completing `evaluate`) — the same APIs Node 66 + // itself uses for require(esm) 67 + export const supportsSyncEsmEvaluate: boolean 68 + = typeof (SourceTextModule as any)?.prototype?.hasAsyncGraph === 'function' 69 + 70 + let lexerInitialized = false 71 + 72 + // Returns true when `source` contains ESM syntax: static import/export 73 + // statements or `import.meta` (dynamic import is allowed in CJS and does not 74 + // count). Returns false when the lexer cannot parse the source at all — 75 + // native ESM would fail on it as well, so the CJS error should surface. 76 + export function hasEsmSyntax(source: string): boolean { 77 + if (!lexerInitialized) { 78 + initSync() 79 + lexerInitialized = true 80 + } 81 + try { 82 + return parse(source)[3] 83 + } 84 + catch { 85 + return false 86 + } 87 + } 88 + 89 + // mirrors Node's require(esm) error codes so user-side catches work uniformly 90 + 91 + export function createRequireAsyncModuleError( 92 + identifier: string, 93 + detail: string, 94 + ): Error { 95 + const error = new Error( 96 + `require() cannot be used to load ES Module ${identifier}: ${detail}. Use import() instead.`, 97 + ) as Error & { code: string } 98 + error.code = 'ERR_REQUIRE_ASYNC_MODULE' 99 + return error 100 + } 101 + 102 + export function createConcurrentRequireError(identifier: string): Error { 103 + const error = new Error( 104 + `Cannot require() ES Module ${identifier} synchronously: it is currently being loaded by a concurrent import(). Await that import before calling require(), or import this module instead of requiring it.`, 105 + ) as Error & { code: string } 106 + error.code = 'ERR_REQUIRE_ESM' 107 + return error 108 + }
+2
test/e2e/fixtures/vm-cjs-namespace/src/external/collision-cjs.cjs
··· 1 + exports['module.exports'] = 'shadowed' 2 + exports.x = 1
+1
test/e2e/fixtures/vm-cjs-namespace/src/external/raw-cjs.cjs
··· 1 + module.exports = { a: 1 }