[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: add toggable `injectCjsGlobals` option (#10709)

authored by

Vladimir and committed by
GitHub
(Jul 8, 2026, 9:02 AM +0200) 826714ec 6198fd2e

+595 -126
+4
docs/.vitepress/config.ts
··· 307 307 link: '/config/globals', 308 308 }, 309 309 { 310 + text: 'injectCjsGlobals', 311 + link: '/config/injectcjsglobals', 312 + }, 313 + { 310 314 text: 'environment', 311 315 link: '/config/environment', 312 316 },
+47
docs/config/injectcjsglobals.md
··· 1 + --- 2 + title: injectCjsGlobals | Config 3 + --- 4 + 5 + # injectCjsGlobals 6 + 7 + - **Type:** `boolean` 8 + - **Default:** `true` 9 + - **CLI:** `--no-inject-cjs-globals`, `--injectCjsGlobals=false` 10 + 11 + Inject CommonJS module variables (`module`, `exports`, `require`, `__filename`, `__dirname`) into every module processed by Vitest. 12 + 13 + By default, every file that Vitest transforms has access to these variables even if it is written using ESM syntax. This doesn't reflect how modules work in the wild: browsers do not support CommonJS variables, and Node.js doesn't expose them in ES modules. 14 + 15 + To make the module environment stricter and closer to the target runtime, you can disable this behaviour: 16 + 17 + ```js 18 + import { defineConfig } from 'vitest/config' 19 + 20 + export default defineConfig({ 21 + test: { 22 + injectCjsGlobals: false, 23 + }, 24 + }) 25 + ``` 26 + 27 + When this option is disabled, only modules that are detected to be CommonJS receive these variables. CommonJS modules always keep them because they are part of the module scope, without them the module cannot be evaluated at all. The module type is detected the same way Node.js does it: 28 + 29 + 1. The file extension: `.cjs` and `.cts` files are always CommonJS, `.mjs` and `.mts` files are always ES modules. 30 + 2. The `type` field in the nearest `package.json`: `"module"` means ES module, `"commonjs"` means CommonJS. Same as in Node.js, the lookup stops at the first `package.json` and never crosses a `node_modules` boundary, so dependencies don't inherit the `type` of your project. 31 + 3. The presence of ESM syntax in the file: if the file has no static `import`/`export` declarations and doesn't reference `import.meta`, it is treated as CommonJS. Syntax inside comments and strings doesn't affect the detection. Dynamic imports are allowed in CommonJS modules, so they don't count as ESM syntax; type-only TypeScript imports are erased during the transform, so they don't count either. 32 + 33 + The syntax detection is always enabled: Vitest doesn't respect Node.js CLI flags that modify the module type resolution, like `--no-experimental-detect-module`, `--input-type` (it only applies to the string input in Node.js), or the `--experimental-default-type` flag removed in Node.js 23. 34 + 35 + Referencing a CommonJS variable in an ES module throws a `ReferenceError`, just like outside of Vitest: 36 + 37 + ``` 38 + ReferenceError: __dirname is not defined 39 + 40 + "__dirname" is a CommonJS variable that is not available in ES modules, and "injectCjsGlobals" is disabled. If this module is meant to be an ES module, use "import.meta.dirname" instead of "__dirname". If it is meant to be a CommonJS module, use the ".cjs" file extension, set "type": "commonjs" in the nearest package.json, or externalize it with "server.deps.external". 41 + ``` 42 + 43 + ::: warning 44 + This option doesn't affect externalized modules which are always executed by the native runtime. Node.js provides CommonJS variables to externalized CommonJS modules on its own. 45 + 46 + Note that inlined CommonJS modules are not processed by Vite plugins even when this option is enabled: `require` calls always leave the module runner, so features like mocking do not apply to them. 47 + :::
+7
docs/guide/cli-generated.md
··· 327 327 328 328 Inject apis globally 329 329 330 + ### injectCjsGlobals 331 + 332 + - **CLI:** `--injectCjsGlobals` 333 + - **Config:** [injectCjsGlobals](/config/injectcjsglobals) 334 + 335 + Inject CommonJS variables (`module`, `exports`, `require`, `__filename`, `__dirname`) into every test module. To disable, use `--no-inject-cjs-globals` (default: `true`) 336 + 330 337 ### dom 331 338 332 339 - **CLI:** `--dom`
+43 -72
packages/utils/src/resolver.ts
··· 1 1 import fs from 'node:fs' 2 - import { dirname, join } from 'pathe' 2 + import { basename, dirname, join } from 'pathe' 3 3 4 - const packageCache = new Map<string, { type?: 'module' | 'commonjs' }>() 4 + const packageScopeTypeCache = new Map<string, 'cjs' | 'esm' | 'none'>() 5 5 6 - export function findNearestPackageData( 7 - basedir: string, 8 - ): { type?: 'module' | 'commonjs' } { 9 - const originalBasedir = basedir 10 - while (basedir) { 11 - const cached = getCachedData(packageCache, basedir, originalBasedir) 6 + // mirrors LOOKUP_PACKAGE_SCOPE from the ESM resolution algorithm: 7 + // the lookup stops at the first package.json and never crosses 8 + // the "node_modules" boundary, so typeless dependencies don't 9 + // inherit the `type` field of the user's project 10 + export function lookupPackageScopeType( 11 + directory: string, 12 + ): 'cjs' | 'esm' | 'none' { 13 + const visited: string[] = [] 14 + let result: 'cjs' | 'esm' | 'none' = 'none' 15 + let current = directory 16 + while (current) { 17 + const cached = packageScopeTypeCache.get(current) 12 18 if (cached) { 13 - return cached 14 - } 15 - 16 - const pkgPath = join(basedir, 'package.json') 17 - if (tryStatSync(pkgPath)?.isFile()) { 18 - const pkgData = JSON.parse(stripBomTag(fs.readFileSync(pkgPath, 'utf8'))) 19 - 20 - if (packageCache) { 21 - setCacheData(packageCache, pkgData, basedir, originalBasedir) 22 - } 23 - 24 - return pkgData 25 - } 26 - 27 - const nextBasedir = dirname(basedir) 28 - if (nextBasedir === basedir) { 19 + result = cached 29 20 break 30 21 } 31 - basedir = nextBasedir 22 + if (basename(current) === 'node_modules') { 23 + break 24 + } 25 + visited.push(current) 26 + const packageJsonPath = join(current, 'package.json') 27 + if (tryStatSync(packageJsonPath)?.isFile()) { 28 + try { 29 + const packageJson = JSON.parse(stripBomTag(fs.readFileSync(packageJsonPath, 'utf8'))) 30 + if (packageJson.type === 'module') { 31 + result = 'esm' 32 + } 33 + else if (packageJson.type === 'commonjs') { 34 + result = 'cjs' 35 + } 36 + } 37 + catch { 38 + // ignore malformed package.json and fall back to "none" 39 + } 40 + break 41 + } 42 + 43 + const parent = dirname(current) 44 + if (parent === current) { 45 + break 46 + } 47 + current = parent 32 48 } 33 49 34 - return {} 50 + visited.forEach(dir => packageScopeTypeCache.set(dir, result)) 51 + return result 35 52 } 36 53 37 54 function stripBomTag(content: string): string { ··· 49 66 } 50 67 catch { 51 68 // Ignore errors 52 - } 53 - } 54 - 55 - export function getCachedData<T>( 56 - cache: Map<string, T>, 57 - basedir: string, 58 - originalBasedir: string, 59 - ): NonNullable<T> | undefined { 60 - const pkgData = cache.get(getFnpdCacheKey(basedir)) 61 - if (pkgData) { 62 - traverseBetweenDirs(originalBasedir, basedir, (dir) => { 63 - cache.set(getFnpdCacheKey(dir), pkgData) 64 - }) 65 - return pkgData 66 - } 67 - } 68 - 69 - export function setCacheData<T>( 70 - cache: Map<string, T>, 71 - data: T, 72 - basedir: string, 73 - originalBasedir: string, 74 - ): void { 75 - cache.set(getFnpdCacheKey(basedir), data) 76 - traverseBetweenDirs(originalBasedir, basedir, (dir) => { 77 - cache.set(getFnpdCacheKey(dir), data) 78 - }) 79 - } 80 - 81 - function getFnpdCacheKey(basedir: string) { 82 - return `fnpd_${basedir}` 83 - } 84 - 85 - /** 86 - * Traverse between `longerDir` (inclusive) and `shorterDir` (exclusive) and call `cb` for each dir. 87 - * @param longerDir Longer dir path, e.g. `/User/foo/bar/baz` 88 - * @param shorterDir Shorter dir path, e.g. `/User/foo` 89 - */ 90 - function traverseBetweenDirs( 91 - longerDir: string, 92 - shorterDir: string, 93 - cb: (dir: string) => void, 94 - ) { 95 - while (longerDir !== shorterDir) { 96 - cb(longerDir) 97 - longerDir = dirname(longerDir) 98 69 } 99 70 }
+2
packages/vitest/src/defaults.ts
··· 68 68 isolate: boolean 69 69 watch: boolean 70 70 globals: boolean 71 + injectCjsGlobals: boolean 71 72 environment: 'node' 72 73 clearMocks: boolean 73 74 restoreMocks: boolean ··· 107 108 isolate: true, 108 109 watch: !isCI && process.stdin.isTTY && !isAgent, 109 110 globals: false, 111 + injectCjsGlobals: true, 110 112 environment: 'node', 111 113 clearMocks: true, 112 114 restoreMocks: false,
+1
packages/web-worker/src/runner.ts
··· 21 21 22 22 const evaluator = new VitestModuleEvaluator(vm, { 23 23 interopDefault: state.config.deps.interopDefault, 24 + injectCjsGlobals: state.config.injectCjsGlobals, 24 25 moduleExecutionInfo: state.moduleExecutionInfo, 25 26 getCurrentTestFilepath: () => state.filepath, 26 27 compiledFunctionArgumentsNames,
+1 -1
test/e2e/test/vite-ssr-resolve.test.ts
··· 4 4 import { describe, expect, onTestFinished, test } from 'vitest' 5 5 import { createVitest } from 'vitest/node' 6 6 7 - const nodeModulesDir = join(import.meta.dirname, '../../node_modules') 7 + const nodeModulesDir = join(import.meta.dirname, '../node_modules') 8 8 9 9 describe.each(['deprecated', 'environment'] as const)('VitestResolver with Vite SSR config in %s style', (style) => { 10 10 test('merges vite ssr.resolve.noExternal with server.deps.inline', async () => {
+7
test/unit/test/cli-test.test.ts
··· 215 215 expect(getCLIOptions('--max-concurrency=1000')).toEqual({ maxConcurrency: 1000 }) 216 216 }) 217 217 218 + test('injectCjsGlobals is parsed correctly', () => { 219 + expect(getCLIOptions('--injectCjsGlobals')).toEqual({ injectCjsGlobals: true }) 220 + expect(getCLIOptions('--inject-cjs-globals')).toEqual({ injectCjsGlobals: true }) 221 + expect(getCLIOptions('--injectCjsGlobals=false')).toEqual({ injectCjsGlobals: false }) 222 + expect(getCLIOptions('--no-inject-cjs-globals')).toEqual({ injectCjsGlobals: false }) 223 + }) 224 + 218 225 test('cache is parsed correctly', () => { 219 226 expect(getCLIOptions('--cache')).toEqual({ cache: {} }) 220 227 expect(getCLIOptions('--no-cache')).toEqual({ cache: false })
+39
test/unit/test/lookup-package-scope-type.test.ts
··· 1 + import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' 2 + import { tmpdir } from 'node:os' 3 + import { join } from 'node:path' 4 + import { lookupPackageScopeType } from '@vitest/utils/resolver' 5 + import { expect, it, onTestFinished } from 'vitest' 6 + 7 + function scaffold(tree: Record<string, string>): string { 8 + const root = mkdtempSync(join(tmpdir(), 'vitest-scope-')) 9 + onTestFinished(() => rmSync(root, { recursive: true, force: true })) 10 + for (const [relativePath, type] of Object.entries(tree)) { 11 + const dir = join(root, relativePath) 12 + mkdirSync(dir, { recursive: true }) 13 + writeFileSync(join(dir, 'package.json'), JSON.stringify(type ? { type } : {})) 14 + } 15 + return root 16 + } 17 + 18 + it('resolves the "type" of the nearest package.json', () => { 19 + const root = scaffold({ '.': 'module' }) 20 + expect(lookupPackageScopeType(join(root, 'src', 'nested'))).toBe('esm') 21 + }) 22 + 23 + it('reports "commonjs" scopes as cjs and typeless scopes as none', () => { 24 + const cjsRoot = scaffold({ '.': 'commonjs' }) 25 + const typelessRoot = scaffold({ '.': '' }) 26 + expect(lookupPackageScopeType(cjsRoot)).toBe('cjs') 27 + expect(lookupPackageScopeType(typelessRoot)).toBe('none') 28 + }) 29 + 30 + it('stops at the node_modules boundary and does not inherit the project type', () => { 31 + const root = scaffold({ 32 + '.': 'module', 33 + 'node_modules/dep-cjs': 'commonjs', 34 + }) 35 + // a typeless dependency must not inherit the project's `type: module` 36 + expect(lookupPackageScopeType(join(root, 'node_modules', 'dep-typeless'))).toBe('none') 37 + // but a dependency's own package.json still wins 38 + expect(lookupPackageScopeType(join(root, 'node_modules', 'dep-cjs'))).toBe('cjs') 39 + })
+73 -4
packages/vitest/src/node/resolver.ts
··· 1 + import type { ModuleType } from '../types/general' 1 2 import type { ResolvedConfig, ServerDepsOptions } from './types/config' 2 3 import { existsSync, promises as fsp } from 'node:fs' 3 4 import { isBuiltin } from 'node:module' 4 5 import { pathToFileURL } from 'node:url' 5 6 import { KNOWN_ASSET_RE } from '@vitest/utils/constants' 6 7 import { cleanUrl } from '@vitest/utils/helpers' 7 - import { findNearestPackageData } from '@vitest/utils/resolver' 8 + import { lookupPackageScopeType } from '@vitest/utils/resolver' 8 9 import * as esModuleLexer from 'es-module-lexer' 9 10 import { dirname, extname, join, resolve } from 'pathe' 11 + import { 12 + ssrExportAllKey, 13 + ssrImportKey, 14 + ssrImportMetaKey, 15 + ssrModuleExportsKey, 16 + } from 'vite/module-runner' 10 17 import { isWindows } from '../utils/env' 11 18 12 19 export class VitestResolver { ··· 145 152 146 153 id = id.replace('file:///', '') 147 154 148 - const package_ = findNearestPackageData(dirname(id)) 149 - 150 - if (package_.type === 'module') { 155 + if (lookupPackageScopeType(dirname(id)) === 'esm') { 151 156 return true 152 157 } 153 158 ··· 164 169 catch { 165 170 return false 166 171 } 172 + } 173 + 174 + const ESM_SYNTAX_MARKERS = [ 175 + ssrImportKey, 176 + ssrModuleExportsKey, 177 + ssrExportAllKey, 178 + ssrImportMetaKey, 179 + // TODO: use ssrExportNameKey when Vite 6 support is over 180 + '__vite_ssr_exportName__', 181 + ] 182 + 183 + const CJS_GLOBALS_REFERENCE_RE = /\b(?:module|exports|require|__filename|__dirname)\b/ 184 + 185 + // mirrors the Node.js module detection algorithm: the file extension wins, 186 + // then the `type` field in the package scope, then the presence of 187 + // ESM syntax. the ssr transform always rewrites static imports/exports and 188 + // `import.meta` into `__vite_ssr_` helpers, so the transformed code is 189 + // checked first: it reflects the compiled output (type-only imports are 190 + // already erased), and a module without the markers cannot be an ES module. 191 + // a marker hit is then confirmed against the lexed source because the 192 + // transform preserves comments and strings that can mention the markers. 193 + // dynamic imports never count because they are allowed in CommonJS modules 194 + export async function detectModuleType( 195 + file: string | null, 196 + code: string, 197 + loadSource?: () => Promise<string | null>, 198 + ): Promise<ModuleType> { 199 + if (file) { 200 + const filepath = cleanUrl(file) 201 + const extension = extname(filepath) 202 + if (extension === '.cjs' || extension === '.cts') { 203 + return 'cjs' 204 + } 205 + if (extension === '.mjs' || extension === '.mts') { 206 + return 'esm' 207 + } 208 + const scopeType = lookupPackageScopeType(dirname(filepath)) 209 + if (scopeType !== 'none') { 210 + return scopeType 211 + } 212 + } 213 + if (!ESM_SYNTAX_MARKERS.some(marker => code.includes(marker))) { 214 + return 'cjs' 215 + } 216 + // a false "esm" verdict can only break modules that reference the CommonJS 217 + // variables, so the source is read and lexed only when both signals appear 218 + if (!CJS_GLOBALS_REFERENCE_RE.test(code)) { 219 + return 'esm' 220 + } 221 + const source = loadSource ? await loadSource() : null 222 + if (source != null) { 223 + try { 224 + await esModuleLexer.init 225 + const [, , , hasModuleSyntax] = esModuleLexer.parse(source) 226 + if (!hasModuleSyntax) { 227 + return 'cjs' 228 + } 229 + } 230 + catch { 231 + // the lexer cannot parse TypeScript types or non-JS sources, 232 + // trust the markers 233 + } 234 + } 235 + return 'esm' 167 236 } 168 237 169 238 export async function shouldExternalize(
+1
packages/vitest/src/runtime/config.ts
··· 42 42 _diffOptions?: DiffOptions 43 43 color?: LabelColor 44 44 globals: boolean 45 + injectCjsGlobals: boolean 45 46 base: string | undefined 46 47 snapshotEnvironment?: string 47 48 disableConsoleIntercept: boolean | undefined
+2 -3
packages/vitest/src/runtime/external-executor.ts
··· 6 6 import { isBuiltin } from 'node:module' 7 7 import { fileURLToPath, pathToFileURL } from 'node:url' 8 8 import { isBareImport, splitFileAndPostfix } from '@vitest/utils/helpers' 9 - import { findNearestPackageData } from '@vitest/utils/resolver' 9 + import { lookupPackageScopeType } from '@vitest/utils/resolver' 10 10 import { extname, normalize } from 'pathe' 11 11 import { CommonjsExecutor } from './vm/commonjs-executor' 12 12 import { EsmExecutor } from './vm/esm-executor' ··· 158 158 type = 'wasm' 159 159 } 160 160 else { 161 - const pkgData = findNearestPackageData(normalize(pathUrl)) 162 - type = pkgData.type === 'module' ? 'module' : 'commonjs' 161 + type = lookupPackageScopeType(normalize(pathUrl)) === 'esm' ? 'module' : 'commonjs' 163 162 } 164 163 165 164 return { type, path: pathUrl, url: fileUrl }
+7
packages/vitest/src/types/general.ts
··· 1 + import type { FetchResult } from 'vite/module-runner' 2 + 1 3 export type { ParsedStack, TestError } from '@vitest/utils' 2 4 3 5 export type ArgumentsType<T> = T extends (...args: infer U) => any ? U : never ··· 33 35 url: string 34 36 } 35 37 38 + export type ModuleType = 'cjs' | 'esm' 39 + 40 + export type VitestFetchResult = FetchResult & { moduleType?: ModuleType } 41 + 36 42 export interface FetchCachedFileSystemResult { 37 43 cached: true 38 44 tmp: string ··· 40 46 file: string | null 41 47 url: string 42 48 invalidate: boolean 49 + moduleType?: ModuleType 43 50 } 44 51 45 52 // These need to be compatible with Tinyrainbow's bg-colors, and CSS's background-color
+207
test/e2e/test/config/injectCjsGlobals.test.ts
··· 1 + import { replaceRoot, runInlineTests, ts } from '#test-utils' 2 + import { expect, test } from 'vitest' 3 + 4 + const pools = ['forks', 'vmThreads'] as const 5 + 6 + test.for(pools)('cjs globals are injected by default (%s)', async (pool) => { 7 + const { stderr, exitCode } = await runInlineTests({ 8 + 'basic.test.js': ts` 9 + import { expect, test } from 'vitest' 10 + 11 + test('cjs globals are defined', () => { 12 + expect(typeof module).toBe('object') 13 + expect(typeof exports).toBe('object') 14 + expect(typeof require).toBe('function') 15 + expect(typeof __filename).toBe('string') 16 + expect(typeof __dirname).toBe('string') 17 + }) 18 + `, 19 + }, { pool }) 20 + expect(stderr).toBe('') 21 + expect(exitCode).toBe(0) 22 + }) 23 + 24 + test.for(pools)('cjs globals are not injected into ES modules when injectCjsGlobals is disabled (%s)', async (pool) => { 25 + const { stderr, exitCode } = await runInlineTests({ 26 + 'basic.test.js': ts` 27 + import { expect, test } from 'vitest' 28 + 29 + test('cjs globals are not defined', () => { 30 + expect(typeof module).toBe('undefined') 31 + expect(typeof exports).toBe('undefined') 32 + expect(typeof require).toBe('undefined') 33 + expect(typeof __filename).toBe('undefined') 34 + expect(typeof __dirname).toBe('undefined') 35 + expect(() => __dirname).toThrowError(ReferenceError) 36 + }) 37 + `, 38 + }, { pool, injectCjsGlobals: false }) 39 + expect(stderr).toBe('') 40 + expect(exitCode).toBe(0) 41 + }) 42 + 43 + test.for(pools)('inlined ".cjs" modules keep the module scope when injectCjsGlobals is disabled (%s)', async (pool) => { 44 + const { stderr, exitCode } = await runInlineTests({ 45 + 'cjs-dep.cjs': ts` 46 + module.exports = { 47 + answer: 42, 48 + filename: __filename, 49 + } 50 + `, 51 + 'basic.test.js': ts` 52 + import { expect, test } from 'vitest' 53 + import cjs from './cjs-dep.cjs' 54 + 55 + test('cjs module is evaluated', () => { 56 + expect(cjs.answer).toBe(42) 57 + expect(cjs.filename).toContain('cjs-dep.cjs') 58 + expect(typeof module).toBe('undefined') 59 + }) 60 + `, 61 + }, { pool, injectCjsGlobals: false }) 62 + expect(stderr).toBe('') 63 + expect(exitCode).toBe(0) 64 + }) 65 + 66 + test('".js" modules without ESM syntax are detected as commonjs in a typeless package', async () => { 67 + const { stderr, exitCode } = await runInlineTests({ 68 + 'package.json': '{}', 69 + 'cjs-dep.js': ts` 70 + module.exports = { answer: 42 } 71 + `, 72 + 'basic.test.js': ts` 73 + import { expect, test } from 'vitest' 74 + import cjs from './cjs-dep.js' 75 + 76 + test('cjs module is evaluated, the test file is strict', () => { 77 + expect(cjs.answer).toBe(42) 78 + expect(typeof module).toBe('undefined') 79 + expect(typeof __dirname).toBe('undefined') 80 + }) 81 + `, 82 + }, { injectCjsGlobals: false }) 83 + expect(stderr).toBe('') 84 + expect(exitCode).toBe(0) 85 + }) 86 + 87 + test('"type": "commonjs" in package.json keeps the module scope when injectCjsGlobals is disabled', async () => { 88 + const { stderr, exitCode } = await runInlineTests({ 89 + 'package.json': '{ "type": "commonjs" }', 90 + 'basic.test.js': ts` 91 + import { expect, test } from 'vitest' 92 + 93 + test('cjs globals are defined', () => { 94 + expect(typeof module).toBe('object') 95 + expect(typeof exports).toBe('object') 96 + expect(typeof require).toBe('function') 97 + expect(typeof __filename).toBe('string') 98 + expect(typeof __dirname).toBe('string') 99 + }) 100 + `, 101 + }, { injectCjsGlobals: false }) 102 + expect(stderr).toBe('') 103 + expect(exitCode).toBe(0) 104 + }) 105 + 106 + test('esm markers inside comments do not affect the detection', async () => { 107 + const { stderr, exitCode } = await runInlineTests({ 108 + 'package.json': '{}', 109 + 'cjs-dep.js': ts` 110 + // this module mentions __vite_ssr_import__ and __vite_ssr_exports__ in a comment 111 + module.exports = { answer: 42 } 112 + `, 113 + 'basic.test.js': ts` 114 + // the marker __vite_ssr_import__ in a comment doesn't make a real ES module less strict 115 + import { expect, test } from 'vitest' 116 + import cjs from './cjs-dep.js' 117 + 118 + test('cjs module is evaluated, the test file is strict', () => { 119 + expect(cjs.answer).toBe(42) 120 + expect(typeof module).toBe('undefined') 121 + }) 122 + `, 123 + }, { injectCjsGlobals: false }) 124 + expect(stderr).toBe('') 125 + expect(exitCode).toBe(0) 126 + }) 127 + 128 + test('typescript file with only type imports is detected as commonjs', async () => { 129 + const { stderr, exitCode } = await runInlineTests({ 130 + 'package.json': '{}', 131 + 'cjs-dep.ts': ts` 132 + import type { Stats } from 'node:fs' 133 + 134 + module.exports = { 135 + dirname: __dirname, 136 + isStats: (stats: Stats) => stats instanceof Object, 137 + } 138 + `, 139 + 'basic.test.js': ts` 140 + import { expect, test } from 'vitest' 141 + import cjs from './cjs-dep.ts' 142 + 143 + test('cjs module is evaluated', () => { 144 + expect(typeof cjs.dirname).toBe('string') 145 + expect(cjs.isStats({})).toBe(true) 146 + }) 147 + `, 148 + }, { injectCjsGlobals: false }) 149 + expect(stderr).toBe('') 150 + expect(exitCode).toBe(0) 151 + }) 152 + 153 + test('typeless files inside node_modules do not inherit the project package type', async () => { 154 + const { stderr, exitCode } = await runInlineTests({ 155 + 'package.json': '{ "type": "module" }', 156 + 'node_modules/raw-dep/index.js': ts` 157 + module.exports = { answer: 42 } 158 + `, 159 + 'basic.test.js': ts` 160 + import { expect, test } from 'vitest' 161 + import cjs from './node_modules/raw-dep/index.js' 162 + 163 + test('cjs module is evaluated, the test file is strict', () => { 164 + expect(cjs.answer).toBe(42) 165 + expect(typeof module).toBe('undefined') 166 + }) 167 + `, 168 + }, { 169 + injectCjsGlobals: false, 170 + server: { deps: { inline: [/raw-dep/] } }, 171 + }) 172 + expect(stderr).toBe('') 173 + expect(exitCode).toBe(0) 174 + }) 175 + 176 + test('referencing a cjs variable in an ES module fails with a hint', async () => { 177 + const { stderr, root, exitCode } = await runInlineTests({ 178 + 'basic.test.js': ts` 179 + import { test } from 'vitest' 180 + 181 + const dirname = __dirname 182 + 183 + test('not reported', () => {}) 184 + `, 185 + }, { injectCjsGlobals: false }) 186 + expect(exitCode).toBe(1) 187 + expect(replaceRoot(stderr, root)).toMatchInlineSnapshot(` 188 + " 189 + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ 190 + 191 + FAIL basic.test.js [ basic.test.js ] 192 + ReferenceError: __dirname is not defined 193 + 194 + "__dirname" is a CommonJS variable that is not available in ES modules, and "injectCjsGlobals" is disabled. If this module is meant to be an ES module, use "import.meta.dirname" instead of "__dirname". If it is meant to be a CommonJS module, use the ".cjs" file extension, set "type": "commonjs" in the nearest package.json, or externalize it with "server.deps.external". 195 + ❯ basic.test.js:4:23 196 + 2| import { test } from 'vitest' 197 + 3| 198 + 4| const dirname = __dirname 199 + | ^ 200 + 5| 201 + 6| test('not reported', () => {}) 202 + 203 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ 204 + 205 + " 206 + `) 207 + })
+3
packages/vitest/src/node/cli/cli-config.ts
··· 345 345 globals: { 346 346 description: 'Inject apis globally', 347 347 }, 348 + injectCjsGlobals: { 349 + description: 'Inject CommonJS variables (`module`, `exports`, `require`, `__filename`, `__dirname`) into every test module. To disable, use `--no-inject-cjs-globals` (default: `true`)', 350 + }, 348 351 dom: { 349 352 description: 'Mock browser API with happy-dom', 350 353 },
+1
packages/vitest/src/node/config/serializeConfig.ts
··· 47 47 name: config.name, 48 48 color: config.color, 49 49 globals: config.globals, 50 + injectCjsGlobals: config.injectCjsGlobals, 50 51 snapshotEnvironment: config.snapshotEnvironment, 51 52 passWithNoTests: config.passWithNoTests, 52 53 coverage: ((coverage) => {
+35 -7
packages/vitest/src/node/environments/fetchModule.ts
··· 1 1 import type { Span } from '@opentelemetry/api' 2 - import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, TransformResult } from 'vite' 3 - import type { FetchFunctionOptions } from 'vite/module-runner' 4 - import type { FetchCachedFileSystemResult } from '../../types/general' 2 + import type { DevEnvironment, EnvironmentModuleNode, Rollup, TransformResult } from 'vite' 3 + import type { FetchFunctionOptions, FetchResult } from 'vite/module-runner' 4 + import type { FetchCachedFileSystemResult, VitestFetchResult } from '../../types/general' 5 5 import type { OTELCarrier, Traces } from '../../utils/traces' 6 6 import type { FileSystemModuleCache } from '../cache/fsModuleCache' 7 7 import type { VitestResolver } from '../resolver' ··· 12 12 import { join } from 'pathe' 13 13 import { fetchModule } from 'vite' 14 14 import { hash } from '../hash' 15 + import { detectModuleType } from '../resolver' 15 16 import { normalizeResolvedIdToUrl } from './normalizeUrl' 16 17 17 - const saveCachePromises = new Map<string, Promise<FetchResult>>() 18 + const saveCachePromises = new Map<string, Promise<VitestFetchResult>>() 18 19 const readFilePromises = new Map<string, Promise<string | null>>() 19 20 20 21 class ModuleFetcher { 21 22 private tmpDirectories = new Set<string>() 22 23 private fsCacheEnabled: boolean 24 + // the module type is only needed by the evaluator to decide if CJS 25 + // variables should be provided to the module, so don't waste time 26 + // on the detection when every module receives them 27 + private detectModuleType: boolean 23 28 24 29 constructor( 25 30 private resolver: VitestResolver, ··· 28 33 private tmpProjectDir: string, 29 34 ) { 30 35 this.fsCacheEnabled = config.experimental?.fsModuleCache === true 36 + this.detectModuleType = config.injectCjsGlobals === false 31 37 } 32 38 33 39 async fetch( ··· 233 239 tmp: moduleGraphModule.transformResult.__vitestTmp, 234 240 url: moduleGraphModule.url, 235 241 invalidate: false, 242 + moduleType: this.detectModuleType 243 + ? await detectModuleType( 244 + moduleGraphModule.file, 245 + moduleGraphModule.transformResult.code, 246 + this.sourceLoader(moduleGraphModule.file), 247 + ) 248 + : undefined, 236 249 } 237 250 } 238 251 ··· 281 294 tmp: cachePath, 282 295 url: cachedModule.url, 283 296 invalidate: false, 297 + moduleType: this.detectModuleType 298 + ? await detectModuleType(cachedModule.file, cachedModule.code, this.sourceLoader(cachedModule.file)) 299 + : undefined, 284 300 } 285 301 } 286 302 ··· 290 306 importer: string | undefined, 291 307 moduleGraphModule: EnvironmentModuleNode, 292 308 options?: FetchFunctionOptions, 293 - ): Promise<FetchResult> { 309 + ): Promise<VitestFetchResult> { 294 310 const moduleRunnerModule = await fetchModule( 295 311 environment, 296 312 url, ··· 301 317 }, 302 318 ).catch(handleRollupError) 303 319 304 - return processResultSource(environment, moduleRunnerModule) 320 + const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule) 321 + if (this.detectModuleType && 'code' in result) { 322 + result.moduleType = await detectModuleType(result.file, result.code, this.sourceLoader(result.file)) 323 + } 324 + return result 325 + } 326 + 327 + private sourceLoader(file: string | null): (() => Promise<string | null>) | undefined { 328 + if (!file || file.startsWith('\x00') || file.startsWith('virtual:')) { 329 + return undefined 330 + } 331 + return () => this.readFileConcurrently(file) 305 332 } 306 333 307 334 private async cacheResult( ··· 452 479 return `data:application/json;base64,${Buffer.from(map).toString('base64')}` 453 480 } 454 481 455 - function getCachedResult(result: Extract<FetchResult, { code: string }>, tmp: string): FetchCachedFileSystemResult { 482 + function getCachedResult(result: Extract<VitestFetchResult, { code: string }>, tmp: string): FetchCachedFileSystemResult { 456 483 return { 457 484 cached: true as const, 458 485 file: result.file, ··· 460 487 tmp, 461 488 url: result.url, 462 489 invalidate: result.invalidate, 490 + moduleType: result.moduleType, 463 491 } 464 492 } 465 493
+18
packages/vitest/src/node/types/config.ts
··· 312 312 globals?: boolean 313 313 314 314 /** 315 + * Inject CommonJS module variables (`module`, `exports`, `require`, 316 + * `__filename`, `__dirname`) into every module processed by Vitest. 317 + * 318 + * When disabled, ES modules no longer have access to CommonJS variables, 319 + * matching how the code runs outside of Vitest. Modules detected to be 320 + * CommonJS keep these variables because they are part of the module scope. 321 + * The module type is detected the same way Node.js does it: the file 322 + * extension wins, then the `type` field in the nearest package.json, 323 + * then the presence of ESM syntax in the file. 324 + * 325 + * This option doesn't affect externalized modules which are always 326 + * executed by the native runtime. 327 + * 328 + * @default true 329 + */ 330 + injectCjsGlobals?: boolean 331 + 332 + /** 315 333 * Running environment 316 334 * 317 335 * Supports 'node', 'jsdom', 'happy-dom', 'edge-runtime'
+94 -39
packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts
··· 5 5 ModuleRunnerContext, 6 6 ModuleRunnerImportMeta, 7 7 } from 'vite/module-runner' 8 + import type { VitestFetchResult } from '../../types/general' 8 9 import type { GetterTracker } from '../getter-tracker' 9 10 import type { VitestEvaluatedModules } from './evaluatedModules' 10 11 import type { ModuleExecutionInfo } from './moduleDebug' ··· 28 29 evaluatedModules?: VitestEvaluatedModules 29 30 metaEnv?: ModuleRunnerImportMeta['env'] 30 31 interopDefault?: boolean | undefined 32 + injectCjsGlobals?: boolean | undefined 31 33 moduleExecutionInfo?: ModuleExecutionInfo 32 34 getCurrentTestFilepath?: () => string | undefined 33 35 compiledFunctionArgumentsNames?: string[] ··· 290 292 291 293 span.setAttribute('code.file.path', meta.filename) 292 294 295 + const __vite_ssr_exportName__ = context.__vite_ssr_exportName__ 296 + || ((name: string, getter: () => unknown) => Object.defineProperty(context[ssrModuleExportsKey], name, { 297 + enumerable: true, 298 + configurable: true, 299 + get: getter, 300 + })) 301 + 302 + let __vite_track_exportName__: ((name: string, getter: () => unknown) => void) | undefined 303 + const getterTracker = this.getterTracker 304 + if (getterTracker) { 305 + __vite_track_exportName__ = getterTracker.createTracker(module.id, __vite_ssr_exportName__) 306 + } 307 + 293 308 const argumentsList = [ 294 309 ssrModuleExportsKey, 295 310 ssrImportMetaKey, ··· 300 315 '__vite_ssr_exportName__', 301 316 ] 302 317 303 - const cjsGlobals = this._createCJSGlobals(context, module, span) 304 - argumentsList.push( 305 - // TODO@discuss deprecate in Vitest 5, remove in Vitest 6(?) 306 - // backwards compat for vite-node 307 - // https://github.com/vitest-dev/vitest/issues/10292 308 - '__filename', 309 - '__dirname', 310 - 'module', 311 - 'exports', 312 - 'require', 313 - ) 318 + const argumentsValues: unknown[] = [ 319 + context[ssrModuleExportsKey], 320 + context[ssrImportMetaKey], 321 + context[ssrImportKey], 322 + context[ssrDynamicImportKey], 323 + context[ssrExportAllKey], 324 + __vite_track_exportName__ || __vite_ssr_exportName__, 325 + ] 326 + 327 + // TODO@discuss switch the default in Vitest 6(?) 328 + // backwards compat for vite-node 329 + const injectCjsGlobals = this.options.injectCjsGlobals !== false 330 + // the module type is provided by the server only when `injectCjsGlobals` is disabled. 331 + // CommonJS modules always receive these variables because they are part 332 + // of the module scope, without them the module cannot be evaluated at all 333 + || (module.meta as VitestFetchResult | undefined)?.moduleType === 'cjs' 334 + 335 + if (injectCjsGlobals) { 336 + const cjsGlobals = this._createCJSGlobals(context, module, span) 337 + argumentsList.push( 338 + '__filename', 339 + '__dirname', 340 + 'module', 341 + 'exports', 342 + 'require', 343 + ) 344 + 345 + argumentsValues.push( 346 + cjsGlobals.__filename, 347 + cjsGlobals.__dirname, 348 + cjsGlobals.module, 349 + cjsGlobals.exports, 350 + cjsGlobals.require, 351 + ) 352 + } 314 353 315 354 if (this.compiledFunctionArgumentsNames) { 316 355 argumentsList.push(...this.compiledFunctionArgumentsNames) 356 + } 357 + 358 + if (this.compiledFunctionArgumentsValues) { 359 + argumentsValues.push(...this.compiledFunctionArgumentsValues) 317 360 } 318 361 319 362 span.setAttribute('vitest.module.arguments', argumentsList) ··· 351 394 ? vm.runInContext(wrappedCode, this.vm.context, options) 352 395 : vm.runInThisContext(wrappedCode, options) 353 396 354 - const __vite_ssr_exportName__ = context.__vite_ssr_exportName__ 355 - || ((name: string, getter: () => unknown) => Object.defineProperty(context[ssrModuleExportsKey], name, { 356 - enumerable: true, 357 - configurable: true, 358 - get: getter, 359 - })) 360 - 361 - let __vite_track_exportName__: ((name: string, getter: () => unknown) => void) | undefined 362 - const getterTracker = this.getterTracker 363 - if (getterTracker) { 364 - __vite_track_exportName__ = getterTracker.createTracker(module.id, __vite_ssr_exportName__) 397 + await initModule(...argumentsValues) 398 + } 399 + catch (error: unknown) { 400 + if (!injectCjsGlobals) { 401 + throw enhanceMissingCjsGlobalsError(error) 365 402 } 366 - 367 - await initModule( 368 - context[ssrModuleExportsKey], 369 - context[ssrImportMetaKey], 370 - context[ssrImportKey], 371 - context[ssrDynamicImportKey], 372 - context[ssrExportAllKey], 373 - __vite_track_exportName__ || __vite_ssr_exportName__, 374 - 375 - cjsGlobals.__filename, 376 - cjsGlobals.__dirname, 377 - cjsGlobals.module, 378 - cjsGlobals.exports, 379 - cjsGlobals.require, 380 - 381 - ...this.compiledFunctionArgumentsValues, 382 - ) 403 + throw error 383 404 } 384 405 finally { 385 406 // moduleExecutionInfo needs to use Node filename instead of the normalized one ··· 564 585 } 565 586 566 587 return { mod, defaultExport } 588 + } 589 + 590 + const CJS_GLOBALS_REFERENCE_ERROR_RE = /^(module|exports|require|__filename|__dirname) is not defined$/ 591 + 592 + const ESM_HINTS: Record<string, string> = { 593 + module: 'use "export" declarations instead of "module.exports"', 594 + exports: 'use "export" declarations instead of "exports"', 595 + require: 'use "import" declarations or "createRequire(import.meta.url)" instead of "require"', 596 + __filename: 'use "import.meta.filename" instead of "__filename"', 597 + __dirname: 'use "import.meta.dirname" instead of "__dirname"', 598 + } 599 + 600 + function enhanceMissingCjsGlobalsError(error: unknown): unknown { 601 + if (error == null || typeof error !== 'object') { 602 + return error 603 + } 604 + const referenceError = error as { name?: unknown; message?: unknown; stack?: unknown } 605 + if (referenceError.name !== 'ReferenceError' || typeof referenceError.message !== 'string') { 606 + return error 607 + } 608 + // the message is anchored, so already enhanced errors are not enhanced twice 609 + const name = referenceError.message.match(CJS_GLOBALS_REFERENCE_ERROR_RE)?.[1] 610 + if (!name) { 611 + return error 612 + } 613 + const message = `${referenceError.message}\n\n` 614 + + `"${name}" is a CommonJS variable that is not available in ES modules, and "injectCjsGlobals" is disabled. ` 615 + + `If this module is meant to be an ES module, ${ESM_HINTS[name]}. ` 616 + + `If it is meant to be a CommonJS module, use the ".cjs" file extension, set "type": "commonjs" in the nearest package.json, or externalize it with "server.deps.external".` 617 + if (typeof referenceError.stack === 'string') { 618 + referenceError.stack = referenceError.stack.replace(referenceError.message, message) 619 + } 620 + referenceError.message = message 621 + return error 567 622 } 568 623 569 624 const VALID_ID_PREFIX = `/@id/`
+3
packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts
··· 66 66 get interopDefault() { 67 67 return state().config.deps.interopDefault 68 68 }, 69 + get injectCjsGlobals() { 70 + return state().config.injectCjsGlobals 71 + }, 69 72 getCurrentTestFilepath: () => state().filepath, 70 73 getterTracker: state().getterTracker, 71 74 },