Skip to content

Commit 826714e

Browse files
authored
feat: add toggable injectCjsGlobals option (#10709)
1 parent 6198fd2 commit 826714e

20 files changed

Lines changed: 591 additions & 122 deletions

File tree

docs/.vitepress/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ export default ({ mode }: { mode: string }) => {
306306
text: 'globals',
307307
link: '/config/globals',
308308
},
309+
{
310+
text: 'injectCjsGlobals',
311+
link: '/config/injectcjsglobals',
312+
},
309313
{
310314
text: 'environment',
311315
link: '/config/environment',

docs/config/injectcjsglobals.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
:::

docs/guide/cli-generated.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,13 @@ Run every test file in isolation. To disable isolation, use `--no-isolate` (defa
327327

328328
Inject apis globally
329329

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+
330337
### dom
331338

332339
- **CLI:** `--dom`

packages/utils/src/resolver.ts

Lines changed: 39 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,54 @@
11
import fs from 'node:fs'
2-
import { dirname, join } from 'pathe'
2+
import { basename, dirname, join } from 'pathe'
33

4-
const packageCache = new Map<string, { type?: 'module' | 'commonjs' }>()
4+
const packageScopeTypeCache = new Map<string, 'cjs' | 'esm' | 'none'>()
55

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)
1218
if (cached) {
13-
return cached
19+
result = cached
20+
break
1421
}
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+
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+
}
2236
}
23-
24-
return pkgData
37+
catch {
38+
// ignore malformed package.json and fall back to "none"
39+
}
40+
break
2541
}
2642

27-
const nextBasedir = dirname(basedir)
28-
if (nextBasedir === basedir) {
43+
const parent = dirname(current)
44+
if (parent === current) {
2945
break
3046
}
31-
basedir = nextBasedir
47+
current = parent
3248
}
3349

34-
return {}
50+
visited.forEach(dir => packageScopeTypeCache.set(dir, result))
51+
return result
3552
}
3653

3754
function stripBomTag(content: string): string {
@@ -51,49 +68,3 @@ function tryStatSync(file: string): fs.Stats | undefined {
5168
// Ignore errors
5269
}
5370
}
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-
}
99-
}

packages/vitest/src/defaults.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const configDefaults: Readonly<{
6868
isolate: boolean
6969
watch: boolean
7070
globals: boolean
71+
injectCjsGlobals: boolean
7172
environment: 'node'
7273
clearMocks: boolean
7374
restoreMocks: boolean
@@ -107,6 +108,7 @@ export const configDefaults: Readonly<{
107108
isolate: true,
108109
watch: !isCI && process.stdin.isTTY && !isAgent,
109110
globals: false,
111+
injectCjsGlobals: true,
110112
environment: 'node',
111113
clearMocks: true,
112114
restoreMocks: false,

packages/vitest/src/node/cli/cli-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
345345
globals: {
346346
description: 'Inject apis globally',
347347
},
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+
},
348351
dom: {
349352
description: 'Mock browser API with happy-dom',
350353
},

packages/vitest/src/node/config/serializeConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
4747
name: config.name,
4848
color: config.color,
4949
globals: config.globals,
50+
injectCjsGlobals: config.injectCjsGlobals,
5051
snapshotEnvironment: config.snapshotEnvironment,
5152
passWithNoTests: config.passWithNoTests,
5253
coverage: ((coverage) => {

packages/vitest/src/node/environments/fetchModule.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
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'
55
import type { OTELCarrier, Traces } from '../../utils/traces'
66
import type { FileSystemModuleCache } from '../cache/fsModuleCache'
77
import type { VitestResolver } from '../resolver'
@@ -12,14 +12,19 @@ import { isExternalUrl, unwrapId } from '@vitest/utils/helpers'
1212
import { join } from 'pathe'
1313
import { fetchModule } from 'vite'
1414
import { hash } from '../hash'
15+
import { detectModuleType } from '../resolver'
1516
import { normalizeResolvedIdToUrl } from './normalizeUrl'
1617

17-
const saveCachePromises = new Map<string, Promise<FetchResult>>()
18+
const saveCachePromises = new Map<string, Promise<VitestFetchResult>>()
1819
const readFilePromises = new Map<string, Promise<string | null>>()
1920

2021
class ModuleFetcher {
2122
private tmpDirectories = new Set<string>()
2223
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
2328

2429
constructor(
2530
private resolver: VitestResolver,
@@ -28,6 +33,7 @@ class ModuleFetcher {
2833
private tmpProjectDir: string,
2934
) {
3035
this.fsCacheEnabled = config.experimental?.fsModuleCache === true
36+
this.detectModuleType = config.injectCjsGlobals === false
3137
}
3238

3339
async fetch(
@@ -233,6 +239,13 @@ class ModuleFetcher {
233239
tmp: moduleGraphModule.transformResult.__vitestTmp,
234240
url: moduleGraphModule.url,
235241
invalidate: false,
242+
moduleType: this.detectModuleType
243+
? await detectModuleType(
244+
moduleGraphModule.file,
245+
moduleGraphModule.transformResult.code,
246+
this.sourceLoader(moduleGraphModule.file),
247+
)
248+
: undefined,
236249
}
237250
}
238251

@@ -281,6 +294,9 @@ class ModuleFetcher {
281294
tmp: cachePath,
282295
url: cachedModule.url,
283296
invalidate: false,
297+
moduleType: this.detectModuleType
298+
? await detectModuleType(cachedModule.file, cachedModule.code, this.sourceLoader(cachedModule.file))
299+
: undefined,
284300
}
285301
}
286302

@@ -290,7 +306,7 @@ class ModuleFetcher {
290306
importer: string | undefined,
291307
moduleGraphModule: EnvironmentModuleNode,
292308
options?: FetchFunctionOptions,
293-
): Promise<FetchResult> {
309+
): Promise<VitestFetchResult> {
294310
const moduleRunnerModule = await fetchModule(
295311
environment,
296312
url,
@@ -301,7 +317,18 @@ class ModuleFetcher {
301317
},
302318
).catch(handleRollupError)
303319

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)
305332
}
306333

307334
private async cacheResult(
@@ -452,14 +479,15 @@ function genSourceMapUrl(map: Rollup.SourceMap | string): string {
452479
return `data:application/json;base64,${Buffer.from(map).toString('base64')}`
453480
}
454481

455-
function getCachedResult(result: Extract<FetchResult, { code: string }>, tmp: string): FetchCachedFileSystemResult {
482+
function getCachedResult(result: Extract<VitestFetchResult, { code: string }>, tmp: string): FetchCachedFileSystemResult {
456483
return {
457484
cached: true as const,
458485
file: result.file,
459486
id: result.id,
460487
tmp,
461488
url: result.url,
462489
invalidate: result.invalidate,
490+
moduleType: result.moduleType,
463491
}
464492
}
465493

0 commit comments

Comments
 (0)