Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ export default ({ mode }: { mode: string }) => {
text: 'globals',
link: '/config/globals',
},
{
text: 'injectCjsGlobals',
link: '/config/injectcjsglobals',
},
{
text: 'environment',
link: '/config/environment',
Expand Down
47 changes: 47 additions & 0 deletions docs/config/injectcjsglobals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: injectCjsGlobals | Config
---

# injectCjsGlobals

- **Type:** `boolean`
- **Default:** `true`
- **CLI:** `--no-inject-cjs-globals`, `--injectCjsGlobals=false`

Inject CommonJS module variables (`module`, `exports`, `require`, `__filename`, `__dirname`) into every module processed by Vitest.

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.

To make the module environment stricter and closer to the target runtime, you can disable this behaviour:

```js
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
injectCjsGlobals: false,
},
})
```

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:

1. The file extension: `.cjs` and `.cts` files are always CommonJS, `.mjs` and `.mts` files are always ES modules.
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.
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.

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.

Referencing a CommonJS variable in an ES module throws a `ReferenceError`, just like outside of Vitest:

```
ReferenceError: __dirname is not defined

"__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".
```

::: warning
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.

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.
:::
7 changes: 7 additions & 0 deletions docs/guide/cli-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,13 @@ Run every test file in isolation. To disable isolation, use `--no-isolate` (defa

Inject apis globally

### injectCjsGlobals

- **CLI:** `--injectCjsGlobals`
- **Config:** [injectCjsGlobals](/config/injectcjsglobals)

Inject CommonJS variables (`module`, `exports`, `require`, `__filename`, `__dirname`) into every test module. To disable, use `--no-inject-cjs-globals` (default: `true`)

### dom

- **CLI:** `--dom`
Expand Down
107 changes: 39 additions & 68 deletions packages/utils/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,54 @@
import fs from 'node:fs'
import { dirname, join } from 'pathe'
import { basename, dirname, join } from 'pathe'

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

export function findNearestPackageData(
basedir: string,
): { type?: 'module' | 'commonjs' } {
const originalBasedir = basedir
while (basedir) {
const cached = getCachedData(packageCache, basedir, originalBasedir)
// mirrors LOOKUP_PACKAGE_SCOPE from the ESM resolution algorithm:
// the lookup stops at the first package.json and never crosses
// the "node_modules" boundary, so typeless dependencies don't
// inherit the `type` field of the user's project
export function lookupPackageScopeType(
directory: string,
): 'cjs' | 'esm' | 'none' {
const visited: string[] = []
let result: 'cjs' | 'esm' | 'none' = 'none'
let current = directory
while (current) {
const cached = packageScopeTypeCache.get(current)
if (cached) {
return cached
result = cached
break
}

const pkgPath = join(basedir, 'package.json')
if (tryStatSync(pkgPath)?.isFile()) {
const pkgData = JSON.parse(stripBomTag(fs.readFileSync(pkgPath, 'utf8')))

if (packageCache) {
setCacheData(packageCache, pkgData, basedir, originalBasedir)
if (basename(current) === 'node_modules') {
break
}
visited.push(current)
const packageJsonPath = join(current, 'package.json')
if (tryStatSync(packageJsonPath)?.isFile()) {
try {
const packageJson = JSON.parse(stripBomTag(fs.readFileSync(packageJsonPath, 'utf8')))
if (packageJson.type === 'module') {
result = 'esm'
}
else if (packageJson.type === 'commonjs') {
result = 'cjs'
}
}

return pkgData
catch {
// ignore malformed package.json and fall back to "none"
}
break
}

const nextBasedir = dirname(basedir)
if (nextBasedir === basedir) {
const parent = dirname(current)
if (parent === current) {
break
}
basedir = nextBasedir
current = parent
}

return {}
visited.forEach(dir => packageScopeTypeCache.set(dir, result))
return result
}

function stripBomTag(content: string): string {
Expand All @@ -51,49 +68,3 @@ function tryStatSync(file: string): fs.Stats | undefined {
// Ignore errors
}
}

export function getCachedData<T>(
cache: Map<string, T>,
basedir: string,
originalBasedir: string,
): NonNullable<T> | undefined {
const pkgData = cache.get(getFnpdCacheKey(basedir))
if (pkgData) {
traverseBetweenDirs(originalBasedir, basedir, (dir) => {
cache.set(getFnpdCacheKey(dir), pkgData)
})
return pkgData
}
}

export function setCacheData<T>(
cache: Map<string, T>,
data: T,
basedir: string,
originalBasedir: string,
): void {
cache.set(getFnpdCacheKey(basedir), data)
traverseBetweenDirs(originalBasedir, basedir, (dir) => {
cache.set(getFnpdCacheKey(dir), data)
})
}

function getFnpdCacheKey(basedir: string) {
return `fnpd_${basedir}`
}

/**
* Traverse between `longerDir` (inclusive) and `shorterDir` (exclusive) and call `cb` for each dir.
* @param longerDir Longer dir path, e.g. `/User/foo/bar/baz`
* @param shorterDir Shorter dir path, e.g. `/User/foo`
*/
function traverseBetweenDirs(
longerDir: string,
shorterDir: string,
cb: (dir: string) => void,
) {
while (longerDir !== shorterDir) {
cb(longerDir)
longerDir = dirname(longerDir)
}
}
2 changes: 2 additions & 0 deletions packages/vitest/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const configDefaults: Readonly<{
isolate: boolean
watch: boolean
globals: boolean
injectCjsGlobals: boolean
environment: 'node'
clearMocks: boolean
restoreMocks: boolean
Expand Down Expand Up @@ -107,6 +108,7 @@ export const configDefaults: Readonly<{
isolate: true,
watch: !isCI && process.stdin.isTTY && !isAgent,
globals: false,
injectCjsGlobals: true,
environment: 'node',
clearMocks: true,
restoreMocks: false,
Expand Down
3 changes: 3 additions & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
globals: {
description: 'Inject apis globally',
},
injectCjsGlobals: {
description: 'Inject CommonJS variables (`module`, `exports`, `require`, `__filename`, `__dirname`) into every test module. To disable, use `--no-inject-cjs-globals` (default: `true`)',
},
dom: {
description: 'Mock browser API with happy-dom',
},
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/config/serializeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
name: config.name,
color: config.color,
globals: config.globals,
injectCjsGlobals: config.injectCjsGlobals,
snapshotEnvironment: config.snapshotEnvironment,
passWithNoTests: config.passWithNoTests,
coverage: ((coverage) => {
Expand Down
42 changes: 35 additions & 7 deletions packages/vitest/src/node/environments/fetchModule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Span } from '@opentelemetry/api'
import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, TransformResult } from 'vite'
import type { FetchFunctionOptions } from 'vite/module-runner'
import type { FetchCachedFileSystemResult } from '../../types/general'
import type { DevEnvironment, EnvironmentModuleNode, Rollup, TransformResult } from 'vite'
import type { FetchFunctionOptions, FetchResult } from 'vite/module-runner'
import type { FetchCachedFileSystemResult, VitestFetchResult } from '../../types/general'
import type { OTELCarrier, Traces } from '../../utils/traces'
import type { FileSystemModuleCache } from '../cache/fsModuleCache'
import type { VitestResolver } from '../resolver'
Expand All @@ -12,14 +12,19 @@ import { isExternalUrl, unwrapId } from '@vitest/utils/helpers'
import { join } from 'pathe'
import { fetchModule } from 'vite'
import { hash } from '../hash'
import { detectModuleType } from '../resolver'
import { normalizeResolvedIdToUrl } from './normalizeUrl'

const saveCachePromises = new Map<string, Promise<FetchResult>>()
const saveCachePromises = new Map<string, Promise<VitestFetchResult>>()
const readFilePromises = new Map<string, Promise<string | null>>()

class ModuleFetcher {
private tmpDirectories = new Set<string>()
private fsCacheEnabled: boolean
// the module type is only needed by the evaluator to decide if CJS
// variables should be provided to the module, so don't waste time
// on the detection when every module receives them
private detectModuleType: boolean

constructor(
private resolver: VitestResolver,
Expand All @@ -28,6 +33,7 @@ class ModuleFetcher {
private tmpProjectDir: string,
) {
this.fsCacheEnabled = config.experimental?.fsModuleCache === true
this.detectModuleType = config.injectCjsGlobals === false
}

async fetch(
Expand Down Expand Up @@ -233,6 +239,13 @@ class ModuleFetcher {
tmp: moduleGraphModule.transformResult.__vitestTmp,
url: moduleGraphModule.url,
invalidate: false,
moduleType: this.detectModuleType
? await detectModuleType(
moduleGraphModule.file,
moduleGraphModule.transformResult.code,
this.sourceLoader(moduleGraphModule.file),
)
: undefined,
}
}

Expand Down Expand Up @@ -281,6 +294,9 @@ class ModuleFetcher {
tmp: cachePath,
url: cachedModule.url,
invalidate: false,
moduleType: this.detectModuleType
? await detectModuleType(cachedModule.file, cachedModule.code, this.sourceLoader(cachedModule.file))
: undefined,
Comment thread
sheremet-va marked this conversation as resolved.
}
}

Expand All @@ -290,7 +306,7 @@ class ModuleFetcher {
importer: string | undefined,
moduleGraphModule: EnvironmentModuleNode,
options?: FetchFunctionOptions,
): Promise<FetchResult> {
): Promise<VitestFetchResult> {
const moduleRunnerModule = await fetchModule(
environment,
url,
Expand All @@ -301,7 +317,18 @@ class ModuleFetcher {
},
).catch(handleRollupError)

return processResultSource(environment, moduleRunnerModule)
const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule)
if (this.detectModuleType && 'code' in result) {
result.moduleType = await detectModuleType(result.file, result.code, this.sourceLoader(result.file))
}
return result
}

private sourceLoader(file: string | null): (() => Promise<string | null>) | undefined {
if (!file || file.startsWith('\x00') || file.startsWith('virtual:')) {
return undefined
}
return () => this.readFileConcurrently(file)
}

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

function getCachedResult(result: Extract<FetchResult, { code: string }>, tmp: string): FetchCachedFileSystemResult {
function getCachedResult(result: Extract<VitestFetchResult, { code: string }>, tmp: string): FetchCachedFileSystemResult {
return {
cached: true as const,
file: result.file,
id: result.id,
tmp,
url: result.url,
invalidate: result.invalidate,
moduleType: result.moduleType,
}
}

Expand Down
Loading
Loading