diff --git a/docs/config/experimental.md b/docs/config/experimental.md
index 61797a690cdf..dc2781dc41bb 100644
--- a/docs/config/experimental.md
+++ b/docs/config/experimental.md
@@ -8,7 +8,7 @@ outline: deep
## experimental.fsModuleCache 4.0.11 {#experimental-fsmodulecache}
::: tip FEEDBACK
-Please, leave feedback regarding this feature in a [GitHub Discussion](https://github.com/vitest-dev/vitest/discussions/9221).
+Please leave feedback regarding this feature in a [GitHub Discussion](https://github.com/vitest-dev/vitest/discussions/9221).
:::
- **Type:** `boolean`
@@ -30,9 +30,9 @@ DEBUG=vitest:cache:fs vitest --experimental.fsModuleCache
### Known Issues
-Vitest creates persistent file hash based on file content, its id, vite's environment configuration and coverage status. Vitest tries to use as much information it has about the configuration, but it is still incomplete. At the moment, it is not possible to track your plugin options because there is no standard interface for it.
+Vitest creates a persistent file hash based on file content, its id, Vite's environment configuration and coverage status. Vitest tries to use as much information as it has about the configuration, but it is still incomplete. At the moment, it is not possible to track your plugin options because there is no standard interface for it.
-If you have a plugin that relies on things outside the file content or the public configuration (like reading another file or a folder), it's possible that the cache will get stale. To workaround that, you can define a [cache key generator](/api/advanced/plugin#definecachekeygenerator) to specify dynamic option or to opt-out of caching for that module:
+If you have a plugin that relies on things outside the file content or the public configuration (like reading another file or a folder), it's possible that the cache will get stale. To work around that, you can define a [cache key generator](/api/advanced/plugin#definecachekeygenerator) to specify a dynamic option or to opt out of caching for that module:
```js [vitest.config.js]
import { defineConfig } from 'vitest/config'
@@ -66,7 +66,7 @@ export default defineConfig({
If you are a plugin author, consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) in your plugin if it can be registered with different options that affect the transform result.
-On the other hand, if your plugin should not affect the cache key, you can opt-out by setting `api.vitest.experimental.ignoreFsModuleCache` to `true`:
+On the other hand, if your plugin should not affect the cache key, you can opt out by setting `api.vitest.experimental.ignoreFsModuleCache` to `true`:
```js [vitest.config.js]
import { defineConfig } from 'vitest/config'
@@ -92,7 +92,7 @@ export default defineConfig({
})
```
-Note that you can still define the cache key generator even the plugin opt-out of module caching.
+Note that you can still define the cache key generator even if the plugin opts out of module caching.
## experimental.fsModuleCachePath 4.0.11 {#experimental-fsmodulecachepath}
@@ -108,7 +108,7 @@ At the moment, Vitest ignores the [test.cache.dir](/config/cache) or [cacheDir](
## experimental.openTelemetry 4.0.11 {#experimental-opentelemetry}
::: tip FEEDBACK
-Please, leave feedback regarding this feature in a [GitHub Discussion](https://github.com/vitest-dev/vitest/discussions/9222).
+Please leave feedback regarding this feature in a [GitHub Discussion](https://github.com/vitest-dev/vitest/discussions/9222).
:::
- **Type:**
@@ -177,7 +177,7 @@ It's important that Node can process `sdkPath` content because it is not transfo
## experimental.printImportBreakdown 4.0.15 {#experimental-printimportbreakdown}
::: tip FEEDBACK
-Please, leave feedback regarding this feature in a [GitHub Discussion](https://github.com/vitest-dev/vitest/discussions/9224).
+Please leave feedback regarding this feature in a [GitHub Discussion](https://github.com/vitest-dev/vitest/discussions/9224).
:::
- **Type:** `boolean`
@@ -195,3 +195,126 @@ Note that if the file path is too long, Vitest will truncate it at the start unt
::: info
[Vitest UI](/guide/ui#import-breakdown) shows a breakdown of imports automatically if at least one file took longer than 500 milliseconds to load. You can manually set this option to `false` to disable this.
:::
+
+## experimental.viteModuleRunner 4.0.16 {#experimental-vitemodulerunner}
+
+- **Type:** `boolean`
+- **Default:** `true`
+
+Controls whether Vitest uses Vite's [module runner](https://vite.dev/guide/api-environment-runtimes#modulerunner) to run the code or fallback to the native `import`.
+
+If this option is defined in the root config, all [projects](/guide/projects) will inherit it automatically.
+
+We recommend disabling the module runner if you are running tests in the same environment as your code (server backend or simple scripts, for example). However, we still recommend running `jsdom`/`happy-dom` tests with the module runner or in [the browser](/guide/browser/) since it doesn't require any additional configuration.
+
+Disabling this flag will disable _all_ file transforms:
+
+- test files and your source code are not processed by Vite
+- your global setup files are not processed
+- your custom runner/pool/environment files are not processed
+- your config file is still processed by Vite (this happens before Vitest knows the `viteModuleRunner` flag)
+
+::: warning
+At the moment, Vitest still requires Vite for certain functionality like the module graph or watch mode.
+:::
+
+### Module Runner
+
+By default, Vitest runs tests in a very permissive module runner sandbox powered by Vite's [Environment API](https://vite.dev/guide/api-environment.html#environment-api). Every file is categorized as either an "inline" module or an "external" module.
+
+Module runner runs all "inline" modules. It provides `import.meta.env`, `require`, `__dirname`, `__filename`, static `import`, and has its own module resolution mechanism. This makes it very easy to run code when you don't want to configure the environment and just need to test that the bare JavaScript logic you wrote works as intended.
+
+All "external" modules run in native mode, meaning they are executed outside of the module runner sandbox. If you are running tests in Node.js, these files are imported with the native `import` keyword and processed by Node.js directly.
+
+While running JSDOM/happy-dom tests in a permissive fake environment might be justified, running Node.js tests in a non-Node.js environment is counter-productive as it can hide and silence potential errors you may encounter in production, especially if your code doesn't require any additional transformations provided by Vite plugins.
+
+### Known Limitations
+
+Some Vitest features rely on files being transformed. Vitest uses synchronous [Node.js Loaders API](https://nodejs.org/api/module.html#customization-hooks) to transform test files and setup files to support these features:
+
+- [`import.meta.vitest`](/guide/in-source)
+- [`vi.mock`](/api/vi#vi-mock)
+- [`vi.hoisted`](/api/vi#vi-hoisted)
+
+::: warning
+This means that Vitest requires at least Node 22.15 for those features to work. At the moment, they also do not work in Deno or Bun.
+:::
+
+This could affect performance because Vitest needs to read the file and process it. If you do not use these features, you can disable the transforms by setting `experimental.nodeLoader` to `false`. Vitest only reads test files and setup files while looking for `vi.mock` or `vi.hoisted`. Using these in other files won't hoist them to the top of the file and can lead to unexpected behavior.
+
+Some features will not work due to the nature of `viteModuleRunner`, including:
+
+- no `import.meta.env`: `import.meta.env` is a Vite feature, use `process.env` instead
+- no `plugins`: plugins are not applied because there is no transformation phase
+- no `alias`: aliases are not applied because there is no transformation phase
+
+With regards to mocking, it is also important to point out that ES modules do not support property override. This means that code like this won't work anymore:
+
+```ts
+import * as module from './some-module.js'
+import { vi } from 'vitest'
+
+vi.spyOn(module, 'function').mockImplementation(() => 42)
+```
+
+However, Vitest supports auto-spying on modules without overriding their implementation. When `vi.mock` is called with a `spy: true` argument, the module is mocked in a way that preserves original implementations, but all exported functions are wrapped in a `vi.fn()` spy:
+
+```ts
+import * as module from './some-module.js'
+import { vi } from 'vitest'
+
+vi.mock('./some-module.js', { spy: true })
+
+module.function.mockImplementation(() => 42)
+```
+
+### TypeScript
+
+If you are using Node.js 22.18/23.6 or higher, TypeScript will be [transformed natively](https://nodejs.org/en/learn/typescript/run-natively) by Node.js.
+
+::: warning TypeScript with Node.js 22.6-22.18
+If you are using Node.js version between 22.6 and 22.18, you can also enable native TypeScript support via `--experimental-strip-types` flag:
+
+```shell
+NODE_OPTIONS="--experimental-strip-types" vitest
+```
+
+Note that Node.js will print an experimental warning for every test file; you can silence the warning by providing `--no-warnings` flag:
+
+```shell
+NODE_OPTIONS="--experimental-strip-types --no-warnings" vitest
+```
+:::
+
+If you are using TypeScript and Node.js version lower than 22.6, then you will need to either:
+
+- build your test files and source code and run those files directly
+- import a [custom loader](https://nodejs.org/api/module.html#customization-hooks) via `execArgv` flag
+
+```ts
+import { defineConfig } from 'vitest/config'
+
+const tsxApi = import.meta.resolve('tsx/esm/api')
+
+export default defineConfig({
+ test: {
+ execArgv: [
+ `--import=data:text/javascript,import * as tsx from "${tsxApi}";tsx.register()`,
+ ],
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+})
+```
+
+If you are running tests in Deno, TypeScript files are processed by the runtime without any additional configurations.
+
+## experimental.nodeLoader 4.0.16 {#experimental-nodeloader}
+
+- **Type:** `boolean`
+- **Default:** `true`
+
+If module runner is disabled, Vitest uses a module loader to transform files to support `import.meta.vitest`, `vi.mock` and `vi.hoisted`.
+
+If you don't use these features, you can disable this.
diff --git a/packages/mocker/package.json b/packages/mocker/package.json
index 21b694c599fa..40848d7300c2 100644
--- a/packages/mocker/package.json
+++ b/packages/mocker/package.json
@@ -44,7 +44,11 @@
"types": "./dist/register.d.ts",
"default": "./dist/register.js"
},
- "./*": "./*"
+ "./transforms": {
+ "types": "./dist/transforms.d.ts",
+ "default": "./dist/transforms.js"
+ },
+ "./package.json": "./package.json"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -79,6 +83,8 @@
"@vitest/spy": "workspace:*",
"@vitest/utils": "workspace:*",
"acorn-walk": "catalog:",
+ "cjs-module-lexer": "^2.1.1",
+ "es-module-lexer": "^2.0.0",
"msw": "catalog:",
"pathe": "catalog:",
"vite": "^6.3.5"
diff --git a/packages/mocker/rollup.config.js b/packages/mocker/rollup.config.js
index 034c5c15283e..937cc3015d4b 100644
--- a/packages/mocker/rollup.config.js
+++ b/packages/mocker/rollup.config.js
@@ -17,6 +17,7 @@ const entries = {
'browser': 'src/browser/index.ts',
'register': 'src/browser/register.ts',
'auto-register': 'src/browser/auto-register.ts',
+ 'transforms': 'src/node/transforms.ts',
}
const external = [
diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts
index 9483e1287ead..80673d4376c7 100644
--- a/packages/mocker/src/browser/mocker.ts
+++ b/packages/mocker/src/browser/mocker.ts
@@ -1,6 +1,6 @@
import type { CreateMockInstanceProcedure } from '../automocker'
import type { MockedModule, MockedModuleType } from '../registry'
-import type { ModuleMockOptions } from '../types'
+import type { ModuleMockContext, ModuleMockOptions, TestModuleMocker } from '../types'
import type { ModuleMockerInterceptor } from './interceptor'
import { extname, join } from 'pathe'
import { mockObject } from '../automocker'
@@ -8,7 +8,7 @@ import { AutomockedModule, MockerRegistry, RedirectedModule } from '../registry'
const { now } = Date
-export class ModuleMocker {
+export class ModuleMocker implements TestModuleMocker {
protected registry: MockerRegistry = new MockerRegistry()
private queue = new Set>()
@@ -66,7 +66,7 @@ export class ModuleMocker {
)
}
const ext = extname(resolved.id)
- const url = new URL(resolved.url, location.href)
+ const url = new URL(resolved.url, this.getBaseUrl())
const query = `_vitest_original&ext${ext}`
const actualUrl = `${url.pathname}${
url.search ? `${url.search}&${query}` : `?${query}`
@@ -81,6 +81,10 @@ export class ModuleMocker {
})
}
+ protected getBaseUrl(): string {
+ return location.href
+ }
+
public async importMock(rawId: string, importer: string): Promise {
await this.prepare()
const { resolvedId, resolvedUrl, redirectUrl } = await this.rpc.resolveMock(
@@ -94,7 +98,7 @@ export class ModuleMocker {
if (!mock) {
if (redirectUrl) {
- const resolvedRedirect = new URL(this.resolveMockPath(cleanVersion(redirectUrl)), location.href).toString()
+ const resolvedRedirect = new URL(this.resolveMockPath(cleanVersion(redirectUrl)), this.getBaseUrl()).toString()
mock = new RedirectedModule(rawId, resolvedId, mockUrl, resolvedRedirect)
}
else {
@@ -107,10 +111,10 @@ export class ModuleMocker {
}
if (mock.type === 'automock' || mock.type === 'autospy') {
- const url = new URL(`/@id/${resolvedId}`, location.href)
+ const url = new URL(`/@id/${resolvedId}`, this.getBaseUrl())
const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}`
const moduleObject = await import(/* @vite-ignore */ `${url.pathname}${query}&mock=${mock.type}${url.hash}`)
- return this.mockObject(moduleObject, mock.type) as T
+ return this.mockObject(moduleObject, undefined, mock.type) as T
}
return import(/* @vite-ignore */ mock.redirect)
@@ -118,19 +122,31 @@ export class ModuleMocker {
public mockObject(
object: Record,
+ mockExports: Record | undefined,
moduleType: 'automock' | 'autospy' = 'automock',
): Record {
- return mockObject({
- globalConstructors: {
- Object,
- Function,
- Array,
- Map,
- RegExp,
+ const result = mockObject(
+ {
+ globalConstructors: {
+ Object,
+ Function,
+ Array,
+ Map,
+ RegExp,
+ },
+ createMockInstance: this.createMockInstance,
+ type: moduleType,
},
- createMockInstance: this.createMockInstance,
- type: moduleType,
- }, object)
+ object,
+ mockExports,
+ )
+ return result
+ }
+
+ public getMockContext(): ModuleMockContext {
+ return {
+ callstack: null,
+ }
}
public queueMock(rawId: string, importer: string, factoryOrOptions?: ModuleMockOptions | (() => any)): void {
@@ -153,7 +169,7 @@ export class ModuleMocker {
: undefined
const mockRedirect = typeof redirectUrl === 'string'
- ? new URL(this.resolveMockPath(cleanVersion(redirectUrl)), location.href).toString()
+ ? new URL(this.resolveMockPath(cleanVersion(redirectUrl)), this.getBaseUrl()).toString()
: null
let module: MockedModule
@@ -211,6 +227,16 @@ export class ModuleMocker {
return moduleFactory
}
+ public getMockedModuleById(id: string): MockedModule | undefined {
+ return this.registry.getById(id)
+ }
+
+ public reset(): void {
+ this.registry.clear()
+ this.mockedIds.clear()
+ this.queue.clear()
+ }
+
private resolveMockPath(path: string) {
const config = this.config
const fsRoot = join('/@fs/', config.root)
diff --git a/packages/mocker/src/index.ts b/packages/mocker/src/index.ts
index 3909b5a55e25..ddf9b4174967 100644
--- a/packages/mocker/src/index.ts
+++ b/packages/mocker/src/index.ts
@@ -19,9 +19,11 @@ export type {
} from './registry'
export type {
+ ModuleMockContext,
ModuleMockFactory,
ModuleMockFactoryWithHelper,
ModuleMockOptions,
ServerIdResolution,
ServerMockResolution,
+ TestModuleMocker,
} from './types'
diff --git a/packages/mocker/src/node/automock.ts b/packages/mocker/src/node/automock.ts
index 8d0d79c95463..95e9bfc5bcbd 100644
--- a/packages/mocker/src/node/automock.ts
+++ b/packages/mocker/src/node/automock.ts
@@ -1,14 +1,16 @@
-import type { Declaration, ExportDefaultDeclaration, ExportNamedDeclaration, Expression, Pattern, Positioned, Program } from './esmWalker'
+import type { Declaration, ExportAllDeclaration, ExportDefaultDeclaration, ExportNamedDeclaration, Expression, Pattern, Positioned, Program } from './esmWalker'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath, pathToFileURL } from 'node:url'
import MagicString from 'magic-string'
-import {
- getArbitraryModuleIdentifier,
-} from './esmWalker'
+import { getArbitraryModuleIdentifier } from './esmWalker'
+import { collectModuleExports, resolveModuleFormat, transformCode } from './parsers'
export interface AutomockOptions {
/**
* @default "__vitest_mocker__"
*/
globalThisAccessor?: string
+ id?: string
}
// TODO: better source map replacement
@@ -19,17 +21,57 @@ export function automockModule(
options: AutomockOptions = {},
): MagicString {
const globalThisAccessor = options.globalThisAccessor || '"__vitest_mocker__"'
- const ast = parse(code) as Program
+ let ast: Program
+ try {
+ ast = parse(code) as Program
+ }
+ catch (cause) {
+ if (options.id) {
+ throw new Error(`failed to parse ${options.id}`, { cause })
+ }
+ throw cause
+ }
const m = new MagicString(code)
const allSpecifiers: { name: string; alias?: string }[] = []
+ const replacers: (() => void)[] = []
let importIndex = 0
for (const _node of ast.body) {
if (_node.type === 'ExportAllDeclaration') {
- throw new Error(
- `automocking files with \`export *\` is not supported in browser mode because it cannot be statically analysed`,
- )
+ const node = _node as Positioned
+ // TODO: pass it down in the browser mode
+ if (!options.id) {
+ throw new Error(
+ `automocking files with \`export *\` is not supported because it cannot be easily statically analysed`,
+ )
+ }
+
+ const source = node.source.value
+ if (typeof source !== 'string') {
+ throw new TypeError(`unknown source type while automocking: ${source}`)
+ }
+
+ const moduleUrl = import.meta.resolve(source, pathToFileURL(options.id).toString())
+ const modulePath = fileURLToPath(moduleUrl)
+ const moduleContent = readFileSync(modulePath, 'utf-8')
+ const transformedCode = transformCode(moduleContent, moduleUrl)
+ const moduleFormat = resolveModuleFormat(moduleUrl, transformedCode)
+ const moduleExports = collectModuleExports(modulePath, transformedCode, moduleFormat || 'module')
+ replacers.push(() => {
+ const importNames: string[] = []
+ moduleExports.forEach((exportName) => {
+ const isReexported = allSpecifiers.some(({ name, alias }) => name === exportName || alias === exportName)
+ if (!isReexported) {
+ importNames.push(exportName)
+ allSpecifiers.push({ name: exportName })
+ }
+ })
+
+ const importString = `import { ${importNames.join(', ')} } from '${source}';`
+
+ m.overwrite(node.start, node.end, importString)
+ })
}
if (_node.type === 'ExportNamedDeclaration') {
@@ -143,12 +185,13 @@ export function automockModule(
m.overwrite(node.start, declaration.start, `const __vitest_default = `)
}
}
+ replacers.forEach(cb => cb())
const moduleObject = `
const __vitest_current_es_module__ = {
__esModule: true,
${allSpecifiers.map(({ name }) => `["${name}"]: ${name},`).join('\n ')}
}
-const __vitest_mocked_module__ = globalThis[${globalThisAccessor}].mockObject(__vitest_current_es_module__, "${mockType}")
+const __vitest_mocked_module__ = globalThis[${globalThisAccessor}].mockObject(__vitest_current_es_module__, undefined, "${mockType}")
`
const assigning = allSpecifiers
.map(({ name }, index) => {
diff --git a/packages/mocker/src/node/dynamicImportPlugin.ts b/packages/mocker/src/node/dynamicImportPlugin.ts
index 5d48d5e0d678..dd236ec7b6a7 100644
--- a/packages/mocker/src/node/dynamicImportPlugin.ts
+++ b/packages/mocker/src/node/dynamicImportPlugin.ts
@@ -42,6 +42,10 @@ export function injectDynamicImport(
parse: Rollup.PluginContext['parse'],
options: DynamicImportPluginOptions = {},
): DynamicImportInjectorResult | undefined {
+ if (code.includes('wrapDynamicImport')) {
+ return
+ }
+
const s = new MagicString(code)
let ast: ReturnType
diff --git a/packages/mocker/src/node/hoistMocks.ts b/packages/mocker/src/node/hoistMocks.ts
new file mode 100644
index 000000000000..1a2120fda18c
--- /dev/null
+++ b/packages/mocker/src/node/hoistMocks.ts
@@ -0,0 +1,523 @@
+import type {
+ AwaitExpression,
+ CallExpression,
+ ExportDefaultDeclaration,
+ ExportNamedDeclaration,
+ Expression,
+ Identifier,
+ ImportDeclaration,
+ VariableDeclaration,
+} from 'estree'
+import type { Node, Positioned } from './esmWalker'
+import { findNodeAround } from 'acorn-walk'
+import MagicString from 'magic-string'
+import { esmWalker } from './esmWalker'
+
+export interface HoistMocksOptions {
+ /**
+ * List of modules that should always be imported before compiler hints.
+ * @default 'vitest'
+ */
+ hoistedModule?: string
+ /**
+ * @default ["vi", "vitest"]
+ */
+ utilsObjectNames?: string[]
+ /**
+ * @default ["mock", "unmock"]
+ */
+ hoistableMockMethodNames?: string[]
+ /**
+ * @default ["mock", "unmock", "doMock", "doUnmock"]
+ */
+ dynamicImportMockMethodNames?: string[]
+ /**
+ * @default ["hoisted"]
+ */
+ hoistedMethodNames?: string[]
+ // @experimental, TODO
+ globalThisAccessor?: string
+ regexpHoistable?: RegExp
+ codeFrameGenerator?: CodeFrameGenerator
+ magicString?: () => MagicString
+}
+
+const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API.
+You may encounter this issue when importing the mocks API from another module other than 'vitest'.
+To fix this issue you can either:
+- import the mocks API directly from 'vitest'
+- enable the 'globals' options`
+
+function API_NOT_FOUND_CHECK(names: string[]) {
+ return `\nif (${names.map(name => `typeof globalThis["${name}"] === "undefined"`).join(' && ')}) `
+ + `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n`
+}
+
+function isIdentifier(node: any): node is Positioned {
+ return node.type === 'Identifier'
+}
+
+function getNodeTail(code: string, node: Node) {
+ let end = node.end
+ if (code[node.end] === ';') {
+ end += 1
+ }
+ if (code[node.end] === '\n') {
+ return end + 1
+ }
+ if (code[node.end + 1] === '\n') {
+ end += 1
+ }
+ return end
+}
+
+const regexpHoistable
+ = /\b(?:vi|vitest)\s*\.\s*(?:mock|unmock|hoisted|doMock|doUnmock)\s*\(/
+const hashbangRE = /^#!.*\n/
+
+// this is a fork of Vite SSR transform
+export function hoistMocks(
+ code: string,
+ id: string,
+ parse: (code: string) => any,
+ options: HoistMocksOptions = {},
+): MagicString | undefined {
+ const needHoisting = (options.regexpHoistable || regexpHoistable).test(code)
+
+ if (!needHoisting) {
+ return
+ }
+
+ const s = options.magicString?.() || new MagicString(code)
+
+ let ast: any
+ try {
+ ast = parse(code)
+ }
+ catch (err) {
+ console.error(`Cannot parse ${id}:\n${(err as any).message}.`)
+ return
+ }
+
+ const {
+ hoistableMockMethodNames = ['mock', 'unmock'],
+ dynamicImportMockMethodNames = ['mock', 'unmock', 'doMock', 'doUnmock'],
+ hoistedMethodNames = ['hoisted'],
+ utilsObjectNames = ['vi', 'vitest'],
+ hoistedModule = 'vitest',
+ } = options
+
+ // hoist at the start of the file, after the hashbang
+ let hoistIndex = hashbangRE.exec(code)?.[0].length ?? 0
+
+ let hoistedModuleImported = false
+
+ let uid = 0
+ const idToImportMap = new Map()
+
+ const imports: {
+ node: Positioned
+ id: string
+ }[] = []
+
+ // this will transform import statements into dynamic ones, if there are imports
+ // it will keep the import as is, if we don't need to mock anything
+ // in browser environment it will wrap the module value with "vitest_wrap_module" function
+ // that returns a proxy to the module so that named exports can be mocked
+ function defineImport(
+ importNode: ImportDeclaration & {
+ start: number
+ end: number
+ },
+ ) {
+ const source = importNode.source.value as string
+ // always hoist vitest import to top of the file, so
+ // "vi" helpers can access it
+ if (hoistedModule === source) {
+ hoistedModuleImported = true
+ return
+ }
+ const importId = `__vi_import_${uid++}__`
+ imports.push({ id: importId, node: importNode })
+
+ return importId
+ }
+
+ // 1. check all import statements and record id -> importName map
+ for (const node of ast.body as Node[]) {
+ // import foo from 'foo' --> foo -> __import_foo__.default
+ // import { baz } from 'foo' --> baz -> __import_foo__.baz
+ // import * as ok from 'foo' --> ok -> __import_foo__
+ if (node.type === 'ImportDeclaration') {
+ const importId = defineImport(node)
+ if (!importId) {
+ continue
+ }
+ for (const spec of node.specifiers) {
+ if (spec.type === 'ImportSpecifier') {
+ if (spec.imported.type === 'Identifier') {
+ idToImportMap.set(
+ spec.local.name,
+ `${importId}.${spec.imported.name}`,
+ )
+ }
+ else {
+ idToImportMap.set(
+ spec.local.name,
+ `${importId}[${JSON.stringify(spec.imported.value as string)}]`,
+ )
+ }
+ }
+ else if (spec.type === 'ImportDefaultSpecifier') {
+ idToImportMap.set(spec.local.name, `${importId}.default`)
+ }
+ else {
+ // namespace specifier
+ idToImportMap.set(spec.local.name, importId)
+ }
+ }
+ }
+ }
+
+ const declaredConst = new Set()
+ const hoistedNodes: Positioned<
+ CallExpression | VariableDeclaration | AwaitExpression
+ >[] = []
+
+ function createSyntaxError(node: Positioned, message: string) {
+ const _error = new SyntaxError(message)
+ Error.captureStackTrace(_error, createSyntaxError)
+ const serializedError: any = {
+ name: 'SyntaxError',
+ message: _error.message,
+ stack: _error.stack,
+ }
+ if (options.codeFrameGenerator) {
+ serializedError.frame = options.codeFrameGenerator(node, id, code)
+ }
+ return serializedError
+ }
+
+ function assertNotDefaultExport(
+ node: Positioned,
+ error: string,
+ ) {
+ const defaultExport = findNodeAround(
+ ast,
+ node.start,
+ 'ExportDefaultDeclaration',
+ )?.node as Positioned | undefined
+ if (
+ defaultExport?.declaration === node
+ || (defaultExport?.declaration.type === 'AwaitExpression'
+ && defaultExport.declaration.argument === node)
+ ) {
+ throw createSyntaxError(defaultExport, error)
+ }
+ }
+
+ function assertNotNamedExport(
+ node: Positioned,
+ error: string,
+ ) {
+ const nodeExported = findNodeAround(
+ ast,
+ node.start,
+ 'ExportNamedDeclaration',
+ )?.node as Positioned | undefined
+ if (nodeExported?.declaration === node) {
+ throw createSyntaxError(nodeExported, error)
+ }
+ }
+
+ function getVariableDeclaration(node: Positioned) {
+ const declarationNode = findNodeAround(
+ ast,
+ node.start,
+ 'VariableDeclaration',
+ )?.node as Positioned | undefined
+ const init = declarationNode?.declarations[0]?.init
+ if (
+ init
+ && (init === node
+ || (init.type === 'AwaitExpression' && init.argument === node))
+ ) {
+ return declarationNode
+ }
+ }
+
+ const usedUtilityExports = new Set()
+
+ esmWalker(ast, {
+ onIdentifier(id, info, parentStack) {
+ const binding = idToImportMap.get(id.name)
+ if (!binding) {
+ return
+ }
+
+ if (info.hasBindingShortcut) {
+ s.appendLeft(id.end, `: ${binding}`)
+ }
+ else if (info.classDeclaration) {
+ if (!declaredConst.has(id.name)) {
+ declaredConst.add(id.name)
+ // locate the top-most node containing the class declaration
+ const topNode = parentStack[parentStack.length - 2]
+ s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`)
+ }
+ }
+ else if (
+ // don't transform class name identifier
+ !info.classExpression
+ ) {
+ s.update(id.start, id.end, binding)
+ }
+ },
+ onDynamicImport(_node) {
+ // TODO: vi.mock(import) breaks it, and vi.mock('', () => import) also does,
+ // only move imports that are outside of vi.mock
+ // backwards compat, don't do if not passed
+ // if (!options.globalThisAccessor) {
+ // return
+ // }
+
+ // const globalThisAccessor = options.globalThisAccessor
+ // const replaceString = `globalThis[${globalThisAccessor}].wrapDynamicImport(() => import(`
+ // const importSubstring = code.substring(node.start, node.end)
+ // const hasIgnore = importSubstring.includes('/* @vite-ignore */')
+ // s.overwrite(
+ // node.start,
+ // (node.source as Positioned).start,
+ // replaceString + (hasIgnore ? '/* @vite-ignore */ ' : ''),
+ // )
+ // s.overwrite(node.end - 1, node.end, '))')
+ },
+ onCallExpression(node) {
+ if (
+ node.callee.type === 'MemberExpression'
+ && isIdentifier(node.callee.object)
+ && utilsObjectNames.includes(node.callee.object.name)
+ && isIdentifier(node.callee.property)
+ ) {
+ const methodName = node.callee.property.name
+ usedUtilityExports.add(node.callee.object.name)
+
+ if (hoistableMockMethodNames.includes(methodName)) {
+ const method = `${node.callee.object.name}.${methodName}`
+ assertNotDefaultExport(
+ node,
+ `Cannot export the result of "${method}". Remove export declaration because "${method}" doesn\'t return anything.`,
+ )
+ const declarationNode = getVariableDeclaration(node)
+ if (declarationNode) {
+ assertNotNamedExport(
+ declarationNode,
+ `Cannot export the result of "${method}". Remove export declaration because "${method}" doesn\'t return anything.`,
+ )
+ }
+ // rewrite vi.mock(import('..')) into vi.mock('..')
+ if (
+ node.type === 'CallExpression'
+ && node.callee.type === 'MemberExpression'
+ && dynamicImportMockMethodNames.includes((node.callee.property as Identifier).name)
+ ) {
+ const moduleInfo = node.arguments[0] as Positioned
+ // vi.mock(import('./path')) -> vi.mock('./path')
+ if (moduleInfo.type === 'ImportExpression') {
+ const source = moduleInfo.source as Positioned
+ s.overwrite(
+ moduleInfo.start,
+ moduleInfo.end,
+ s.slice(source.start, source.end),
+ )
+ }
+ // vi.mock(await import('./path')) -> vi.mock('./path')
+ if (
+ moduleInfo.type === 'AwaitExpression'
+ && moduleInfo.argument.type === 'ImportExpression'
+ ) {
+ const source = moduleInfo.argument.source as Positioned
+ s.overwrite(
+ moduleInfo.start,
+ moduleInfo.end,
+ s.slice(source.start, source.end),
+ )
+ }
+ }
+ hoistedNodes.push(node)
+ }
+ // vi.doMock(import('./path')) -> vi.doMock('./path')
+ // vi.doMock(await import('./path')) -> vi.doMock('./path')
+ else if (dynamicImportMockMethodNames.includes(methodName)) {
+ const moduleInfo = node.arguments[0] as Positioned
+ let source: Positioned | null = null
+ if (moduleInfo.type === 'ImportExpression') {
+ source = moduleInfo.source as Positioned
+ }
+ if (
+ moduleInfo.type === 'AwaitExpression'
+ && moduleInfo.argument.type === 'ImportExpression'
+ ) {
+ source = moduleInfo.argument.source as Positioned
+ }
+ if (source) {
+ s.overwrite(
+ moduleInfo.start,
+ moduleInfo.end,
+ s.slice(source.start, source.end),
+ )
+ }
+ }
+
+ if (hoistedMethodNames.includes(methodName)) {
+ assertNotDefaultExport(
+ node,
+ 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.',
+ )
+
+ const declarationNode = getVariableDeclaration(node)
+ if (declarationNode) {
+ assertNotNamedExport(
+ declarationNode,
+ 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.',
+ )
+ // hoist "const variable = vi.hoisted(() => {})"
+ hoistedNodes.push(declarationNode)
+ }
+ else {
+ const awaitedExpression = findNodeAround(
+ ast,
+ node.start,
+ 'AwaitExpression',
+ )?.node as Positioned | undefined
+ // hoist "await vi.hoisted(async () => {})" or "vi.hoisted(() => {})"
+ const moveNode = awaitedExpression?.argument === node ? awaitedExpression : node
+ hoistedNodes.push(moveNode)
+ }
+ }
+ }
+ },
+ })
+
+ function getNodeName(node: CallExpression) {
+ const callee = node.callee || {}
+ if (
+ callee.type === 'MemberExpression'
+ && isIdentifier(callee.property)
+ && isIdentifier(callee.object)
+ ) {
+ return `${callee.object.name}.${callee.property.name}()`
+ }
+ return '"hoisted method"'
+ }
+
+ function getNodeCall(node: Node): Positioned {
+ if (node.type === 'CallExpression') {
+ return node
+ }
+ if (node.type === 'VariableDeclaration') {
+ const { declarations } = node
+ const init = declarations[0].init
+ if (init) {
+ return getNodeCall(init as Node)
+ }
+ }
+ if (node.type === 'AwaitExpression') {
+ const { argument } = node
+ if (argument.type === 'CallExpression') {
+ return getNodeCall(argument as Node)
+ }
+ }
+ return node as Positioned
+ }
+
+ function createError(outsideNode: Node, insideNode: Node) {
+ const outsideCall = getNodeCall(outsideNode)
+ const insideCall = getNodeCall(insideNode)
+ throw createSyntaxError(
+ insideCall,
+ `Cannot call ${getNodeName(insideCall)} inside ${getNodeName(
+ outsideCall,
+ )}: both methods are hoisted to the top of the file and not actually called inside each other.`,
+ )
+ }
+
+ // validate hoistedNodes doesn't have nodes inside other nodes
+ for (let i = 0; i < hoistedNodes.length; i++) {
+ const node = hoistedNodes[i]
+ for (let j = i + 1; j < hoistedNodes.length; j++) {
+ const otherNode = hoistedNodes[j]
+
+ if (node.start >= otherNode.start && node.end <= otherNode.end) {
+ throw createError(otherNode, node)
+ }
+ if (otherNode.start >= node.start && otherNode.end <= node.end) {
+ throw createError(node, otherNode)
+ }
+ }
+ }
+
+ // hoist vi.mock/vi.hoisted
+ for (const node of hoistedNodes) {
+ const end = getNodeTail(code, node)
+ // don't hoist into itself if it's already at the top
+ if (hoistIndex === end || hoistIndex === node.start) {
+ hoistIndex = end
+ }
+ else {
+ s.move(node.start, end, hoistIndex)
+ }
+ }
+
+ // hoist actual dynamic imports last so they are inserted after all hoisted mocks
+ for (const { node: importNode, id: importId } of imports) {
+ const source = importNode.source.value as string
+
+ const sourceString = JSON.stringify(source)
+ let importLine = `const ${importId} = await `
+ if (options.globalThisAccessor) {
+ importLine += `globalThis[${options.globalThisAccessor}].wrapDynamicImport(() => import(${sourceString}));\n`
+ }
+ else {
+ importLine += `import(${sourceString});\n`
+ }
+
+ s.update(
+ importNode.start,
+ importNode.end,
+ importLine,
+ )
+
+ if (importNode.start === hoistIndex) {
+ // no need to hoist, but update hoistIndex to keep the order
+ hoistIndex = importNode.end
+ }
+ else {
+ // There will be an error if the module is called before it is imported,
+ // so the module import statement is hoisted to the top
+ s.move(importNode.start, importNode.end, hoistIndex)
+ }
+ }
+
+ if (!hoistedModuleImported && hoistedNodes.length) {
+ const utilityImports = [...usedUtilityExports]
+ // "vi" or "vitest" is imported from a module other than "vitest"
+ if (utilityImports.some(name => idToImportMap.has(name))) {
+ s.prepend(API_NOT_FOUND_CHECK(utilityImports))
+ }
+ // if "vi" or "vitest" are not imported at all, import them
+ else if (utilityImports.length) {
+ s.prepend(
+ `import { ${[...usedUtilityExports].join(', ')} } from ${JSON.stringify(
+ hoistedModule,
+ )}\n`,
+ )
+ }
+ }
+
+ return s
+}
+
+interface CodeFrameGenerator {
+ (node: Positioned, id: string, code: string): string
+}
diff --git a/packages/mocker/src/node/hoistMocksPlugin.ts b/packages/mocker/src/node/hoistMocksPlugin.ts
index e62f69a9172e..de29eb1f8d0f 100644
--- a/packages/mocker/src/node/hoistMocksPlugin.ts
+++ b/packages/mocker/src/node/hoistMocksPlugin.ts
@@ -1,46 +1,9 @@
-import type {
- AwaitExpression,
- CallExpression,
- ExportDefaultDeclaration,
- ExportNamedDeclaration,
- Expression,
- Identifier,
- ImportDeclaration,
- VariableDeclaration,
-} from 'estree'
import type { SourceMap } from 'magic-string'
import type { Plugin, Rollup } from 'vite'
-import type { Node, Positioned } from './esmWalker'
-import { findNodeAround } from 'acorn-walk'
-import MagicString from 'magic-string'
+import type { HoistMocksOptions } from './hoistMocks'
import { createFilter } from 'vite'
-import { esmWalker } from './esmWalker'
-
-interface HoistMocksOptions {
- /**
- * List of modules that should always be imported before compiler hints.
- * @default 'vitest'
- */
- hoistedModule?: string
- /**
- * @default ["vi", "vitest"]
- */
- utilsObjectNames?: string[]
- /**
- * @default ["mock", "unmock"]
- */
- hoistableMockMethodNames?: string[]
- /**
- * @default ["mock", "unmock", "doMock", "doUnmock"]
- */
- dynamicImportMockMethodNames?: string[]
- /**
- * @default ["hoisted"]
- */
- hoistedMethodNames?: string[]
- regexpHoistable?: RegExp
- codeFrameGenerator?: CodeFrameGenerator
-}
+import { cleanUrl } from '../utils'
+import { hoistMocks } from './hoistMocks'
export interface HoistMocksPluginOptions extends Omit {
include?: string | RegExp | (string | RegExp)[]
@@ -78,7 +41,7 @@ export function hoistMocksPlugin(options: HoistMocksPluginOptions = {}): Plugin
if (!filter(id)) {
return
}
- return hoistMocks(code, id, this.parse, {
+ const s = hoistMocks(code, id, this.parse, {
regexpHoistable,
hoistableMockMethodNames,
hoistedMethodNames,
@@ -86,468 +49,33 @@ export function hoistMocksPlugin(options: HoistMocksPluginOptions = {}): Plugin
dynamicImportMockMethodNames,
...options,
})
+ if (s) {
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: 'boundary', source: cleanUrl(id) }),
+ }
+ }
},
}
}
-const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API.
-You may encounter this issue when importing the mocks API from another module other than 'vitest'.
-To fix this issue you can either:
-- import the mocks API directly from 'vitest'
-- enable the 'globals' options`
-
-function API_NOT_FOUND_CHECK(names: string[]) {
- return `\nif (${names.map(name => `typeof globalThis["${name}"] === "undefined"`).join(' && ')}) `
- + `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n`
-}
-
-function isIdentifier(node: any): node is Positioned {
- return node.type === 'Identifier'
-}
-
-function getNodeTail(code: string, node: Node) {
- let end = node.end
- if (code[node.end] === ';') {
- end += 1
- }
- if (code[node.end] === '\n') {
- return end + 1
- }
- if (code[node.end + 1] === '\n') {
- end += 1
- }
- return end
-}
-
-const regexpHoistable
- = /\b(?:vi|vitest)\s*\.\s*(?:mock|unmock|hoisted|doMock|doUnmock)\s*\(/
-const hashbangRE = /^#!.*\n/
-
-export interface HoistMocksResult {
- code: string
- map: SourceMap
-}
-
-interface CodeFrameGenerator {
- (node: Positioned, id: string, code: string): string
-}
-
-// this is a fork of Vite SSR transform
-export function hoistMocks(
+// to keeb backwards compat
+export function hoistMockAndResolve(
code: string,
id: string,
parse: Rollup.PluginContext['parse'],
options: HoistMocksOptions = {},
): HoistMocksResult | undefined {
- const needHoisting = (options.regexpHoistable || regexpHoistable).test(code)
-
- if (!needHoisting) {
- return
- }
-
- const s = new MagicString(code)
-
- let ast: ReturnType
- try {
- ast = parse(code)
- }
- catch (err) {
- console.error(`Cannot parse ${id}:\n${(err as any).message}.`)
- return
- }
-
- const {
- hoistableMockMethodNames = ['mock', 'unmock'],
- dynamicImportMockMethodNames = ['mock', 'unmock', 'doMock', 'doUnmock'],
- hoistedMethodNames = ['hoisted'],
- utilsObjectNames = ['vi', 'vitest'],
- hoistedModule = 'vitest',
- } = options
-
- // hoist at the start of the file, after the hashbang
- let hoistIndex = hashbangRE.exec(code)?.[0].length ?? 0
-
- let hoistedModuleImported = false
-
- let uid = 0
- const idToImportMap = new Map()
-
- const imports: {
- node: Positioned
- id: string
- }[] = []
-
- // this will transform import statements into dynamic ones, if there are imports
- // it will keep the import as is, if we don't need to mock anything
- // in browser environment it will wrap the module value with "vitest_wrap_module" function
- // that returns a proxy to the module so that named exports can be mocked
- function defineImport(
- importNode: ImportDeclaration & {
- start: number
- end: number
- },
- ) {
- const source = importNode.source.value as string
- // always hoist vitest import to top of the file, so
- // "vi" helpers can access it
- if (hoistedModule === source) {
- hoistedModuleImported = true
- return
- }
- const importId = `__vi_import_${uid++}__`
- imports.push({ id: importId, node: importNode })
-
- return importId
- }
-
- // 1. check all import statements and record id -> importName map
- for (const node of ast.body as Node[]) {
- // import foo from 'foo' --> foo -> __import_foo__.default
- // import { baz } from 'foo' --> baz -> __import_foo__.baz
- // import * as ok from 'foo' --> ok -> __import_foo__
- if (node.type === 'ImportDeclaration') {
- const importId = defineImport(node)
- if (!importId) {
- continue
- }
- for (const spec of node.specifiers) {
- if (spec.type === 'ImportSpecifier') {
- if (spec.imported.type === 'Identifier') {
- idToImportMap.set(
- spec.local.name,
- `${importId}.${spec.imported.name}`,
- )
- }
- else {
- idToImportMap.set(
- spec.local.name,
- `${importId}[${JSON.stringify(spec.imported.value as string)}]`,
- )
- }
- }
- else if (spec.type === 'ImportDefaultSpecifier') {
- idToImportMap.set(spec.local.name, `${importId}.default`)
- }
- else {
- // namespace specifier
- idToImportMap.set(spec.local.name, importId)
- }
- }
- }
- }
-
- const declaredConst = new Set()
- const hoistedNodes: Positioned<
- CallExpression | VariableDeclaration | AwaitExpression
- >[] = []
-
- function createSyntaxError(node: Positioned, message: string) {
- const _error = new SyntaxError(message)
- Error.captureStackTrace(_error, createSyntaxError)
- const serializedError: any = {
- name: 'SyntaxError',
- message: _error.message,
- stack: _error.stack,
- }
- if (options.codeFrameGenerator) {
- serializedError.frame = options.codeFrameGenerator(node, id, code)
- }
- return serializedError
- }
-
- function assertNotDefaultExport(
- node: Positioned,
- error: string,
- ) {
- const defaultExport = findNodeAround(
- ast,
- node.start,
- 'ExportDefaultDeclaration',
- )?.node as Positioned | undefined
- if (
- defaultExport?.declaration === node
- || (defaultExport?.declaration.type === 'AwaitExpression'
- && defaultExport.declaration.argument === node)
- ) {
- throw createSyntaxError(defaultExport, error)
- }
- }
-
- function assertNotNamedExport(
- node: Positioned,
- error: string,
- ) {
- const nodeExported = findNodeAround(
- ast,
- node.start,
- 'ExportNamedDeclaration',
- )?.node as Positioned | undefined
- if (nodeExported?.declaration === node) {
- throw createSyntaxError(nodeExported, error)
- }
- }
-
- function getVariableDeclaration(node: Positioned) {
- const declarationNode = findNodeAround(
- ast,
- node.start,
- 'VariableDeclaration',
- )?.node as Positioned | undefined
- const init = declarationNode?.declarations[0]?.init
- if (
- init
- && (init === node
- || (init.type === 'AwaitExpression' && init.argument === node))
- ) {
- return declarationNode
- }
- }
-
- const usedUtilityExports = new Set()
-
- esmWalker(ast, {
- onIdentifier(id, info, parentStack) {
- const binding = idToImportMap.get(id.name)
- if (!binding) {
- return
- }
-
- if (info.hasBindingShortcut) {
- s.appendLeft(id.end, `: ${binding}`)
- }
- else if (info.classDeclaration) {
- if (!declaredConst.has(id.name)) {
- declaredConst.add(id.name)
- // locate the top-most node containing the class declaration
- const topNode = parentStack[parentStack.length - 2]
- s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`)
- }
- }
- else if (
- // don't transform class name identifier
- !info.classExpression
- ) {
- s.update(id.start, id.end, binding)
- }
- },
- onCallExpression(node) {
- if (
- node.callee.type === 'MemberExpression'
- && isIdentifier(node.callee.object)
- && utilsObjectNames.includes(node.callee.object.name)
- && isIdentifier(node.callee.property)
- ) {
- const methodName = node.callee.property.name
- usedUtilityExports.add(node.callee.object.name)
-
- if (hoistableMockMethodNames.includes(methodName)) {
- const method = `${node.callee.object.name}.${methodName}`
- assertNotDefaultExport(
- node,
- `Cannot export the result of "${method}". Remove export declaration because "${method}" doesn\'t return anything.`,
- )
- const declarationNode = getVariableDeclaration(node)
- if (declarationNode) {
- assertNotNamedExport(
- declarationNode,
- `Cannot export the result of "${method}". Remove export declaration because "${method}" doesn\'t return anything.`,
- )
- }
- // rewrite vi.mock(import('..')) into vi.mock('..')
- if (
- node.type === 'CallExpression'
- && node.callee.type === 'MemberExpression'
- && dynamicImportMockMethodNames.includes((node.callee.property as Identifier).name)
- ) {
- const moduleInfo = node.arguments[0] as Positioned
- // vi.mock(import('./path')) -> vi.mock('./path')
- if (moduleInfo.type === 'ImportExpression') {
- const source = moduleInfo.source as Positioned
- s.overwrite(
- moduleInfo.start,
- moduleInfo.end,
- s.slice(source.start, source.end),
- )
- }
- // vi.mock(await import('./path')) -> vi.mock('./path')
- if (
- moduleInfo.type === 'AwaitExpression'
- && moduleInfo.argument.type === 'ImportExpression'
- ) {
- const source = moduleInfo.argument.source as Positioned
- s.overwrite(
- moduleInfo.start,
- moduleInfo.end,
- s.slice(source.start, source.end),
- )
- }
- }
- hoistedNodes.push(node)
- }
- // vi.doMock(import('./path')) -> vi.doMock('./path')
- // vi.doMock(await import('./path')) -> vi.doMock('./path')
- else if (dynamicImportMockMethodNames.includes(methodName)) {
- const moduleInfo = node.arguments[0] as Positioned
- let source: Positioned | null = null
- if (moduleInfo.type === 'ImportExpression') {
- source = moduleInfo.source as Positioned
- }
- if (
- moduleInfo.type === 'AwaitExpression'
- && moduleInfo.argument.type === 'ImportExpression'
- ) {
- source = moduleInfo.argument.source as Positioned
- }
- if (source) {
- s.overwrite(
- moduleInfo.start,
- moduleInfo.end,
- s.slice(source.start, source.end),
- )
- }
- }
-
- if (hoistedMethodNames.includes(methodName)) {
- assertNotDefaultExport(
- node,
- 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.',
- )
-
- const declarationNode = getVariableDeclaration(node)
- if (declarationNode) {
- assertNotNamedExport(
- declarationNode,
- 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.',
- )
- // hoist "const variable = vi.hoisted(() => {})"
- hoistedNodes.push(declarationNode)
- }
- else {
- const awaitedExpression = findNodeAround(
- ast,
- node.start,
- 'AwaitExpression',
- )?.node as Positioned | undefined
- // hoist "await vi.hoisted(async () => {})" or "vi.hoisted(() => {})"
- const moveNode = awaitedExpression?.argument === node ? awaitedExpression : node
- hoistedNodes.push(moveNode)
- }
- }
- }
- },
- })
-
- function getNodeName(node: CallExpression) {
- const callee = node.callee || {}
- if (
- callee.type === 'MemberExpression'
- && isIdentifier(callee.property)
- && isIdentifier(callee.object)
- ) {
- return `${callee.object.name}.${callee.property.name}()`
- }
- return '"hoisted method"'
- }
-
- function getNodeCall(node: Node): Positioned {
- if (node.type === 'CallExpression') {
- return node
- }
- if (node.type === 'VariableDeclaration') {
- const { declarations } = node
- const init = declarations[0].init
- if (init) {
- return getNodeCall(init as Node)
- }
- }
- if (node.type === 'AwaitExpression') {
- const { argument } = node
- if (argument.type === 'CallExpression') {
- return getNodeCall(argument as Node)
- }
- }
- return node as Positioned
- }
-
- function createError(outsideNode: Node, insideNode: Node) {
- const outsideCall = getNodeCall(outsideNode)
- const insideCall = getNodeCall(insideNode)
- throw createSyntaxError(
- insideCall,
- `Cannot call ${getNodeName(insideCall)} inside ${getNodeName(
- outsideCall,
- )}: both methods are hoisted to the top of the file and not actually called inside each other.`,
- )
- }
-
- // validate hoistedNodes doesn't have nodes inside other nodes
- for (let i = 0; i < hoistedNodes.length; i++) {
- const node = hoistedNodes[i]
- for (let j = i + 1; j < hoistedNodes.length; j++) {
- const otherNode = hoistedNodes[j]
-
- if (node.start >= otherNode.start && node.end <= otherNode.end) {
- throw createError(otherNode, node)
- }
- if (otherNode.start >= node.start && otherNode.end <= node.end) {
- throw createError(node, otherNode)
- }
- }
- }
-
- // hoist vi.mock/vi.hoisted
- for (const node of hoistedNodes) {
- const end = getNodeTail(code, node)
- // don't hoist into itself if it's already at the top
- if (hoistIndex === end || hoistIndex === node.start) {
- hoistIndex = end
- }
- else {
- s.move(node.start, end, hoistIndex)
- }
- }
-
- // hoist actual dynamic imports last so they are inserted after all hoisted mocks
- for (const { node: importNode, id: importId } of imports) {
- const source = importNode.source.value as string
-
- s.update(
- importNode.start,
- importNode.end,
- `const ${importId} = await import(${JSON.stringify(
- source,
- )});\n`,
- )
-
- if (importNode.start === hoistIndex) {
- // no need to hoist, but update hoistIndex to keep the order
- hoistIndex = importNode.end
- }
- else {
- // There will be an error if the module is called before it is imported,
- // so the module import statement is hoisted to the top
- s.move(importNode.start, importNode.end, hoistIndex)
- }
- }
-
- if (!hoistedModuleImported && hoistedNodes.length) {
- const utilityImports = [...usedUtilityExports]
- // "vi" or "vitest" is imported from a module other than "vitest"
- if (utilityImports.some(name => idToImportMap.has(name))) {
- s.prepend(API_NOT_FOUND_CHECK(utilityImports))
- }
- // if "vi" or "vitest" are not imported at all, import them
- else if (utilityImports.length) {
- s.prepend(
- `import { ${[...usedUtilityExports].join(', ')} } from ${JSON.stringify(
- hoistedModule,
- )}\n`,
- )
+ const s = hoistMocks(code, id, parse, options)
+ if (s) {
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: 'boundary', source: cleanUrl(id) }),
}
}
+}
- return {
- code: s.toString(),
- map: s.generateMap({ hires: 'boundary', source: id }),
- }
+export interface HoistMocksResult {
+ code: string
+ map: SourceMap
}
diff --git a/packages/mocker/src/node/index.ts b/packages/mocker/src/node/index.ts
index 8ad76b77b1ac..f81d9db87739 100644
--- a/packages/mocker/src/node/index.ts
+++ b/packages/mocker/src/node/index.ts
@@ -3,7 +3,7 @@ export { automockModule } from './automock'
export type { AutomockPluginOptions } from './automockPlugin'
export { automockPlugin } from './automockPlugin'
export { dynamicImportPlugin } from './dynamicImportPlugin'
-export { hoistMocks, hoistMocksPlugin } from './hoistMocksPlugin'
+export { hoistMockAndResolve as hoistMocks, hoistMocksPlugin } from './hoistMocksPlugin'
export type { HoistMocksPluginOptions, HoistMocksResult } from './hoistMocksPlugin'
export { interceptorPlugin } from './interceptorPlugin'
diff --git a/packages/mocker/src/node/parsers.ts b/packages/mocker/src/node/parsers.ts
new file mode 100644
index 000000000000..9da5451656e0
--- /dev/null
+++ b/packages/mocker/src/node/parsers.ts
@@ -0,0 +1,171 @@
+import { readFileSync } from 'node:fs'
+import module, { createRequire, isBuiltin } from 'node:module'
+import { extname } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { filterOutComments } from '@vitest/utils/helpers'
+import { init as initCjsLexer, parse as parseCjsSyntax } from 'cjs-module-lexer'
+import { init as initModuleLexer, parse as parseModuleSyntax } from 'es-module-lexer'
+
+export async function initSyntaxLexers(): Promise {
+ await Promise.all([
+ initCjsLexer(),
+ initModuleLexer,
+ ])
+}
+
+export function transformCode(code: string, filename: string): string {
+ const ext = extname(filename)
+ const isTs = ext === '.ts' || ext === '.cts' || ext === '.mts'
+ if (!isTs) {
+ return code
+ }
+ if (!module.stripTypeScriptTypes) {
+ throw new Error(`Cannot parse '${filename}' because "module.stripTypeScriptTypes" is not supported. Module mocking requires Node.js 22.15 or higher. This is NOT a bug of Vitest.`)
+ }
+ return module.stripTypeScriptTypes(code)
+}
+
+const cachedFileExports = new Map()
+
+export function collectModuleExports(
+ filename: string,
+ code: string,
+ format: 'module' | 'commonjs',
+ exports: string[] = [],
+): string[] {
+ if (format === 'module') {
+ const [imports_, exports_] = parseModuleSyntax(code, filename)
+ const fileExports = [...exports_.map(p => p.n)]
+ imports_.forEach(({ ss: start, se: end, n: name }) => {
+ const substring = code.substring(start, end).replace(/ +/g, ' ')
+ if (name && substring.startsWith('export *') && !substring.startsWith('export * as')) {
+ fileExports.push(...tryParseModule(name))
+ }
+ })
+ cachedFileExports.set(filename, fileExports)
+ exports.push(...fileExports)
+ }
+ else {
+ const { exports: exports_, reexports } = parseCjsSyntax(code, filename)
+ const fileExports = [...exports_]
+ reexports.forEach((name) => {
+ fileExports.push(...tryParseModule(name))
+ })
+ cachedFileExports.set(filename, fileExports)
+ exports.push(...fileExports)
+ }
+
+ function tryParseModule(name: string): string[] {
+ try {
+ return parseModule(name)
+ }
+ catch (error) {
+ console.warn(`[module mocking] Failed to parse '${name}' imported from ${filename}:`, error)
+ return []
+ }
+ }
+
+ let __require: NodeJS.Require | undefined
+ function getModuleRequire() {
+ return (__require ??= createRequire(filename))
+ }
+
+ function parseModule(name: string): string[] {
+ if (isBuiltin(name)) {
+ if (cachedFileExports.has(name)) {
+ const cachedExports = cachedFileExports.get(name)!
+ return cachedExports
+ }
+
+ const builtinModule = getBuiltinModule(name)
+ const builtinExports = Object.keys(builtinModule)
+ cachedFileExports.set(name, builtinExports)
+ return builtinExports
+ }
+
+ const resolvedModuleUrl = format === 'module'
+ ? import.meta.resolve(name, pathToFileURL(filename).toString())
+ : getModuleRequire().resolve(name)
+
+ const resolvedModulePath = format === 'commonjs'
+ ? resolvedModuleUrl
+ : fileURLToPath(resolvedModuleUrl)
+
+ if (cachedFileExports.has(resolvedModulePath)) {
+ const cachedExports = cachedFileExports.get(resolvedModulePath)!
+ return cachedExports
+ }
+
+ const fileContent = readFileSync(resolvedModulePath, 'utf-8')
+ const ext = extname(resolvedModulePath)
+ const code = transformCode(fileContent, resolvedModulePath)
+ if (code == null) {
+ cachedFileExports.set(resolvedModulePath, [])
+ return []
+ }
+
+ const resolvedModuleFormat = resolveModuleFormat(resolvedModulePath, code)
+ if (ext === '.json') {
+ return ['default']
+ }
+ else {
+ // can't do wasm, for example
+ console.warn(`Cannot process '${resolvedModuleFormat}' imported from ${filename} because of unknown file extension: ${ext}.`)
+ }
+ if (resolvedModuleFormat) {
+ return collectModuleExports(resolvedModulePath, code, resolvedModuleFormat, exports)
+ }
+ return []
+ }
+
+ return Array.from(new Set(exports))
+}
+
+export function resolveModuleFormat(url: string, code: string): 'module' | 'commonjs' | undefined {
+ const ext = extname(url)
+
+ if (ext === '.cjs' || ext === '.cts') {
+ return 'commonjs'
+ }
+ else if (ext === '.mjs' || ext === '.mts') {
+ return 'module'
+ }
+ // https://nodejs.org/api/packages.html#syntax-detection
+ else if (ext === '.js' || ext === '.ts' || ext === '') {
+ if (!module.findPackageJSON) {
+ throw new Error(`Cannot parse the module format of '${url}' because "module.findPackageJSON" is not available. Upgrade to Node 22.14 to use this feature. This is NOT a bug of Vitest.`)
+ }
+ const pkgJsonPath = module.findPackageJSON(url)
+ const pkgJson = pkgJsonPath ? JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) : {}
+ if (pkgJson?.type === 'module') {
+ return 'module'
+ }
+ else if (pkgJson?.type === 'commonjs') {
+ return 'commonjs'
+ }
+ else {
+ // Ambiguous input! Check if it has ESM syntax. Node.js is much smarter here,
+ // but we don't need to run the code, so we can be more relaxed
+ if (hasESM(filterOutComments(code))) {
+ return 'module'
+ }
+ else {
+ return 'commonjs'
+ }
+ }
+ }
+ return undefined
+}
+
+let __globalRequire: NodeJS.Require | undefined
+function getBuiltinModule(moduleId: string) {
+ __globalRequire ??= module.createRequire(import.meta.url)
+ return __globalRequire(moduleId)
+}
+
+const ESM_RE
+ = /(?:[\s;]|^)(?:import[\s\w*,{}]*from|import\s*["'*{]|export\b\s*(?:[*{]|default|class|type|function|const|var|let|async function)|import\.meta\b)/m
+
+function hasESM(code: string) {
+ return ESM_RE.test(code)
+}
diff --git a/packages/mocker/src/node/transforms.ts b/packages/mocker/src/node/transforms.ts
new file mode 100644
index 000000000000..186a083c1633
--- /dev/null
+++ b/packages/mocker/src/node/transforms.ts
@@ -0,0 +1,4 @@
+export { createManualModuleSource } from '../utils'
+export { automockModule } from './automock'
+export { hoistMocks } from './hoistMocks'
+export { collectModuleExports, initSyntaxLexers } from './parsers'
diff --git a/packages/mocker/src/registry.ts b/packages/mocker/src/registry.ts
index 87fdb5a9cca8..057555d61631 100644
--- a/packages/mocker/src/registry.ts
+++ b/packages/mocker/src/registry.ts
@@ -258,41 +258,43 @@ export interface RedirectedModuleSerialized {
redirect: string
}
-export class ManualMockedModule {
- public cache: Record | undefined
+export class ManualMockedModule {
+ public cache: T | undefined
public readonly type = 'manual'
constructor(
public raw: string,
public id: string,
public url: string,
- public factory: () => any,
+ public factory: () => T,
) {}
- async resolve(): Promise> {
+ resolve(): T {
if (this.cache) {
return this.cache
}
let exports: any
try {
- exports = await this.factory()
+ exports = this.factory()
}
- catch (err) {
- const vitestError = new Error(
- '[vitest] There was an error when mocking a module. '
- + 'If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. '
- + 'Read more: https://vitest.dev/api/vi.html#vi-mock',
- )
- vitestError.cause = err
- throw vitestError
+ catch (err: any) {
+ throw createHelpfulError(err)
}
- if (exports === null || typeof exports !== 'object' || Array.isArray(exports)) {
- throw new TypeError(
- `[vitest] vi.mock("${this.raw}", factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?`,
+ if (typeof exports === 'object' && typeof exports?.then === 'function') {
+ return exports.then(
+ (result: T) => {
+ assertValidExports(this.raw, result)
+ return (this.cache = result)
+ },
+ (error: any) => {
+ throw createHelpfulError(error)
+ },
)
}
+ assertValidExports(this.raw, exports)
+
return (this.cache = exports)
}
@@ -310,6 +312,24 @@ export class ManualMockedModule {
}
}
+function createHelpfulError(cause: Error) {
+ const error = new Error(
+ '[vitest] There was an error when mocking a module. '
+ + 'If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. '
+ + 'Read more: https://vitest.dev/api/vi.html#vi-mock',
+ )
+ error.cause = cause
+ return error
+}
+
+function assertValidExports(raw: string, exports: any) {
+ if (exports === null || typeof exports !== 'object' || Array.isArray(exports)) {
+ throw new TypeError(
+ `[vitest] vi.mock("${raw}", factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?`,
+ )
+ }
+}
+
export interface ManualMockedModuleSerialized {
type: 'manual'
url: string
diff --git a/packages/mocker/src/types.ts b/packages/mocker/src/types.ts
index 63639fc48249..83aee806f5b6 100644
--- a/packages/mocker/src/types.ts
+++ b/packages/mocker/src/types.ts
@@ -1,3 +1,5 @@
+/* eslint-disable ts/method-signature-style */
+
type Awaitable = T | PromiseLike
export type ModuleMockFactoryWithHelper = (
@@ -21,3 +23,32 @@ export interface ServerIdResolution {
url: string
optimized: boolean
}
+
+export interface ModuleMockContext {
+ /**
+ * When mocking with a factory, this refers to the module that imported the mock.
+ */
+ callstack: null | string[]
+}
+
+export interface TestModuleMocker {
+ queueMock(
+ id: string,
+ importer: string,
+ factoryOrOptions?: ModuleMockFactory | ModuleMockOptions,
+ ): void
+ queueUnmock(id: string, importer: string): void
+ importActual(
+ rawId: string,
+ importer: string,
+ callstack?: string[] | null,
+ ): Promise
+ importMock(rawId: string, importer: string): Promise
+ mockObject(
+ object: Record,
+ mockExports?: Record,
+ behavior?: 'automock' | 'autospy',
+ ): Record
+ getMockContext(): ModuleMockContext
+ reset(): void
+}
diff --git a/packages/mocker/src/utils.ts b/packages/mocker/src/utils.ts
index abbf172f7823..084fc7f42ab1 100644
--- a/packages/mocker/src/utils.ts
+++ b/packages/mocker/src/utils.ts
@@ -4,14 +4,25 @@ export function cleanUrl(url: string): string {
}
export function createManualModuleSource(moduleUrl: string, exports: string[], globalAccessor = '"__vitest_mocker__"'): string {
- const source = `const module = globalThis[${globalAccessor}].getFactoryModule("${moduleUrl}");`
+ const source = `
+const __factoryModule__ = await globalThis[${globalAccessor}].getFactoryModule("${moduleUrl}");
+`
const keys = exports
- .map((name) => {
- if (name === 'default') {
- return `export default module["default"];`
- }
- return `export const ${name} = module["${name}"];`
+ .map((name, index) => {
+ return `let __${index} = __factoryModule__["${name}"]
+export { __${index} as "${name}" }`
})
.join('\n')
- return `${source}\n${keys}`
+ let code = `${source}\n${keys}`
+ // this prevents recursion
+ code += `
+if (__factoryModule__.__factoryPromise != null) {
+ __factoryModule__.__factoryPromise.then((resolvedModule) => {
+ ${exports.map((name, index) => {
+ return `__${index} = resolvedModule["${name}"];`
+ }).join('\n')}
+ })
+}
+ `
+ return code
}
diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts
index 9eb81883f6e0..323a053191e7 100644
--- a/packages/runner/src/fixture.ts
+++ b/packages/runner/src/fixture.ts
@@ -1,6 +1,6 @@
import type { VitestRunner } from './types'
import type { FixtureOptions, TestContext } from './types/tasks'
-import { createDefer, isObject } from '@vitest/utils/helpers'
+import { createDefer, filterOutComments, isObject } from '@vitest/utils/helpers'
import { getFileContext } from './context'
import { getTestFixture } from './map'
@@ -386,36 +386,6 @@ function getUsedProps(fn: Function) {
return props
}
-function filterOutComments(s: string): string {
- const result: string[] = []
- let commentState: 'none' | 'singleline' | 'multiline' = 'none'
- for (let i = 0; i < s.length; ++i) {
- if (commentState === 'singleline') {
- if (s[i] === '\n') {
- commentState = 'none'
- }
- }
- else if (commentState === 'multiline') {
- if (s[i - 1] === '*' && s[i] === '/') {
- commentState = 'none'
- }
- }
- else if (commentState === 'none') {
- if (s[i] === '/' && s[i + 1] === '/') {
- commentState = 'singleline'
- }
- else if (s[i] === '/' && s[i + 1] === '*') {
- commentState = 'multiline'
- i += 2
- }
- else {
- result.push(s[i])
- }
- }
- }
- return result.join('')
-}
-
function splitByComma(s: string) {
const result = []
const stack = []
diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts
index 2c20b56347a4..77ed29519ea2 100644
--- a/packages/snapshot/src/port/inlineSnapshot.ts
+++ b/packages/snapshot/src/port/inlineSnapshot.ts
@@ -24,7 +24,11 @@ export async function saveInlineSnapshots(
await Promise.all(
Array.from(files).map(async (file) => {
const snaps = snapshots.filter(i => i.file === file)
- const code = await environment.readSnapshotFile(file) as string
+ const code = await environment.readSnapshotFile(file)
+ if (code == null) {
+ throw new Error(`cannot read ${file} when saving inline snapshot`)
+ }
+
const s = new MagicString(code)
for (const snap of snaps) {
diff --git a/packages/ui/client/composables/module-graph.ts b/packages/ui/client/composables/module-graph.ts
index 9d8993115ccd..3c23f3bcabf6 100644
--- a/packages/ui/client/composables/module-graph.ts
+++ b/packages/ui/client/composables/module-graph.ts
@@ -8,6 +8,7 @@ import type {
import type { ModuleGraphData } from 'vitest'
import { defineGraph, defineLink, defineNode } from 'd3-graph-controller'
import { calcExternalLabels, createModuleLabelItem } from '~/utils/task'
+import { config } from './client'
export type ModuleType = 'external' | 'inline'
export type ModuleNode = GraphNode
@@ -25,7 +26,7 @@ function defineExternalModuleNodes(modules: string[]): ModuleNode[] {
createModuleLabelItem(module),
)
const map = calcExternalLabels(labels)
- return labels.map(({ raw, id, splits }) => {
+ return labels.map(({ raw, id, splitted }) => {
return defineNode({
color: 'var(--color-node-external)',
label: {
@@ -33,7 +34,7 @@ function defineExternalModuleNodes(modules: string[]): ModuleNode[] {
fontSize: '0.875rem',
text: id.includes('node_modules')
? (map.get(raw) ?? raw)
- : splits.pop()!,
+ : splitted[splitted.length - 1],
},
isFocused: false,
id,
@@ -64,11 +65,15 @@ export function getModuleGraph(
return defineGraph({})
}
- const externalizedNodes = defineExternalModuleNodes(data.externalized)
+ const externalizedNodes = !config.value.experimental.viteModuleRunner
+ ? defineExternalModuleNodes([...data.inlined, ...data.externalized])
+ : defineExternalModuleNodes(data.externalized)
const inlinedNodes
- = data.inlined.map(module =>
- defineInlineModuleNode(module, module === rootPath),
- ) ?? []
+ = !config.value.experimental.viteModuleRunner
+ ? []
+ : data.inlined.map(module =>
+ defineInlineModuleNode(module, module === rootPath),
+ ) ?? []
const nodes = [...externalizedNodes, ...inlinedNodes]
const nodeMap = Object.fromEntries(nodes.map(node => [node.id, node]))
const links = Object.entries(data.graph).flatMap(
diff --git a/packages/ui/client/utils/task.ts b/packages/ui/client/utils/task.ts
index 272bc9f54b1f..4152bc9dcb64 100644
--- a/packages/ui/client/utils/task.ts
+++ b/packages/ui/client/utils/task.ts
@@ -36,6 +36,7 @@ export interface ModuleLabelItem {
id: string
raw: string
splits: string[]
+ readonly splitted: string[]
candidate: string
finished: boolean
}
@@ -103,6 +104,7 @@ export function createModuleLabelItem(module: string): ModuleLabelItem {
return {
raw,
splits,
+ splitted: [...splits],
candidate: '',
finished: false,
id: module,
diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts
index 3b5d59d49282..b39e76033eb5 100644
--- a/packages/utils/src/helpers.ts
+++ b/packages/utils/src/helpers.ts
@@ -95,6 +95,36 @@ export function withTrailingSlash(path: string): string {
return path
}
+export function filterOutComments(s: string): string {
+ const result: string[] = []
+ let commentState: 'none' | 'singleline' | 'multiline' = 'none'
+ for (let i = 0; i < s.length; ++i) {
+ if (commentState === 'singleline') {
+ if (s[i] === '\n') {
+ commentState = 'none'
+ }
+ }
+ else if (commentState === 'multiline') {
+ if (s[i - 1] === '*' && s[i] === '/') {
+ commentState = 'none'
+ }
+ }
+ else if (commentState === 'none') {
+ if (s[i] === '/' && s[i + 1] === '/') {
+ commentState = 'singleline'
+ }
+ else if (s[i] === '/' && s[i + 1] === '*') {
+ commentState = 'multiline'
+ i += 2
+ }
+ else {
+ result.push(s[i])
+ }
+ }
+ }
+ return result.join('')
+}
+
const bareImportRE = /^(?![a-z]:)[\w@](?!.*:\/\/)/i
export function isBareImport(id: string): boolean {
diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts
index f263b2caec12..3c2fbd013a2d 100644
--- a/packages/utils/src/source-map.ts
+++ b/packages/utils/src/source-map.ts
@@ -40,6 +40,9 @@ const stackIgnorePatterns: (string | RegExp)[] = [
export { stackIgnorePatterns as defaultStackIgnorePatterns }
+const NOW_LENGTH = Date.now().toString().length
+const REGEXP_VITEST = new RegExp(`vitest=\\d{${NOW_LENGTH}}`)
+
function extractLocation(urlLike: string) {
// Fail-fast but return locations like "(native)"
if (!urlLike.includes(':')) {
@@ -65,6 +68,9 @@ function extractLocation(urlLike: string) {
const isWindows = /^\/@fs\/[a-zA-Z]:\//.test(url)
url = url.slice(isWindows ? 5 : 4)
}
+ if (url.includes('vitest=')) {
+ url = url.replace(REGEXP_VITEST, '').replace(/[?&]$/, '')
+ }
return [url, parts[2] || undefined, parts[3] || undefined]
}
diff --git a/packages/vitest/LICENSE.md b/packages/vitest/LICENSE.md
index ba4ea56c407a..638bf4c8d133 100644
--- a/packages/vitest/LICENSE.md
+++ b/packages/vitest/LICENSE.md
@@ -193,6 +193,35 @@ Repository: git+https://github.com/sinonjs/fake-timers.git
---------------------------------------
+## acorn
+License: MIT
+By: Marijn Haverbeke, Ingvar Stepanyan, Adrian Heine
+Repository: https://github.com/acornjs/acorn.git
+
+> MIT License
+>
+> Copyright (C) 2012-2022 by various contributors (see AUTHORS)
+>
+> Permission is hereby granted, free of charge, to any person obtaining a copy
+> of this software and associated documentation files (the "Software"), to deal
+> in the Software without restriction, including without limitation the rights
+> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+> copies of the Software, and to permit persons to whom the Software is
+> furnished to do so, subject to the following conditions:
+>
+> The above copyright notice and this permission notice shall be included in
+> all copies or substantial portions of the Software.
+>
+> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+> THE SOFTWARE.
+
+---------------------------------------
+
## acorn-walk
License: MIT
By: Marijn Haverbeke, Ingvar Stepanyan, Adrian Heine
diff --git a/packages/vitest/package.json b/packages/vitest/package.json
index 3dce38879a7c..2ec2dc091985 100644
--- a/packages/vitest/package.json
+++ b/packages/vitest/package.json
@@ -26,7 +26,8 @@
"#module-evaluator": {
"types": "./dist/module-evaluator.d.ts",
"default": "./dist/module-evaluator.js"
- }
+ },
+ "#test-loader": "./dist/nodejs-worker-loader.js"
},
"exports": {
".": {
@@ -180,7 +181,7 @@
"@vitest/snapshot": "workspace:*",
"@vitest/spy": "workspace:*",
"@vitest/utils": "workspace:*",
- "es-module-lexer": "^1.7.0",
+ "es-module-lexer": "^2.0.0",
"expect-type": "^1.2.2",
"magic-string": "catalog:",
"obug": "catalog:",
@@ -208,6 +209,7 @@
"@types/picomatch": "^4.0.2",
"@types/prompts": "^2.4.9",
"@types/sinonjs__fake-timers": "^8.1.5",
+ "acorn": "8.11.3",
"acorn-walk": "catalog:",
"birpc": "catalog:",
"cac": "catalog:",
diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js
index ae411c9ddcb3..7d2920c2061d 100644
--- a/packages/vitest/rollup.config.js
+++ b/packages/vitest/rollup.config.js
@@ -32,6 +32,7 @@ const entries = {
'worker': 'src/public/worker.ts',
'module-runner': 'src/public/module-runner.ts',
'module-evaluator': 'src/runtime/moduleRunner/moduleEvaluator.ts',
+ 'nodejs-worker-loader': 'src/runtime/nodejsWorkerLoader.ts',
// for performance reasons we bundle them separately so we don't import everything at once
// 'worker': 'src/runtime/worker.ts',
@@ -79,7 +80,7 @@ const external = [
'vitest/browser',
'vite/module-runner',
'@vitest/mocker',
- '@vitest/mocker/node',
+ /@vitest\/mocker\/\w+/,
'@vitest/utils/diff',
'@vitest/utils/error',
'@vitest/utils/source-map',
diff --git a/packages/vitest/src/integrations/snapshot/environments/resolveSnapshotEnvironment.ts b/packages/vitest/src/integrations/snapshot/environments/resolveSnapshotEnvironment.ts
index 46345abba0ff..d1da39595719 100644
--- a/packages/vitest/src/integrations/snapshot/environments/resolveSnapshotEnvironment.ts
+++ b/packages/vitest/src/integrations/snapshot/environments/resolveSnapshotEnvironment.ts
@@ -1,17 +1,17 @@
import type { SnapshotEnvironment } from '@vitest/snapshot/environment'
import type { SerializedConfig } from '../../../runtime/config'
-import type { VitestModuleRunner } from '../../../runtime/moduleRunner/moduleRunner'
+import type { TestModuleRunner } from '../../../runtime/moduleRunner/testModuleRunner'
export async function resolveSnapshotEnvironment(
config: SerializedConfig,
- executor: VitestModuleRunner,
+ moduleRunner: TestModuleRunner,
): Promise {
if (!config.snapshotEnvironment) {
const { VitestNodeSnapshotEnvironment } = await import('./node')
return new VitestNodeSnapshotEnvironment()
}
- const mod = await executor.import(config.snapshotEnvironment)
+ const mod = await moduleRunner.import(config.snapshotEnvironment)
if (typeof mod.default !== 'object' || !mod.default) {
throw new Error(
'Snapshot environment module must have a default export object with a shape of `SnapshotEnvironment`',
diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts
index a3e078e5b387..52475b6c777b 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -778,6 +778,12 @@ export const cliOptionsConfig: VitestCLIOptions = {
printImportBreakdown: {
description: 'Print import breakdown after the summary. If the reporter doesn\'t support summary, this will have no effect. Note that UI\'s "Module Graph" tab always has an import breakdown.',
},
+ viteModuleRunner: {
+ description: 'Control whether Vitest uses Vite\'s module runner to run the code or fallback to the native `import`. (default: `true`)',
+ },
+ nodeLoader: {
+ description: 'Controls whether Vitest will use Node.js Loader API to process in-source or mocked files. This has no effect if `viteModuleRunner` is enabled. Disabling this can increase performance. (default: `true`)',
+ },
},
},
// disable CLI options
diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts
index 46b511afb49f..46b69a2c9760 100644
--- a/packages/vitest/src/node/config/serializeConfig.ts
+++ b/packages/vitest/src/node/config/serializeConfig.ts
@@ -133,6 +133,8 @@ export function serializeConfig(project: TestProject): SerializedConfig {
experimental: {
fsModuleCache: config.experimental.fsModuleCache ?? false,
printImportBreakdown: config.experimental.printImportBreakdown,
+ viteModuleRunner: config.experimental.viteModuleRunner ?? true,
+ nodeLoader: config.experimental.nodeLoader ?? true,
},
}
}
diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts
index e59253a78fae..b33193a42436 100644
--- a/packages/vitest/src/node/core.ts
+++ b/packages/vitest/src/node/core.ts
@@ -24,6 +24,7 @@ import { version } from '../../package.json' with { type: 'json' }
import { WebSocketReporter } from '../api/setup'
import { distDir } from '../paths'
import { wildcardPatternToRegExp } from '../utils/base'
+import { NativeModuleRunner } from '../utils/nativeModuleRunner'
import { convertTasksToEvents } from '../utils/tasks'
import { Traces } from '../utils/traces'
import { astCollectTests, createFailedFileTask } from './ast-collect'
@@ -238,11 +239,13 @@ export class Vitest {
this._tmpDir,
)
const environment = server.environments.__vitest__
- this.runner = new ServerModuleRunner(
- environment,
- this._fetcher,
- resolved,
- )
+ this.runner = resolved.experimental.viteModuleRunner === false
+ ? new NativeModuleRunner(resolved.root)
+ : new ServerModuleRunner(
+ environment,
+ this._fetcher,
+ resolved,
+ )
if (this.config.watch) {
// hijack server restart
diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts
index 4cbce54f8105..e84ccfd0b776 100644
--- a/packages/vitest/src/node/plugins/workspace.ts
+++ b/packages/vitest/src/node/plugins/workspace.ts
@@ -97,15 +97,21 @@ export function WorkspaceVitestPlugin(
name: { label: name, color },
}
+ vitestConfig.experimental ??= {}
+
// always inherit the global `fsModuleCache` value even without `extends: true`
- if (testConfig.experimental?.fsModuleCache == null && project.vitest.config.experimental?.fsModuleCache !== null) {
- vitestConfig.experimental ??= {}
+ if (testConfig.experimental?.fsModuleCache == null && project.vitest.config.experimental?.fsModuleCache != null) {
vitestConfig.experimental.fsModuleCache = project.vitest.config.experimental.fsModuleCache
}
- if (testConfig.experimental?.fsModuleCachePath == null && project.vitest.config.experimental?.fsModuleCachePath !== null) {
- vitestConfig.experimental ??= {}
+ if (testConfig.experimental?.fsModuleCachePath == null && project.vitest.config.experimental?.fsModuleCachePath != null) {
vitestConfig.experimental.fsModuleCachePath = project.vitest.config.experimental.fsModuleCachePath
}
+ if (testConfig.experimental?.viteModuleRunner == null && project.vitest.config.experimental?.viteModuleRunner != null) {
+ vitestConfig.experimental.viteModuleRunner = project.vitest.config.experimental.viteModuleRunner
+ }
+ if (testConfig.experimental?.nodeLoader == null && project.vitest.config.experimental?.nodeLoader != null) {
+ vitestConfig.experimental.nodeLoader = project.vitest.config.experimental.nodeLoader
+ }
return {
base: '/',
diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts
index ca87cb614412..05fca9acf7d7 100644
--- a/packages/vitest/src/node/pools/rpc.ts
+++ b/packages/vitest/src/node/pools/rpc.ts
@@ -149,5 +149,36 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp
getCountOfFailedTests() {
return vitest.state.getCountOfFailedTests()
},
+
+ ensureModuleGraphEntry(id, importer) {
+ const filepath = id.startsWith('file:') ? fileURLToPath(id) : id
+ const importerPath = importer.startsWith('file:') ? fileURLToPath(importer) : importer
+ // environment itself doesn't matter
+ const moduleGraph = project.vite.environments.__vitest__?.moduleGraph
+ if (!moduleGraph) {
+ // TODO: is it possible?
+ console.error('no module graph for', id)
+ return
+ }
+ const importerNode = moduleGraph.getModuleById(importerPath) || moduleGraph.createFileOnlyEntry(importerPath)
+ const moduleNode = moduleGraph.getModuleById(filepath) || moduleGraph.createFileOnlyEntry(filepath)
+
+ if (!moduleGraph.idToModuleMap.has(importerPath)) {
+ importerNode.id = importerPath
+ moduleGraph.idToModuleMap.set(importerPath, importerNode)
+ }
+ if (!moduleGraph.idToModuleMap.has(filepath)) {
+ moduleNode.id = filepath
+ moduleGraph.idToModuleMap.set(filepath, moduleNode)
+ }
+
+ // this is checked by the "printError" function - TODO: is there a better way?
+ moduleNode.transformResult = {
+ code: ' ',
+ map: null,
+ }
+ importerNode.importedModules.add(moduleNode)
+ moduleNode.importers.add(importerNode)
+ },
}
}
diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts
index 4601e8a2f02a..1e6b6cb1cb77 100644
--- a/packages/vitest/src/node/project.ts
+++ b/packages/vitest/src/node/project.ts
@@ -1,5 +1,5 @@
import type { GlobOptions } from 'tinyglobby'
-import type { ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite'
+import type { DevEnvironment, ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite'
import type { ModuleRunner } from 'vite/module-runner'
import type { Typechecker } from '../typecheck/typechecker'
import type { ProvidedContext } from '../types/general'
@@ -24,6 +24,7 @@ import pm from 'picomatch'
import { glob } from 'tinyglobby'
import { setup } from '../api/setup'
import { createDefinesScript } from '../utils/config-helpers'
+import { NativeModuleRunner } from '../utils/nativeModuleRunner'
import { isBrowserEnabled, resolveConfig } from './config/resolveConfig'
import { serializeConfig } from './config/serializeConfig'
import { createFetchModuleFunction } from './environments/fetchModule'
@@ -568,11 +569,21 @@ export class TestProject {
)
const environment = server.environments.__vitest__
- this.runner = new ServerModuleRunner(
- environment,
- this._fetcher,
- this._config,
- )
+ this.runner = this._config.experimental.viteModuleRunner === false
+ ? new NativeModuleRunner(this._config.root)
+ : new ServerModuleRunner(
+ environment,
+ this._fetcher,
+ this._config,
+ )
+ }
+
+ /** @internal */
+ public _getViteEnvironments(): DevEnvironment[] {
+ return [
+ ...Object.values(this.browser?.vite.environments || {}),
+ ...Object.values(this.vite.environments || {}),
+ ]
}
private _serializeOverriddenConfig(): SerializedConfig {
diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts
index 412b9f7ce035..68b012b3a2eb 100644
--- a/packages/vitest/src/node/types/config.ts
+++ b/packages/vitest/src/node/types/config.ts
@@ -854,6 +854,23 @@ export interface InlineConfig {
* Enabling this will also show a breakdown by default in UI, but you can always press a button to toggle it.
*/
printImportBreakdown?: boolean
+
+ /**
+ * Controls whether Vitest uses Vite's module runner to run the code or fallback to the native `import`.
+ *
+ * If Node.js cannot process the code, consider registering [module loader](https://nodejs.org/api/module.html#customization-hooks) via `execArgv`.
+ * @default true
+ */
+ viteModuleRunner?: boolean
+ /**
+ * If module runner is disabled, Vitest uses a module loader to transform files to support
+ * `import.meta.vitest` and `vi.mock`.
+ *
+ * If you don't use these features, you can disable this.
+ *
+ * This option only affects `loader.load` method, Vitest always defines a `loader.resolve` to populate the module graph.
+ */
+ nodeLoader?: boolean
}
}
diff --git a/packages/vitest/src/node/watcher.ts b/packages/vitest/src/node/watcher.ts
index 7a716f5feea7..4f7b8823b817 100644
--- a/packages/vitest/src/node/watcher.ts
+++ b/packages/vitest/src/node/watcher.ts
@@ -179,8 +179,9 @@ export class VitestWatcher {
}
const projects = this.vitest.projects.filter((project) => {
- const moduleGraph = project.browser?.vite.moduleGraph || project.vite.moduleGraph
- return moduleGraph.getModulesByFile(filepath)?.size
+ return project._getViteEnvironments().some(({ moduleGraph }) => {
+ return moduleGraph.getModulesByFile(filepath)?.size
+ })
})
if (!projects.length) {
// if there are no modules it's possible that server was restarted
@@ -195,9 +196,8 @@ export class VitestWatcher {
const files: string[] = []
for (const project of projects) {
- const mods = project.browser?.vite.moduleGraph.getModulesByFile(filepath)
- || project.vite.moduleGraph.getModulesByFile(filepath)
- if (!mods || !mods.size) {
+ const environmentMods = project._getViteEnvironments().map(({ moduleGraph }) => moduleGraph.getModulesByFile(filepath))
+ if (!environmentMods.length) {
continue
}
@@ -211,17 +211,19 @@ export class VitestWatcher {
}
let rerun = false
- for (const mod of mods) {
- mod.importers.forEach((i) => {
- if (!i.file) {
- return
- }
-
- const needsRerun = this.handleFileChanged(i.file)
- if (needsRerun) {
- rerun = true
- }
- })
+ for (const mods of environmentMods) {
+ for (const mod of mods || []) {
+ mod.importers.forEach((i) => {
+ if (!i.file) {
+ return
+ }
+
+ const needsRerun = this.handleFileChanged(i.file)
+ if (needsRerun) {
+ rerun = true
+ }
+ })
+ }
}
if (rerun) {
diff --git a/packages/vitest/src/public/module-runner.ts b/packages/vitest/src/public/module-runner.ts
index 841257fdb48f..c7ea9582ba1c 100644
--- a/packages/vitest/src/public/module-runner.ts
+++ b/packages/vitest/src/public/module-runner.ts
@@ -10,5 +10,6 @@ export {
type ContextModuleRunnerOptions,
startVitestModuleRunner,
VITEST_VM_CONTEXT_SYMBOL,
-} from '../runtime/moduleRunner/startModuleRunner'
+} from '../runtime/moduleRunner/startVitestModuleRunner'
+export type { TestModuleRunner } from '../runtime/moduleRunner/testModuleRunner'
export { getWorkerState } from '../runtime/utils'
diff --git a/packages/vitest/src/public/worker.ts b/packages/vitest/src/public/worker.ts
index 786ad6bbf548..9297eb10c855 100644
--- a/packages/vitest/src/public/worker.ts
+++ b/packages/vitest/src/public/worker.ts
@@ -1,2 +1,2 @@
-export { runBaseTests, setupEnvironment } from '../runtime/workers/base'
+export { runBaseTests, setupBaseEnvironment as setupEnvironment } from '../runtime/workers/base'
export { init } from '../runtime/workers/init'
diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts
index 13e09ce403a9..fcd05a8a64ac 100644
--- a/packages/vitest/src/runtime/config.ts
+++ b/packages/vitest/src/runtime/config.ts
@@ -120,6 +120,8 @@ export interface SerializedConfig {
experimental: {
fsModuleCache: boolean
printImportBreakdown: boolean | undefined
+ viteModuleRunner: boolean
+ nodeLoader: boolean
}
}
diff --git a/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts
new file mode 100644
index 000000000000..e6435e3f9e5c
--- /dev/null
+++ b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts
@@ -0,0 +1,355 @@
+import type { MockedModule, MockedModuleType, ModuleMockContext, TestModuleMocker } from '@vitest/mocker'
+import type { MockFactory, MockOptions, PendingSuiteMock } from '../../types/mocker'
+import type { Traces } from '../../utils/traces'
+import { isAbsolute } from 'node:path'
+import { MockerRegistry, mockObject } from '@vitest/mocker'
+import { findMockRedirect } from '@vitest/mocker/redirect'
+
+export interface BareModuleMockerOptions {
+ /**
+ * @internal
+ */
+ traces: Traces
+ spyModule?: typeof import('@vitest/spy')
+ root: string
+ moduleDirectories: string[]
+ resolveId: (id: string, importer?: string) => Promise<{
+ id: string
+ file: string
+ url: string
+ } | null>
+ getCurrentTestFilepath: () => string | undefined
+}
+
+export class BareModuleMocker implements TestModuleMocker {
+ static pendingIds: PendingSuiteMock[] = []
+ protected spyModule?: typeof import('@vitest/spy')
+ protected primitives: {
+ Object: typeof Object
+ Function: typeof Function
+ RegExp: typeof RegExp
+ Array: typeof Array
+ Map: typeof Map
+ Error: typeof Error
+ Symbol: typeof Symbol
+ }
+
+ protected registries: Map = new Map()
+
+ protected mockContext: ModuleMockContext = {
+ callstack: null,
+ }
+
+ protected _otel: Traces
+
+ constructor(protected options: BareModuleMockerOptions) {
+ this._otel = options.traces
+ this.primitives = {
+ Object,
+ Error,
+ Function,
+ RegExp,
+ Symbol: globalThis.Symbol,
+ Array,
+ Map,
+ }
+
+ if (options.spyModule) {
+ this.spyModule = options.spyModule
+ }
+ }
+
+ protected get root(): string {
+ return this.options.root
+ }
+
+ protected get moduleDirectories(): string[] {
+ return this.options.moduleDirectories || []
+ }
+
+ protected getMockerRegistry(): MockerRegistry {
+ const suite = this.getSuiteFilepath()
+ if (!this.registries.has(suite)) {
+ this.registries.set(suite, new MockerRegistry())
+ }
+ return this.registries.get(suite)!
+ }
+
+ public reset(): void {
+ this.registries.clear()
+ }
+
+ protected invalidateModuleById(_id: string): void {
+ // implemented by mockers that control the module runner
+ }
+
+ protected isModuleDirectory(path: string): boolean {
+ return this.moduleDirectories.some(dir => path.includes(dir))
+ }
+
+ public getSuiteFilepath(): string {
+ return this.options.getCurrentTestFilepath() || 'global'
+ }
+
+ protected createError(message: string, codeFrame?: string): Error {
+ const Error = this.primitives.Error
+ const error = new Error(message)
+ Object.assign(error, { codeFrame })
+ return error
+ }
+
+ public async resolveId(rawId: string, importer?: string): Promise<{
+ id: string
+ url: string
+ external: string | null
+ }> {
+ return this._otel.$(
+ 'vitest.mocker.resolve_id',
+ {
+ attributes: {
+ 'vitest.module.raw_id': rawId,
+ 'vitest.module.importer': rawId,
+ },
+ },
+ async (span) => {
+ const result = await this.options.resolveId(rawId, importer)
+ if (!result) {
+ span.addEvent('could not resolve id, fallback to unresolved values')
+ const id = normalizeModuleId(rawId)
+ span.setAttributes({
+ 'vitest.module.id': id,
+ 'vitest.module.url': rawId,
+ 'vitest.module.external': id,
+ 'vitest.module.fallback': true,
+ })
+ return {
+ id,
+ url: rawId,
+ external: id,
+ }
+ }
+ // external is node_module or unresolved module
+ // for example, some people mock "vscode" and don't have it installed
+ const external
+ = !isAbsolute(result.file) || this.isModuleDirectory(result.file) ? normalizeModuleId(rawId) : null
+ const id = normalizeModuleId(result.id)
+ span.setAttributes({
+ 'vitest.module.id': id,
+ 'vitest.module.url': result.url,
+ 'vitest.module.external': external ?? false,
+ })
+ return {
+ ...result,
+ id,
+ external,
+ }
+ },
+ )
+ }
+
+ public async resolveMocks(): Promise {
+ if (!BareModuleMocker.pendingIds.length) {
+ return
+ }
+
+ await Promise.all(
+ BareModuleMocker.pendingIds.map(async (mock) => {
+ const { id, url, external } = await this.resolveId(
+ mock.id,
+ mock.importer,
+ )
+ if (mock.action === 'unmock') {
+ this.unmockPath(id)
+ }
+ if (mock.action === 'mock') {
+ this.mockPath(
+ mock.id,
+ id,
+ url,
+ external,
+ mock.type,
+ mock.factory,
+ )
+ }
+ }),
+ )
+
+ BareModuleMocker.pendingIds = []
+ }
+
+ // public method to avoid circular dependency
+ public getMockContext(): ModuleMockContext {
+ return this.mockContext
+ }
+
+ // path used to store mocked dependencies
+ public getMockPath(dep: string) {
+ return `mock:${dep}`
+ }
+
+ public getDependencyMock(id: string): MockedModule | undefined {
+ const registry = this.getMockerRegistry()
+ return registry.getById(fixLeadingSlashes(id))
+ }
+
+ public findMockRedirect(mockPath: string, external: string | null): string | null {
+ return findMockRedirect(this.root, mockPath, external)
+ }
+
+ public mockObject(
+ object: Record,
+ mockExports: Record = {},
+ behavior: 'automock' | 'autospy' = 'automock',
+ ): Record {
+ const createMockInstance = this.spyModule?.createMockInstance
+ if (!createMockInstance) {
+ throw this.createError(
+ '[vitest] `spyModule` is not defined. This is a Vitest error. Please open a new issue with reproduction.',
+ )
+ }
+ return mockObject(
+ {
+ globalConstructors: this.primitives,
+ createMockInstance,
+ type: behavior,
+ },
+ object,
+ mockExports,
+ )
+ }
+
+ public unmockPath(id: string): void {
+ const registry = this.getMockerRegistry()
+
+ registry.deleteById(id)
+ this.invalidateModuleById(id)
+ }
+
+ public mockPath(
+ originalId: string,
+ id: string,
+ url: string,
+ external: string | null,
+ mockType: MockedModuleType | undefined,
+ factory: MockFactory | undefined,
+ ): void {
+ const registry = this.getMockerRegistry()
+
+ if (mockType === 'manual') {
+ registry.register('manual', originalId, id, url, factory!)
+ }
+ else if (mockType === 'autospy') {
+ registry.register('autospy', originalId, id, url)
+ }
+ else {
+ const redirect = this.findMockRedirect(id, external)
+ if (redirect) {
+ registry.register('redirect', originalId, id, url, redirect)
+ }
+ else {
+ registry.register('automock', originalId, id, url)
+ }
+ }
+
+ // every time the mock is registered, we remove the previous one from the cache
+ this.invalidateModuleById(id)
+ }
+
+ async importActual(_rawId: string, _importer: string, _callstack?: string[] | null): Promise {
+ throw new Error(`importActual is not implemented`)
+ }
+
+ async importMock(_rawId: string, _importer: string, _callstack?: string[] | null): Promise {
+ throw new Error(`importMock is not implemented`)
+ }
+
+ public queueMock(
+ id: string,
+ importer: string,
+ factoryOrOptions?: MockFactory | MockOptions,
+ ): void {
+ const mockType = getMockType(factoryOrOptions)
+ BareModuleMocker.pendingIds.push({
+ action: 'mock',
+ id,
+ importer,
+ factory: typeof factoryOrOptions === 'function' ? factoryOrOptions : undefined,
+ type: mockType,
+ })
+ }
+
+ public queueUnmock(id: string, importer: string): void {
+ BareModuleMocker.pendingIds.push({
+ action: 'unmock',
+ id,
+ importer,
+ })
+ }
+}
+
+declare module 'vite/module-runner' {
+ interface EvaluatedModuleNode {
+ /**
+ * @internal
+ */
+ mockedExports?: Record
+ }
+}
+
+function getMockType(factoryOrOptions?: MockFactory | MockOptions): MockedModuleType {
+ if (!factoryOrOptions) {
+ return 'automock'
+ }
+ if (typeof factoryOrOptions === 'function') {
+ return 'manual'
+ }
+ return factoryOrOptions.spy ? 'autospy' : 'automock'
+}
+
+// unique id that is not available as "$bare_import" like "test"
+// https://nodejs.org/api/modules.html#built-in-modules-with-mandatory-node-prefix
+const prefixedBuiltins = new Set([
+ 'node:sea',
+ 'node:sqlite',
+ 'node:test',
+ 'node:test/reporters',
+])
+
+const isWindows = process.platform === 'win32'
+
+// transform file url to id
+// virtual:custom -> virtual:custom
+// \0custom -> \0custom
+// /root/id -> /id
+// /root/id.js -> /id.js
+// C:/root/id.js -> /id.js
+// C:\root\id.js -> /id.js
+// TODO: expose this in vite/module-runner
+export function normalizeModuleId(file: string): string {
+ if (prefixedBuiltins.has(file)) {
+ return file
+ }
+
+ // unix style, but Windows path still starts with the drive letter to check the root
+ const unixFile = slash(file)
+ .replace(/^\/@fs\//, isWindows ? '' : '/')
+ .replace(/^node:/, '')
+ .replace(/^\/+/, '/')
+
+ // if it's not in the root, keep it as a path, not a URL
+ return unixFile.replace(/^file:\//, '/')
+}
+
+const windowsSlashRE = /\\/g
+function slash(p: string): string {
+ return p.replace(windowsSlashRE, '/')
+}
+
+const multipleSlashRe = /^\/+/
+// module-runner incorrectly replaces file:///path with `///path`
+function fixLeadingSlashes(id: string): string {
+ if (id.startsWith('//')) {
+ return id.replace(multipleSlashRe, '/')
+ }
+ return id
+}
diff --git a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts
index f48f63143582..056aceb85a50 100644
--- a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts
+++ b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts
@@ -1,87 +1,32 @@
-import type { ManualMockedModule, MockedModule, MockedModuleType } from '@vitest/mocker'
+import type { ManualMockedModule, MockedModule } from '@vitest/mocker'
import type { EvaluatedModuleNode } from 'vite/module-runner'
-import type { MockFactory, MockOptions, PendingSuiteMock } from '../../types/mocker'
-import type { Traces } from '../../utils/traces'
+import type { BareModuleMockerOptions } from './bareModuleMocker'
import type { VitestModuleRunner } from './moduleRunner'
-import { isAbsolute, resolve } from 'node:path'
+import { resolve } from 'node:path'
import vm from 'node:vm'
-import { AutomockedModule, MockerRegistry, mockObject, RedirectedModule } from '@vitest/mocker'
-import { findMockRedirect } from '@vitest/mocker/redirect'
+import { AutomockedModule, RedirectedModule } from '@vitest/mocker'
import { distDir } from '../../paths'
+import { BareModuleMocker } from './bareModuleMocker'
const spyModulePath = resolve(distDir, 'spy.js')
-interface MockContext {
- /**
- * When mocking with a factory, this refers to the module that imported the mock.
- */
- callstack: null | string[]
-}
-
-export interface VitestMockerOptions {
+export interface VitestMockerOptions extends BareModuleMockerOptions {
context?: vm.Context
- /**
- * @internal
- */
- traces: Traces
- spyModule?: typeof import('@vitest/spy')
- root: string
- moduleDirectories: string[]
- resolveId: (id: string, importer?: string) => Promise<{
- id: string
- file: string
- url: string
- } | null>
- getCurrentTestFilepath: () => string | undefined
}
-export class VitestMocker {
- static pendingIds: PendingSuiteMock[] = []
- private spyModule?: typeof import('@vitest/spy')
- private primitives: {
- Object: typeof Object
- Function: typeof Function
- RegExp: typeof RegExp
- Array: typeof Array
- Map: typeof Map
- Error: typeof Error
- Symbol: typeof Symbol
- }
-
+export class VitestMocker extends BareModuleMocker {
private filterPublicKeys: (symbol | string)[]
- private registries = new Map()
-
- private mockContext: MockContext = {
- callstack: null,
- }
-
- private _otel: Traces
+ constructor(public moduleRunner: VitestModuleRunner, protected options: VitestMockerOptions) {
+ super(options)
- constructor(public moduleRunner: VitestModuleRunner, private options: VitestMockerOptions) {
const context = this.options.context
- this._otel = options.traces
if (context) {
this.primitives = vm.runInContext(
'({ Object, Error, Function, RegExp, Symbol, Array, Map })',
context,
)
}
- else {
- this.primitives = {
- Object,
- Error,
- Function,
- RegExp,
- Symbol: globalThis.Symbol,
- Array,
- Map,
- }
- }
-
- if (options.spyModule) {
- this.spyModule = options.spyModule
- }
const Symbol = this.primitives.Symbol
@@ -103,18 +48,10 @@ export class VitestMocker {
]
}
- private get root() {
- return this.options.root
- }
-
private get evaluatedModules() {
return this.moduleRunner.evaluatedModules
}
- private get moduleDirectories() {
- return this.options.moduleDirectories || []
- }
-
public async initializeSpyModule(): Promise {
if (this.spyModule) {
return
@@ -123,19 +60,11 @@ export class VitestMocker {
this.spyModule = await this.moduleRunner.import(spyModulePath)
}
- private getMockerRegistry() {
- const suite = this.getSuiteFilepath()
- if (!this.registries.has(suite)) {
- this.registries.set(suite, new MockerRegistry())
- }
- return this.registries.get(suite)!
- }
-
public reset(): void {
this.registries.clear()
}
- private invalidateModuleById(id: string) {
+ protected invalidateModuleById(id: string): void {
const mockId = this.getMockPath(id)
const node = this.evaluatedModules.getModuleById(mockId)
if (node) {
@@ -144,100 +73,6 @@ export class VitestMocker {
}
}
- private isModuleDirectory(path: string) {
- return this.moduleDirectories.some(dir => path.includes(dir))
- }
-
- public getSuiteFilepath(): string {
- return this.options.getCurrentTestFilepath() || 'global'
- }
-
- private createError(message: string, codeFrame?: string) {
- const Error = this.primitives.Error
- const error = new Error(message)
- Object.assign(error, { codeFrame })
- return error
- }
-
- public async resolveId(rawId: string, importer?: string): Promise<{
- id: string
- url: string
- external: string | null
- }> {
- return this._otel.$(
- 'vitest.mocker.resolve_id',
- {
- attributes: {
- 'vitest.module.raw_id': rawId,
- 'vitest.module.importer': rawId,
- },
- },
- async (span) => {
- const result = await this.options.resolveId(rawId, importer)
- if (!result) {
- span.addEvent('could not resolve id, fallback to unresolved values')
- const id = normalizeModuleId(rawId)
- span.setAttributes({
- 'vitest.module.id': id,
- 'vitest.module.url': rawId,
- 'vitest.module.external': id,
- 'vitest.module.fallback': true,
- })
- return {
- id,
- url: rawId,
- external: id,
- }
- }
- // external is node_module or unresolved module
- // for example, some people mock "vscode" and don't have it installed
- const external
- = !isAbsolute(result.file) || this.isModuleDirectory(result.file) ? normalizeModuleId(rawId) : null
- const id = normalizeModuleId(result.id)
- span.setAttributes({
- 'vitest.module.id': id,
- 'vitest.module.url': result.url,
- 'vitest.module.external': external ?? false,
- })
- return {
- ...result,
- id,
- external,
- }
- },
- )
- }
-
- public async resolveMocks(): Promise {
- if (!VitestMocker.pendingIds.length) {
- return
- }
-
- await Promise.all(
- VitestMocker.pendingIds.map(async (mock) => {
- const { id, url, external } = await this.resolveId(
- mock.id,
- mock.importer,
- )
- if (mock.action === 'unmock') {
- this.unmockPath(id)
- }
- if (mock.action === 'mock') {
- this.mockPath(
- mock.id,
- id,
- url,
- external,
- mock.type,
- mock.factory,
- )
- }
- }),
- )
-
- VitestMocker.pendingIds = []
- }
-
private ensureModule(id: string, url: string) {
const node = this.evaluatedModules.ensureModule(id, url)
// TODO
@@ -289,84 +124,6 @@ export class VitestMocker {
return moduleExports
}
- // public method to avoid circular dependency
- public getMockContext(): MockContext {
- return this.mockContext
- }
-
- // path used to store mocked dependencies
- public getMockPath(dep: string) {
- return `mock:${dep}`
- }
-
- public getDependencyMock(id: string): MockedModule | undefined {
- const registry = this.getMockerRegistry()
- return registry.getById(fixLeadingSlashes(id))
- }
-
- public findMockRedirect(mockPath: string, external: string | null): string | null {
- return findMockRedirect(this.root, mockPath, external)
- }
-
- public mockObject(
- object: Record,
- mockExports: Record = {},
- behavior: 'automock' | 'autospy' = 'automock',
- ): Record {
- const createMockInstance = this.spyModule?.createMockInstance
- if (!createMockInstance) {
- throw this.createError(
- '[vitest] `spyModule` is not defined. This is a Vitest error. Please open a new issue with reproduction.',
- )
- }
- return mockObject(
- {
- globalConstructors: this.primitives,
- createMockInstance,
- type: behavior,
- },
- object,
- mockExports,
- )
- }
-
- public unmockPath(id: string): void {
- const registry = this.getMockerRegistry()
-
- registry.deleteById(id)
- this.invalidateModuleById(id)
- }
-
- public mockPath(
- originalId: string,
- id: string,
- url: string,
- external: string | null,
- mockType: MockedModuleType | undefined,
- factory: MockFactory | undefined,
- ): void {
- const registry = this.getMockerRegistry()
-
- if (mockType === 'manual') {
- registry.register('manual', originalId, id, url, factory!)
- }
- else if (mockType === 'autospy') {
- registry.register('autospy', originalId, id, url)
- }
- else {
- const redirect = this.findMockRedirect(id, external)
- if (redirect) {
- registry.register('redirect', originalId, id, url, redirect)
- }
- else {
- registry.register('automock', originalId, id, url)
- }
- }
-
- // every time the mock is registered, we remove the previous one from the cache
- this.invalidateModuleById(id)
- }
-
public async importActual(
rawId: string,
importer: string,
@@ -500,29 +257,6 @@ export class VitestMocker {
return this.requestWithMockedModule(url, evaluatedNode, callstack, mock)
}
-
- public queueMock(
- id: string,
- importer: string,
- factoryOrOptions?: MockFactory | MockOptions,
- ): void {
- const mockType = getMockType(factoryOrOptions)
- VitestMocker.pendingIds.push({
- action: 'mock',
- id,
- importer,
- factory: typeof factoryOrOptions === 'function' ? factoryOrOptions : undefined,
- type: mockType,
- })
- }
-
- public queueUnmock(id: string, importer: string): void {
- VitestMocker.pendingIds.push({
- action: 'unmock',
- id,
- importer,
- })
- }
}
declare module 'vite/module-runner' {
@@ -533,61 +267,3 @@ declare module 'vite/module-runner' {
mockedExports?: Record
}
}
-
-function getMockType(factoryOrOptions?: MockFactory | MockOptions): MockedModuleType {
- if (!factoryOrOptions) {
- return 'automock'
- }
- if (typeof factoryOrOptions === 'function') {
- return 'manual'
- }
- return factoryOrOptions.spy ? 'autospy' : 'automock'
-}
-
-// unique id that is not available as "$bare_import" like "test"
-// https://nodejs.org/api/modules.html#built-in-modules-with-mandatory-node-prefix
-const prefixedBuiltins = new Set([
- 'node:sea',
- 'node:sqlite',
- 'node:test',
- 'node:test/reporters',
-])
-
-const isWindows = process.platform === 'win32'
-
-// transform file url to id
-// virtual:custom -> virtual:custom
-// \0custom -> \0custom
-// /root/id -> /id
-// /root/id.js -> /id.js
-// C:/root/id.js -> /id.js
-// C:\root\id.js -> /id.js
-// TODO: expose this in vite/module-runner
-function normalizeModuleId(file: string): string {
- if (prefixedBuiltins.has(file)) {
- return file
- }
-
- // unix style, but Windows path still starts with the drive letter to check the root
- const unixFile = slash(file)
- .replace(/^\/@fs\//, isWindows ? '' : '/')
- .replace(/^node:/, '')
- .replace(/^\/+/, '/')
-
- // if it's not in the root, keep it as a path, not a URL
- return unixFile.replace(/^file:\//, '/')
-}
-
-const windowsSlashRE = /\\/g
-function slash(p: string): string {
- return p.replace(windowsSlashRE, '/')
-}
-
-const multipleSlashRe = /^\/+/
-// module-runner incorrectly replaces file:///path with `///path`
-function fixLeadingSlashes(id: string): string {
- if (id.startsWith('//')) {
- return id.replace(multipleSlashRe, '/')
- }
- return id
-}
diff --git a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
index 9aad3895721a..6f7eb54ddbd7 100644
--- a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
+++ b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
@@ -6,6 +6,7 @@ import type { ExternalModulesExecutor } from '../external-executor'
import type { ModuleExecutionInfo } from './moduleDebug'
import type { VitestModuleEvaluator } from './moduleEvaluator'
import type { VitestTransportOptions } from './moduleTransport'
+import type { TestModuleRunner } from './testModuleRunner'
import * as viteModuleRunner from 'vite/module-runner'
import { Traces } from '../../utils/traces'
import { VitestMocker } from './moduleMocker'
@@ -42,7 +43,9 @@ function createImportMetaResolver() {
}
// @ts-expect-error overriding private method
-export class VitestModuleRunner extends viteModuleRunner.ModuleRunner {
+export class VitestModuleRunner
+ extends viteModuleRunner.ModuleRunner
+ implements TestModuleRunner {
public mocker: VitestMocker
public moduleExecutionInfo: ModuleExecutionInfo
private _otel: Traces
diff --git a/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts b/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts
new file mode 100644
index 000000000000..3ab191083eed
--- /dev/null
+++ b/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts
@@ -0,0 +1,285 @@
+import type { DeferPromise } from '@vitest/utils/helpers'
+import type { SourceMap } from 'magic-string'
+import module, { isBuiltin } from 'node:module'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import {
+ automockModule,
+ collectModuleExports,
+ createManualModuleSource,
+} from '@vitest/mocker/transforms'
+import { cleanUrl, createDefer } from '@vitest/utils/helpers'
+import { parse } from 'acorn'
+import { isAbsolute } from 'pathe'
+import { toBuiltin } from '../../utils/modules'
+import { BareModuleMocker, normalizeModuleId } from './bareModuleMocker'
+
+export class NativeModuleMocker extends BareModuleMocker {
+ public wrapDynamicImport(moduleFactory: () => Promise): Promise {
+ if (typeof moduleFactory === 'function') {
+ const promise = new Promise((resolve, reject) => {
+ this.resolveMocks().finally(() => {
+ moduleFactory().then(resolve, reject)
+ })
+ })
+ return promise
+ }
+ return moduleFactory
+ }
+
+ public resolveMockedModule(url: string, parentURL: string): module.ResolveFnOutput | undefined {
+ const filename = url.startsWith('file://') ? fileURLToPath(url) : url
+ const moduleId = normalizeModuleId(filename)
+
+ const mockedModule = this.getDependencyMock(moduleId)
+ if (!mockedModule) {
+ return
+ }
+ if (mockedModule.type === 'redirect') {
+ return {
+ url: pathToFileURL(mockedModule.redirect).toString(),
+ shortCircuit: true,
+ }
+ }
+ if (mockedModule.type === 'automock' || mockedModule.type === 'autospy') {
+ return {
+ url: injectQuery(url, parentURL, `mock=${mockedModule.type}`),
+ shortCircuit: true,
+ }
+ }
+ if (mockedModule.type === 'manual') {
+ return {
+ url: injectQuery(url, parentURL, 'mock=manual'),
+ shortCircuit: true,
+ }
+ }
+ }
+
+ public loadAutomock(url: string, result: module.LoadFnOutput): module.LoadFnOutput | undefined {
+ const filename = url.startsWith('file://') ? fileURLToPath(url) : url
+ const moduleId = cleanUrl(normalizeModuleId(filename))
+ let source: string | undefined
+ if (isBuiltin(moduleId)) {
+ const builtinModule = getBuiltinModule(moduleId)
+ const exports = Object.keys(builtinModule)
+ source = `
+import * as builtinModule from '${url}'
+
+${exports.map((key, index) => {
+ return `
+const __${index} = builtinModule["${key}"]
+export { __${index} as "${key}" }`.trim()
+})}`
+ }
+ else {
+ source = result.source?.toString()
+ }
+
+ if (source == null) {
+ return
+ }
+
+ const mockType = url.includes('mock=automock') ? 'automock' : 'autospy'
+ const transformedCode = transformCode(source, result.format || 'module', moduleId)
+ // failed to transform ts file
+ if (transformedCode == null) {
+ return
+ }
+
+ try {
+ const ms = automockModule(
+ transformedCode,
+ mockType,
+ code => parse(code, {
+ sourceType: 'module',
+ ecmaVersion: 'latest',
+ }),
+ { id: moduleId },
+ )
+ const transformed = ms.toString()
+ const map = ms.generateMap({ hires: 'boundary', source: moduleId })
+ const code = `${transformed}\n//# sourceMappingURL=${genSourceMapUrl(map)}`
+
+ return {
+ format: 'module',
+ source: code,
+ shortCircuit: true,
+ }
+ }
+ catch (cause) {
+ throw new Error(`Cannot automock '${url}' because it failed to parse.`, { cause })
+ }
+ }
+
+ public loadManualMock(url: string, result: module.LoadFnOutput): module.LoadFnOutput | undefined {
+ const filename = url.startsWith('file://') ? fileURLToPath(url) : url
+ const moduleId = cleanUrl(normalizeModuleId(filename))
+ const mockedModule = this.getDependencyMock(moduleId)
+ // should not be possible
+ if (mockedModule?.type !== 'manual') {
+ console.warn(`Vitest detected unregistered manual mock ${moduleId}. This is a bug in Vitest. Please, open a new issue with reproduction.`)
+ return
+ }
+
+ if (isBuiltin(moduleId)) {
+ const builtinModule = getBuiltinModule(toBuiltin(moduleId))
+ const exports = Object.keys(builtinModule)
+ const manualMockedModule = createManualModuleSource(moduleId, exports)
+
+ return {
+ format: 'module',
+ source: manualMockedModule,
+ shortCircuit: true,
+ }
+ }
+ if (!result.source) {
+ return
+ }
+
+ const source = result.source.toString()
+ const transformedCode = transformCode(source, result.format || 'module', moduleId)
+ if (transformedCode == null) {
+ return
+ }
+
+ const format = result.format?.startsWith('module') ? 'module' : 'commonjs'
+ try {
+ // we parse the module with es/cjs-module-lexer to find the original exports -- we assume the same ones are returned from the factory
+ // injecting new keys is not supported (and should not be advised anyway)
+ const exports = collectModuleExports(moduleId, transformedCode, format)
+ const manualMockedModule = createManualModuleSource(moduleId, exports)
+
+ return {
+ format: 'module',
+ source: manualMockedModule,
+ shortCircuit: true,
+ }
+ }
+ catch (cause) {
+ throw new Error(`Failed to mock '${url}'. See the cause for more information.`, { cause })
+ }
+ }
+
+ private processedModules = new Map()
+
+ public checkCircularManualMock(url: string): void {
+ const filename = url.startsWith('file://') ? fileURLToPath(url) : url
+ const id = cleanUrl(normalizeModuleId(filename))
+ this.processedModules.set(id, (this.processedModules.get(id) ?? 0) + 1)
+ // the module is mocked and requested a second time, let's resolve
+ // the factory function that will redefine the exports later
+ if (this.originalModulePromises.has(id)) {
+ const factoryPromise = this.factoryPromises.get(id)
+ this.originalModulePromises.get(id)?.resolve({ __factoryPromise: factoryPromise })
+ }
+ }
+
+ private originalModulePromises = new Map>()
+ private factoryPromises = new Map>()
+
+ // potential performance improvement:
+ // store by URL, not ids, no need to call url.*to* methods and normalizeModuleId
+ public getFactoryModule(id: string): any {
+ const registry = this.getMockerRegistry()
+ const mock = registry.getById(id)
+ if (!mock || mock.type !== 'manual') {
+ throw new Error(`Mock ${id} wasn't registered. This is probably a Vitest error. Please, open a new issue with reproduction.`)
+ }
+
+ const mockResult = mock.resolve()
+ if (mockResult instanceof Promise) {
+ // to avoid circular dependency, we resolve this function as {__factoryPromise} in `checkCircularManualMock`
+ // when it's requested the second time. then the exports are exposed as `undefined`,
+ // but later redefined when the promise is actually resolved
+ const promise = createDefer()
+ promise.finally(() => {
+ this.originalModulePromises.delete(id)
+ })
+ mockResult.then(promise.resolve, promise.reject).finally(() => {
+ this.factoryPromises.delete(id)
+ })
+ this.factoryPromises.set(id, mockResult)
+ this.originalModulePromises.set(id, promise)
+ // Node.js on windows processes all the files first, and then runs them
+ // unlike Node.js logic on Mac and Unix where it also runs the code while evaluating
+ // So on Linux/Mac this `if` won't be hit because `checkCircularManualMock` will resolve it
+ // And on Windows, the `checkCircularManualMock` will never have `originalModulePromises`
+ // because `getFactoryModule` is not called until the evaluation phase
+ // But if we track how many times the module was transformed,
+ // we can deduce when to return `__factoryPromise` to support circular modules
+ if ((this.processedModules.get(id) ?? 0) > 1) {
+ this.processedModules.set(id, (this.processedModules.get(id) ?? 1) - 1)
+ promise.resolve({ __factoryPromise: mockResult })
+ }
+ return promise
+ }
+
+ return mockResult
+ }
+
+ public importActual(rawId: string, importer: string): Promise {
+ const resolvedId = import.meta.resolve(rawId, pathToFileURL(importer).toString())
+ const url = new URL(resolvedId)
+ url.searchParams.set('mock', 'actual')
+ return import(url.toString())
+ }
+
+ public importMock(rawId: string, importer: string): Promise {
+ const resolvedId = import.meta.resolve(rawId, pathToFileURL(importer).toString())
+ // file is already mocked
+ if (resolvedId.includes('mock=')) {
+ return import(resolvedId)
+ }
+
+ const filename = fileURLToPath(resolvedId)
+ const external = !isAbsolute(filename) || this.isModuleDirectory(resolvedId)
+ ? normalizeModuleId(rawId)
+ : null
+ // file is not mocked, automock or redirect it
+ const redirect = this.findMockRedirect(filename, external)
+ if (redirect) {
+ return import(pathToFileURL(redirect).toString())
+ }
+
+ const url = new URL(resolvedId)
+ url.searchParams.set('mock', 'automock')
+ return import(url.toString())
+ }
+}
+
+const replacePercentageRE = /%/g
+function injectQuery(url: string, importer: string, queryToInject: string): string {
+ // encode percents for consistent behavior with pathToFileURL
+ // see #2614 for details
+ const resolvedUrl = new URL(
+ url.replace(replacePercentageRE, '%25'),
+ importer,
+ )
+ const { search, hash } = resolvedUrl
+ const pathname = cleanUrl(url)
+ return `${pathname}?${queryToInject}${search ? `&${search.slice(1)}` : ''}${
+ hash ?? ''
+ }`
+}
+
+let __require: NodeJS.Require | undefined
+function getBuiltinModule(moduleId: string) {
+ __require ??= module.createRequire(import.meta.url)
+ return __require(`${moduleId}?mock=actual`)
+}
+
+function genSourceMapUrl(map: SourceMap | string): string {
+ if (typeof map !== 'string') {
+ map = JSON.stringify(map)
+ }
+ return `data:application/json;base64,${Buffer.from(map).toString('base64')}`
+}
+
+function transformCode(code: string, format: string, filename: string) {
+ if (format.includes('typescript')) {
+ if (!module.stripTypeScriptTypes) {
+ throw new Error(`Cannot parse '${filename}' because "module.stripTypeScriptTypes" is not supported. Module mocking requires Node.js 22.15 or higher. This is NOT a bug of Vitest.`)
+ }
+ return module.stripTypeScriptTypes(code)
+ }
+ return code
+}
diff --git a/packages/vitest/src/runtime/moduleRunner/startModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts
similarity index 93%
rename from packages/vitest/src/runtime/moduleRunner/startModuleRunner.ts
rename to packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts
index e56a922e261d..898be8307353 100644
--- a/packages/vitest/src/runtime/moduleRunner/startModuleRunner.ts
+++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts
@@ -7,8 +7,8 @@ import type { CreateImportMeta } from './moduleRunner'
import fs from 'node:fs'
import { isBareImport } from '@vitest/utils/helpers'
import { isBrowserExternal, isBuiltin, toBuiltin } from '../../utils/modules'
+import { getSafeWorkerState } from '../utils'
import { getCachedVitestImport } from './cachedResolver'
-import { listenForErrors } from './errorCatcher'
import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator'
import { VitestMocker } from './moduleMocker'
import { VitestModuleRunner } from './moduleRunner'
@@ -38,16 +38,9 @@ const isWindows = process.platform === 'win32'
export function startVitestModuleRunner(options: ContextModuleRunnerOptions): VitestModuleRunner {
const traces = options.traces
const state = (): WorkerGlobalState =>
- // @ts-expect-error injected untyped global
- globalThis.__vitest_worker__ || options.state
+ getSafeWorkerState() || options.state
const rpc = () => state().rpc
- process.exit = (code = process.exitCode || 0): never => {
- throw new Error(`process.exit unexpectedly called with "${code}"`)
- }
-
- listenForErrors(state)
-
const environment = () => {
const environment = state().environment
return environment.viteEnvironment || environment.name
@@ -158,7 +151,7 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi
|| (typeof cause?.message === 'string' && cause.message.startsWith('Cannot find module \''))
) {
const error = new Error(
- `Cannot find ${isBareImport(id) ? 'package' : 'module'} '${id}'${importer ? ` imported from '${importer}'` : ''}`,
+ `Cannot find ${isBareImport(id) ? 'package' : 'module'} '${id}'${importer ? ` imported from ${importer}` : ''}`,
{ cause },
) as Error & { code: string }
error.code = 'ERR_MODULE_NOT_FOUND'
diff --git a/packages/vitest/src/runtime/moduleRunner/testModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/testModuleRunner.ts
new file mode 100644
index 000000000000..63c668f0c484
--- /dev/null
+++ b/packages/vitest/src/runtime/moduleRunner/testModuleRunner.ts
@@ -0,0 +1,8 @@
+import type { TestModuleMocker } from '@vitest/mocker'
+import type { ModuleExecutionInfo } from './moduleDebug'
+
+export interface TestModuleRunner {
+ moduleExecutionInfo?: ModuleExecutionInfo
+ mocker?: TestModuleMocker
+ import: (moduleId: string) => Promise
+}
diff --git a/packages/vitest/src/runtime/nodejsWorkerLoader.ts b/packages/vitest/src/runtime/nodejsWorkerLoader.ts
new file mode 100644
index 000000000000..97a5b62b1dde
--- /dev/null
+++ b/packages/vitest/src/runtime/nodejsWorkerLoader.ts
@@ -0,0 +1,55 @@
+import type { InitializeHook, ResolveHook } from 'node:module'
+import type { MessagePort } from 'node:worker_threads'
+
+let port: MessagePort
+
+export const initialize: InitializeHook = async ({
+ port: _port,
+ time: _time,
+}: {
+ port: MessagePort
+ time: string
+}) => {
+ port = _port
+}
+
+const NOW_LENGTH = Date.now().toString().length
+const REGEXP_VITEST = new RegExp(`%3Fvitest=\\d{${NOW_LENGTH}}`)
+
+export const resolve: ResolveHook = (specifier, context, defaultResolve) => {
+ const isVitest = specifier.includes('%3Fvitest=')
+ const result = defaultResolve(
+ isVitest ? specifier.replace(REGEXP_VITEST, '') : specifier,
+ context,
+ )
+ if (!port || !context?.parentURL) {
+ return result
+ }
+
+ if (typeof result === 'object' && 'then' in result) {
+ return result.then((resolved) => {
+ ensureModuleGraphEntry(resolved.url, context.parentURL!)
+ if (isVitest) {
+ resolved.url = `${resolved.url}?vitest=${Date.now()}`
+ }
+ return resolved
+ })
+ }
+
+ if (isVitest) {
+ result.url = `${result.url}?vitest=${Date.now()}`
+ }
+ ensureModuleGraphEntry(result.url, context.parentURL)
+ return result
+}
+
+function ensureModuleGraphEntry(url: string, parentURL: string) {
+ if (url.includes('/node_modules/')) {
+ return
+ }
+ port.postMessage({
+ event: 'register-module-graph-entry',
+ url,
+ parentURL,
+ })
+}
diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts
index 66de6a845c9c..710f74575496 100644
--- a/packages/vitest/src/runtime/runBaseTests.ts
+++ b/packages/vitest/src/runtime/runBaseTests.ts
@@ -2,7 +2,7 @@ import type { FileSpecification } from '@vitest/runner'
import type { Environment } from '../types/environment'
import type { Traces } from '../utils/traces'
import type { SerializedConfig } from './config'
-import type { VitestModuleRunner } from './moduleRunner/moduleRunner'
+import type { TestModuleRunner } from './moduleRunner/testModuleRunner'
import { performance } from 'node:perf_hooks'
import { collectTests, startTests } from '@vitest/runner'
import {
@@ -21,7 +21,7 @@ export async function run(
method: 'run' | 'collect',
files: FileSpecification[],
config: SerializedConfig,
- moduleRunner: VitestModuleRunner,
+ moduleRunner: TestModuleRunner,
environment: Environment,
traces: Traces,
): Promise {
@@ -50,7 +50,7 @@ export async function run(
async () => {
for (const file of files) {
if (config.isolate) {
- moduleRunner.mocker.reset()
+ moduleRunner.mocker?.reset()
resetModules(workerState.evaluatedModules, true)
}
diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts
index 27676a6a55b9..96a0d2969e85 100644
--- a/packages/vitest/src/runtime/runVmTests.ts
+++ b/packages/vitest/src/runtime/runVmTests.ts
@@ -1,7 +1,7 @@
import type { FileSpecification } from '@vitest/runner'
import type { Traces } from '../utils/traces'
import type { SerializedConfig } from './config'
-import type { VitestModuleRunner } from './moduleRunner/moduleRunner'
+import type { TestModuleRunner } from './moduleRunner/testModuleRunner'
import { createRequire } from 'node:module'
import { performance } from 'node:perf_hooks'
import timers from 'node:timers'
@@ -25,7 +25,7 @@ export async function run(
method: 'run' | 'collect',
files: FileSpecification[],
config: SerializedConfig,
- moduleRunner: VitestModuleRunner,
+ moduleRunner: TestModuleRunner,
traces: Traces,
): Promise {
const workerState = getWorkerState()
diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts
index 056e4fc8feaa..b7cdd53b1693 100644
--- a/packages/vitest/src/runtime/runners/index.ts
+++ b/packages/vitest/src/runtime/runners/index.ts
@@ -1,7 +1,7 @@
import type { VitestRunner, VitestRunnerConstructor } from '@vitest/runner'
import type { Traces } from '../../utils/traces'
import type { SerializedConfig } from '../config'
-import type { VitestModuleRunner } from '../moduleRunner/moduleRunner'
+import type { TestModuleRunner } from '../moduleRunner/testModuleRunner'
import { takeCoverageInsideWorker } from '../../integrations/coverage'
import { rpc } from '../rpc'
import { loadDiffConfig, loadSnapshotSerializers } from '../setup-common'
@@ -11,7 +11,7 @@ import { VitestTestRunner } from './test'
async function getTestRunnerConstructor(
config: SerializedConfig,
- moduleRunner: VitestModuleRunner,
+ moduleRunner: TestModuleRunner,
): Promise {
if (!config.runner) {
return (
@@ -31,7 +31,7 @@ async function getTestRunnerConstructor(
export async function resolveTestRunner(
config: SerializedConfig,
- moduleRunner: VitestModuleRunner,
+ moduleRunner: TestModuleRunner,
traces: Traces,
): Promise {
const TestRunner = await getTestRunnerConstructor(config, moduleRunner)
diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts
index 477173388d03..3f05d7f8acb4 100644
--- a/packages/vitest/src/runtime/runners/test.ts
+++ b/packages/vitest/src/runtime/runners/test.ts
@@ -39,10 +39,12 @@ export class VitestTestRunner implements VitestRunner {
public pool: string = this.workerState.ctx.pool
private _otel!: Traces
public viteEnvironment: string
+ private viteModuleRunner: boolean
constructor(public config: SerializedConfig) {
const environment = this.workerState.environment
this.viteEnvironment = environment.viteEnvironment || environment.name
+ this.viteModuleRunner = config.experimental.viteModuleRunner
}
importFile(filepath: string, source: VitestRunnerImportSource): unknown {
@@ -59,7 +61,12 @@ export class VitestTestRunner implements VitestRunner {
'code.file.path': filepath,
},
},
- () => this.moduleRunner.import(filepath),
+ () => {
+ if (!this.viteModuleRunner) {
+ filepath = `${filepath}?vitest=${Date.now()}`
+ }
+ return this.moduleRunner.import(filepath)
+ },
)
}
diff --git a/packages/vitest/src/runtime/utils.ts b/packages/vitest/src/runtime/utils.ts
index 6772287e066b..fcf660bda1af 100644
--- a/packages/vitest/src/runtime/utils.ts
+++ b/packages/vitest/src/runtime/utils.ts
@@ -20,6 +20,11 @@ export function getWorkerState(): WorkerGlobalState {
return workerState
}
+export function getSafeWorkerState(): WorkerGlobalState | undefined {
+ // @ts-expect-error untyped global
+ return globalThis[NAME_WORKER_STATE]
+}
+
export function provideWorkerState(context: any, state: WorkerGlobalState): WorkerGlobalState {
Object.defineProperty(context, NAME_WORKER_STATE, {
value: state,
diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts
index e0dc6af90a98..69f4eecfa908 100644
--- a/packages/vitest/src/runtime/workers/base.ts
+++ b/packages/vitest/src/runtime/workers/base.ts
@@ -1,28 +1,65 @@
+import type { TestModuleMocker } from '@vitest/mocker'
import type { Environment } from '../../types/environment'
import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker'
-import type { Traces } from '../../utils/traces'
-import type { VitestModuleRunner } from '../moduleRunner/moduleRunner'
-import type { ContextModuleRunnerOptions } from '../moduleRunner/startModuleRunner'
+import type { ContextModuleRunnerOptions } from '../moduleRunner/startVitestModuleRunner'
+import type { TestModuleRunner } from '../moduleRunner/testModuleRunner'
import { runInThisContext } from 'node:vm'
import * as spyModule from '@vitest/spy'
import { setupChaiConfig } from '../../integrations/chai/config'
import { loadEnvironment } from '../../integrations/env/loader'
+import { NativeModuleRunner } from '../../utils/nativeModuleRunner'
+import { Traces } from '../../utils/traces'
+import { listenForErrors } from '../moduleRunner/errorCatcher'
import { VitestEvaluatedModules } from '../moduleRunner/evaluatedModules'
import { createNodeImportMeta } from '../moduleRunner/moduleRunner'
-import { startVitestModuleRunner } from '../moduleRunner/startModuleRunner'
+import { startVitestModuleRunner } from '../moduleRunner/startVitestModuleRunner'
import { run } from '../runBaseTests'
-import { provideWorkerState } from '../utils'
+import { getSafeWorkerState, provideWorkerState } from '../utils'
-let _moduleRunner: VitestModuleRunner
+let _moduleRunner: TestModuleRunner
const evaluatedModules = new VitestEvaluatedModules()
const moduleExecutionInfo = new Map()
-function startModuleRunner(options: ContextModuleRunnerOptions) {
+async function startModuleRunner(options: ContextModuleRunnerOptions): Promise {
if (_moduleRunner) {
return _moduleRunner
}
+ process.exit = (code = process.exitCode || 0): never => {
+ throw new Error(`process.exit unexpectedly called with "${code}"`)
+ }
+ const state = () => getSafeWorkerState() || options.state
+
+ listenForErrors(state)
+
+ if (options.state.config.experimental.viteModuleRunner === false) {
+ const root = options.state.config.root
+ let mocker: TestModuleMocker | undefined
+ if (options.state.config.experimental.nodeLoader !== false) {
+ // this additionally imports acorn/magic-string
+ const { NativeModuleMocker } = await import('../moduleRunner/nativeModuleMocker')
+ mocker = new NativeModuleMocker({
+ async resolveId(id, importer) {
+ // TODO: use import.meta.resolve instead
+ return state().rpc.resolve(id, importer, '__vitest__')
+ },
+ root,
+ moduleDirectories: state().config.deps.moduleDirectories || ['/node_modules/'],
+ traces: options.traces || new Traces({ enabled: false }),
+ getCurrentTestFilepath() {
+ return state().filepath
+ },
+ spyModule,
+ })
+ }
+ _moduleRunner = new NativeModuleRunner(
+ root,
+ mocker,
+ )
+ return _moduleRunner
+ }
+
_moduleRunner = startVitestModuleRunner(options)
return _moduleRunner
}
@@ -31,7 +68,12 @@ let _currentEnvironment!: Environment
let _environmentTime: number
/** @experimental */
-export async function setupEnvironment(context: WorkerSetupContext): Promise<() => Promise> {
+export async function setupBaseEnvironment(context: WorkerSetupContext): Promise<() => Promise> {
+ if (context.config.experimental.viteModuleRunner === false) {
+ const { setupNodeLoaderHooks } = await import('./native')
+ await setupNodeLoaderHooks(context)
+ }
+
const startTime = performance.now()
const {
environment: { name: environmentName, options: environmentOptions },
@@ -53,6 +95,7 @@ export async function setupEnvironment(context: WorkerSetupContext): Promise<()
}
const otel = context.traces
+ // TODO: if viteModuleRunner is disabled, disable this
const { environment, loader } = await loadEnvironment(environmentName, config.root, rpc, otel)
_currentEnvironment = environment
const env = await otel.$(
@@ -108,7 +151,7 @@ export async function runBaseTests(method: 'run' | 'collect', state: WorkerGloba
})
})
- const moduleRunner = startModuleRunner({
+ const moduleRunner = await startModuleRunner({
state,
evaluatedModules: state.evaluatedModules,
spyModule,
diff --git a/packages/vitest/src/runtime/workers/forks.ts b/packages/vitest/src/runtime/workers/forks.ts
index 3cfb00b6320b..2cb537f6d4ef 100644
--- a/packages/vitest/src/runtime/workers/forks.ts
+++ b/packages/vitest/src/runtime/workers/forks.ts
@@ -1,4 +1,4 @@
-import { runBaseTests, setupEnvironment } from './base'
+import { runBaseTests, setupBaseEnvironment } from './base'
import workerInit from './init-forks'
-workerInit({ runTests: runBaseTests, setup: setupEnvironment })
+workerInit({ runTests: runBaseTests, setup: setupBaseEnvironment })
diff --git a/packages/vitest/src/runtime/workers/init-forks.ts b/packages/vitest/src/runtime/workers/init-forks.ts
index 2eb165815a1f..ffb8bf9b422e 100644
--- a/packages/vitest/src/runtime/workers/init-forks.ts
+++ b/packages/vitest/src/runtime/workers/init-forks.ts
@@ -30,7 +30,7 @@ processOn('error', onError)
export default function workerInit(options: {
runTests: (method: 'run' | 'collect', state: WorkerGlobalState, traces: Traces) => Promise
- setup?: (context: WorkerSetupContext) => Promise<() => Promise>
+ setup?: (context: WorkerSetupContext) => void | Promise<() => Promise>
}): void {
const { runTests } = options
diff --git a/packages/vitest/src/runtime/workers/init-threads.ts b/packages/vitest/src/runtime/workers/init-threads.ts
index 42cfd0c18cab..a3dfa1cb36cc 100644
--- a/packages/vitest/src/runtime/workers/init-threads.ts
+++ b/packages/vitest/src/runtime/workers/init-threads.ts
@@ -9,7 +9,7 @@ if (isMainThread || !parentPort) {
export default function workerInit(options: {
runTests: (method: 'run' | 'collect', state: WorkerGlobalState, traces: Traces) => Promise
- setup?: (context: WorkerSetupContext) => Promise<() => Promise>
+ setup?: (context: WorkerSetupContext) => void | Promise<() => Promise>
}): void {
const { runTests } = options
diff --git a/packages/vitest/src/runtime/workers/init.ts b/packages/vitest/src/runtime/workers/init.ts
index c97402e1d1c4..3bbd3794061e 100644
--- a/packages/vitest/src/runtime/workers/init.ts
+++ b/packages/vitest/src/runtime/workers/init.ts
@@ -23,7 +23,7 @@ export function init(worker: Options): void {
let runPromise: Promise | undefined
let isRunning = false
- let workerTeardown: (() => Promise) | undefined
+ let workerTeardown: (() => Promise) | undefined | void
let setupContext!: WorkerSetupContext
function send(response: WorkerResponse) {
diff --git a/packages/vitest/src/runtime/workers/native.ts b/packages/vitest/src/runtime/workers/native.ts
new file mode 100644
index 000000000000..a2c15d5d9a83
--- /dev/null
+++ b/packages/vitest/src/runtime/workers/native.ts
@@ -0,0 +1,240 @@
+import type { SourceMap } from 'magic-string'
+import type { WorkerSetupContext } from '../../types/worker'
+import type { NativeModuleMocker } from '../moduleRunner/nativeModuleMocker'
+import module, { isBuiltin } from 'node:module'
+import { fileURLToPath } from 'node:url'
+import { MessageChannel } from 'node:worker_threads'
+import { hoistMocks, initSyntaxLexers } from '@vitest/mocker/transforms'
+import { cleanUrl } from '@vitest/utils/helpers'
+import { parse } from 'acorn'
+import MagicString from 'magic-string'
+import { resolve } from 'pathe'
+import c from 'tinyrainbow'
+import { distDir } from '../../paths'
+
+const NOW_LENGTH = Date.now().toString().length
+const REGEXP_VITEST = new RegExp(`%3Fvitest=\\d{${NOW_LENGTH}}`)
+const REGEXP_MOCK_ACTUAL = /\?mock=actual/
+
+export async function setupNodeLoaderHooks(worker: WorkerSetupContext): Promise {
+ if (module.setSourceMapsSupport) {
+ module.setSourceMapsSupport(true)
+ }
+
+ if (worker.config.experimental.nodeLoader !== false) {
+ await initSyntaxLexers()
+ }
+
+ if (typeof module.registerHooks === 'function') {
+ module.registerHooks({
+ resolve(specifier, context, nextResolve) {
+ if (specifier.includes('mock=actual')) {
+ // url is already resolved by `importActual`
+ const moduleId = specifier.replace(REGEXP_MOCK_ACTUAL, '')
+ return {
+ url: moduleId,
+ format: isBuiltin(moduleId) ? 'builtin' : undefined,
+ shortCircuit: true,
+ }
+ }
+
+ const isVitest = specifier.includes('%3Fvitest=')
+ const result = nextResolve(
+ isVitest ? specifier.replace(REGEXP_VITEST, '') : specifier,
+ context,
+ )
+
+ // avoid /node_modules/ for performance reasons
+ if (context.parentURL && result.url && !result.url.includes('/node_modules/')) {
+ worker.rpc.ensureModuleGraphEntry(result.url, context.parentURL).catch(() => {
+ // ignore errors
+ })
+ }
+
+ // this is require for in-source tests to be invalidated if
+ // one of the files already imported it in --maxWorkers=1 --no-isolate
+ if (isVitest) {
+ result.url = `${result.url}?vitest=${Date.now()}`
+ }
+ if (
+ // nodeLoader disables mocking and `importmeta.vitest`
+ worker.config.experimental.nodeLoader === false
+ // something is wrong if there is no parent, we should not mock anything
+ || !context.parentURL
+ // ignore any transforms inside of `vitest` module
+ || result.url.includes(distDir)
+ || context.parentURL?.toString().includes(distDir)
+ ) {
+ return result
+ }
+
+ const mocker = getNativeMocker()
+ const mockedResult = mocker?.resolveMockedModule(result.url, context.parentURL)
+ if (mockedResult != null) {
+ return mockedResult
+ }
+
+ return result
+ },
+ load: worker.config.experimental.nodeLoader === false
+ ? undefined
+ : createLoadHook(worker),
+ })
+ }
+ else if (module.register) {
+ if (worker.config.experimental.nodeLoader !== false) {
+ console.warn(
+ `${c.bgYellow(' WARNING ')} "module.registerHooks" is not supported in Node.js ${process.version}. This means that some features like module mocking or in-source testing are not supported. Upgrade your Node.js version to at least 22.15 or disable "experimental.nodeLoader" flag manually.\n`,
+ )
+ }
+ const { port1, port2 } = new MessageChannel()
+ port1.unref()
+ port2.unref()
+ port1.on('message', (data) => {
+ if (!data || typeof data !== 'object') {
+ return
+ }
+ switch (data.event) {
+ case 'register-module-graph-entry': {
+ const { url, parentURL } = data
+ worker.rpc.ensureModuleGraphEntry(url, parentURL)
+ return
+ }
+ default: {
+ console.error('Unknown message event:', data.event)
+ }
+ }
+ })
+ module.register('#test-loader', {
+ parentURL: import.meta.url,
+ data: { port: port2 },
+ transferList: [port2],
+ })
+ }
+ else if (!process.versions.deno && !process.versions.bun) {
+ console.warn(
+ '"module.registerHooks" and "module.register" are not supported. Some Vitest features may not work. Please, use Node.js 18.19.0 or higher.',
+ )
+ }
+}
+
+function replaceInSourceMarker(url: string, source: string, ms: () => MagicString) {
+ const re = /import\.meta\.vitest/g
+ let match: RegExpExecArray | null
+ let overridden = false
+ // eslint-disable-next-line no-cond-assign
+ while ((match = re.exec(source))) {
+ const { index, '0': code } = match
+ overridden = true
+ // should it support process.vitest for CJS modules?
+ ms().overwrite(index, index + code.length, 'IMPORT_META_VITEST') // the length is the same
+ }
+ if (overridden) {
+ const filename = resolve(fileURLToPath(url))
+ ms().prepend(`const IMPORT_META_VITEST = typeof __vitest_worker__ !== 'undefined' && __vitest_worker__.filepath === "${filename.replace(/"/g, '\\"')}" ? __vitest_index__ : undefined;`)
+ }
+}
+
+const ignoreFormats = new Set([
+ 'addon',
+ 'builtin',
+ 'wasm',
+])
+
+function createLoadHook(_worker: WorkerSetupContext): module.LoadHookSync {
+ return (url, context, nextLoad) => {
+ const result: module.LoadFnOutput = url.includes('mock=manual') && isBuiltin(cleanUrl(url))
+ ? { format: 'module' } // avoid resolving the builtin module that is supposed to be mocked
+ : nextLoad(url, context)
+ if (
+ (result.format && ignoreFormats.has(result.format))
+ || url.includes(distDir)
+ ) {
+ return result
+ }
+
+ const mocker = getNativeMocker()
+
+ mocker?.checkCircularManualMock(url)
+
+ if (url.includes('mock=automock') || url.includes('mock=autospy')) {
+ const automockedResult = mocker?.loadAutomock(url, result)
+ if (automockedResult != null) {
+ return automockedResult
+ }
+ return result
+ }
+
+ if (url.includes('mock=manual')) {
+ const mockedResult = mocker?.loadManualMock(url, result)
+ if (mockedResult != null) {
+ return mockedResult
+ }
+ return result
+ }
+
+ // ignore non-vitest modules for performance reasons,
+ // vi.hoisted and vi.mock won't work outside of test files or setup files
+ if (!result.source || !url.includes('vitest=')) {
+ return result
+ }
+
+ const filename = url.startsWith('file://') ? fileURLToPath(url) : url
+ const source = result.source.toString()
+ const transformedCode = result.format?.includes('typescript')
+ ? module.stripTypeScriptTypes(source)
+ : source
+
+ let _ms: MagicString | undefined
+ const ms = () => _ms || (_ms = new MagicString(source))
+
+ if (source.includes('import.meta.vitest')) {
+ replaceInSourceMarker(url, source, ms)
+ }
+
+ hoistMocks(
+ transformedCode,
+ filename,
+ code => parse(code, {
+ ecmaVersion: 'latest',
+ sourceType: result.format === 'module' || result.format === 'module-typescript' || result.format === 'typescript'
+ ? 'module'
+ : 'script',
+ }),
+ {
+ magicString: ms,
+ globalThisAccessor: '"__vitest_mocker__"',
+ },
+ )
+
+ let code: string
+ if (_ms) {
+ const transformed = _ms.toString()
+ const map = _ms.generateMap({ hires: 'boundary', source: filename })
+ code = `${transformed}\n//# sourceMappingURL=${genSourceMapUrl(map)}`
+ }
+ else {
+ code = source
+ }
+
+ return {
+ format: result.format,
+ shortCircuit: true,
+ source: code,
+ }
+ }
+}
+
+function genSourceMapUrl(map: SourceMap | string): string {
+ if (typeof map !== 'string') {
+ map = JSON.stringify(map)
+ }
+ return `data:application/json;base64,${Buffer.from(map).toString('base64')}`
+}
+
+function getNativeMocker() {
+ const mocker: NativeModuleMocker | undefined
+ // @ts-expect-error untyped global
+ = typeof __vitest_mocker__ !== 'undefined' ? __vitest_mocker__ : undefined
+ return mocker
+}
diff --git a/packages/vitest/src/runtime/workers/threads.ts b/packages/vitest/src/runtime/workers/threads.ts
index 82830296bfd2..f9556933628b 100644
--- a/packages/vitest/src/runtime/workers/threads.ts
+++ b/packages/vitest/src/runtime/workers/threads.ts
@@ -1,4 +1,4 @@
-import { runBaseTests, setupEnvironment } from './base'
+import { runBaseTests, setupBaseEnvironment } from './base'
import workerInit from './init-threads'
-workerInit({ runTests: runBaseTests, setup: setupEnvironment })
+workerInit({ runTests: runBaseTests, setup: setupBaseEnvironment })
diff --git a/packages/vitest/src/runtime/workers/types.ts b/packages/vitest/src/runtime/workers/types.ts
index 82dea0000909..757ab8264816 100644
--- a/packages/vitest/src/runtime/workers/types.ts
+++ b/packages/vitest/src/runtime/workers/types.ts
@@ -13,5 +13,5 @@ export interface VitestWorker extends WorkerRpcOptions {
runTests: (state: WorkerGlobalState, traces: Traces) => Awaitable
collectTests: (state: WorkerGlobalState, traces: Traces) => Awaitable
- setup?: (context: WorkerSetupContext) => Promise<() => Promise>
+ setup?: (context: WorkerSetupContext) => void | Promise<() => Promise>
}
diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts
index 80af0e4eb99e..bca071dc5935 100644
--- a/packages/vitest/src/runtime/workers/vm.ts
+++ b/packages/vitest/src/runtime/workers/vm.ts
@@ -1,5 +1,5 @@
import type { Context } from 'node:vm'
-import type { WorkerGlobalState } from '../../types/worker'
+import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker'
import type { Traces } from '../../utils/traces'
import { pathToFileURL } from 'node:url'
import { isContext, runInContext } from 'node:vm'
@@ -8,9 +8,10 @@ import { loadEnvironment } from '../../integrations/env/loader'
import { distDir } from '../../paths'
import { createCustomConsole } from '../console'
import { ExternalModulesExecutor } from '../external-executor'
+import { listenForErrors } from '../moduleRunner/errorCatcher'
import { getDefaultRequestStubs } from '../moduleRunner/moduleEvaluator'
import { createNodeImportMeta } from '../moduleRunner/moduleRunner'
-import { startVitestModuleRunner, VITEST_VM_CONTEXT_SYMBOL } from '../moduleRunner/startModuleRunner'
+import { startVitestModuleRunner, VITEST_VM_CONTEXT_SYMBOL } from '../moduleRunner/startVitestModuleRunner'
import { provideWorkerState } from '../utils'
import { FileMap } from '../vm/file-map'
@@ -89,6 +90,12 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS
viteClientModule: stubs['/@vite/client'],
})
+ process.exit = (code = process.exitCode || 0): never => {
+ throw new Error(`process.exit unexpectedly called with "${code}"`)
+ }
+
+ listenForErrors(() => state)
+
const moduleRunner = startVitestModuleRunner({
context,
evaluatedModules: state.evaluatedModules,
@@ -146,3 +153,9 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS
)
}
}
+
+export function setupVmWorker(context: WorkerSetupContext): void {
+ if (context.config.experimental.viteModuleRunner === false) {
+ throw new Error(`Pool "${context.pool}" cannot run with "experimental.viteModuleRunner: false". Please, use "threads" or "forks" instead.`)
+ }
+}
diff --git a/packages/vitest/src/runtime/workers/vmForks.ts b/packages/vitest/src/runtime/workers/vmForks.ts
index db709ff7aa7e..8aa7a95ed6c0 100644
--- a/packages/vitest/src/runtime/workers/vmForks.ts
+++ b/packages/vitest/src/runtime/workers/vmForks.ts
@@ -1,4 +1,4 @@
import workerInit from './init-forks'
-import { runVmTests } from './vm'
+import { runVmTests, setupVmWorker } from './vm'
-workerInit({ runTests: runVmTests })
+workerInit({ runTests: runVmTests, setup: setupVmWorker })
diff --git a/packages/vitest/src/runtime/workers/vmThreads.ts b/packages/vitest/src/runtime/workers/vmThreads.ts
index 7777770edd91..8d025b9b62eb 100644
--- a/packages/vitest/src/runtime/workers/vmThreads.ts
+++ b/packages/vitest/src/runtime/workers/vmThreads.ts
@@ -1,4 +1,4 @@
import workerInit from './init-threads'
-import { runVmTests } from './vm'
+import { runVmTests, setupVmWorker } from './vm'
-workerInit({ runTests: runVmTests })
+workerInit({ runTests: runVmTests, setup: setupVmWorker })
diff --git a/packages/vitest/src/types/mocker.ts b/packages/vitest/src/types/mocker.ts
index 3a5835ceb719..565d75819e7b 100644
--- a/packages/vitest/src/types/mocker.ts
+++ b/packages/vitest/src/types/mocker.ts
@@ -1,19 +1,15 @@
-import type { MockedModuleType } from '@vitest/mocker'
+import type { MockedModuleType, ModuleMockFactory } from '@vitest/mocker'
-type Promisable = T | Promise
-
-export type MockFactoryWithHelper = (
- importOriginal: () => Promise,
-) => Promisable>
-export type MockFactory = () => any
-export interface MockOptions {
- spy?: boolean
-}
+export type {
+ ModuleMockFactory as MockFactory,
+ ModuleMockFactoryWithHelper as MockFactoryWithHelper,
+ ModuleMockOptions as MockOptions,
+} from '@vitest/mocker'
export interface PendingSuiteMock {
id: string
importer: string
action: 'mock' | 'unmock'
type?: MockedModuleType
- factory?: MockFactory
+ factory?: ModuleMockFactory
}
diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts
index caf16e6283d2..900627d594b7 100644
--- a/packages/vitest/src/types/rpc.ts
+++ b/packages/vitest/src/types/rpc.ts
@@ -27,6 +27,8 @@ export interface RuntimeRPC {
snapshotSaved: (snapshot: SnapshotResult) => void
resolveSnapshotPath: (testPath: string) => string
+
+ ensureModuleGraphEntry: (id: string, importer: string) => void
}
export interface RunnerRPC {
diff --git a/packages/vitest/src/utils/environments.ts b/packages/vitest/src/utils/environments.ts
index e0a360f2841f..34cd1b76f5ba 100644
--- a/packages/vitest/src/utils/environments.ts
+++ b/packages/vitest/src/utils/environments.ts
@@ -2,19 +2,15 @@ import type { DevEnvironment } from 'vite'
import type { TestProject } from '../node/project'
export function getTestFileEnvironment(project: TestProject, testFile: string, browser = false): DevEnvironment | undefined {
- let environment: DevEnvironment | undefined
if (browser) {
- environment = project.browser?.vite.environments.client
+ return project.browser?.vite.environments.client
}
else {
for (const name in project.vite.environments) {
const env = project.vite.environments[name]
if (env.moduleGraph.getModuleById(testFile)) {
- environment = env
- break
+ return env
}
}
}
-
- return environment
}
diff --git a/packages/vitest/src/utils/graph.ts b/packages/vitest/src/utils/graph.ts
index b96d4c3fe062..c6b2bbf4e540 100644
--- a/packages/vitest/src/utils/graph.ts
+++ b/packages/vitest/src/utils/graph.ts
@@ -15,7 +15,9 @@ export async function getModuleGraph(
const project = ctx.getProjectByName(projectName)
- const environment = getTestFileEnvironment(project, testFilePath, browser)
+ const environment = project.config.experimental.viteModuleRunner === false
+ ? project.vite.environments.__vitest__
+ : getTestFileEnvironment(project, testFilePath, browser)
if (!environment) {
throw new Error(`Cannot find environment for ${testFilePath}`)
@@ -54,7 +56,6 @@ export async function getModuleGraph(
return id
}
inlined.add(id)
- // TODO: cached modules don't have that!
const mods = Array.from(mod.importedModules).filter(
i => i.id && !i.id.includes('/vitest/dist/'),
)
diff --git a/packages/vitest/src/utils/nativeModuleRunner.ts b/packages/vitest/src/utils/nativeModuleRunner.ts
new file mode 100644
index 000000000000..2ee3346a2475
--- /dev/null
+++ b/packages/vitest/src/utils/nativeModuleRunner.ts
@@ -0,0 +1,41 @@
+import type { TestModuleMocker } from '@vitest/mocker'
+import { pathToFileURL } from 'node:url'
+import { isAbsolute, resolve } from 'pathe'
+import { ModuleRunner } from 'vite/module-runner'
+
+export class NativeModuleRunner extends ModuleRunner {
+ /**
+ * @internal
+ */
+ public mocker?: TestModuleMocker
+
+ constructor(private root: string, mocker?: TestModuleMocker) {
+ super({
+ hmr: false,
+ sourcemapInterceptor: false,
+ transport: {
+ invoke() {
+ throw new Error('Unexpected `invoke`')
+ },
+ },
+ })
+ this.mocker = mocker
+ if (mocker) {
+ Object.defineProperty(globalThis, '__vitest_mocker__', {
+ configurable: true,
+ writable: true,
+ value: mocker,
+ })
+ }
+ }
+
+ override import(moduleId: string): Promise {
+ if (!isAbsolute(moduleId)) {
+ moduleId = resolve(this.root, moduleId)
+ }
+ if (!moduleId.startsWith('file://')) {
+ moduleId = pathToFileURL(moduleId).toString()
+ }
+ return import(moduleId)
+ }
+}
diff --git a/packages/vitest/suppress-warnings.cjs b/packages/vitest/suppress-warnings.cjs
index 65a3106e3c78..7ab439dc571e 100644
--- a/packages/vitest/suppress-warnings.cjs
+++ b/packages/vitest/suppress-warnings.cjs
@@ -7,6 +7,7 @@ const ignoreWarnings = new Set([
'Custom ESM Loaders is an experimental feature and might change at any time',
'VM Modules is an experimental feature and might change at any time',
'VM Modules is an experimental feature. This feature could change at any time',
+ 'stripTypeScriptTypes is an experimental feature and might change at any time',
])
const { emit } = process
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2103754dbee4..84efbceb5805 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -725,6 +725,12 @@ importers:
acorn-walk:
specifier: 'catalog:'
version: 8.3.4
+ cjs-module-lexer:
+ specifier: ^2.1.1
+ version: 2.1.1
+ es-module-lexer:
+ specifier: ^2.0.0
+ version: 2.0.0
msw:
specifier: 'catalog:'
version: 2.12.3(@types/node@24.10.1)(typescript@5.9.3)
@@ -969,8 +975,8 @@ importers:
specifier: workspace:*
version: link:../utils
es-module-lexer:
- specifier: ^1.7.0
- version: 1.7.0
+ specifier: ^2.0.0
+ version: 2.0.0
expect-type:
specifier: ^1.2.2
version: 1.2.2
@@ -1047,6 +1053,9 @@ importers:
'@types/sinonjs__fake-timers':
specifier: ^8.1.5
version: 8.1.5(patch_hash=0218b33f433e26861380c2b90c757bde6fea871cb988083c0bd4a9a1f6c00252)
+ acorn:
+ specifier: 8.11.3
+ version: 8.11.3(patch_hash=62f89b815dbd769c8a4d5b19b1f6852f28922ecb581d876c8a8377d05c2483c4)
acorn-walk:
specifier: 'catalog:'
version: 8.3.4
@@ -1455,6 +1464,24 @@ importers:
specifier: workspace:*
version: link:../../packages/vitest
+ test/native:
+ devDependencies:
+ '@types/node':
+ specifier: ^24.10.1
+ version: 24.10.1
+ '@vitest/ui':
+ specifier: workspace:*
+ version: link:../../packages/ui
+ tinyspy:
+ specifier: ^4.0.4
+ version: 4.0.4
+ vite:
+ specifier: ^7.1.5
+ version: 7.1.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.2)
+ vitest:
+ specifier: workspace:*
+ version: link:../../packages/vitest
+
test/optimize-deps:
devDependencies:
'@vitest/test-dep-url':
@@ -5267,6 +5294,9 @@ packages:
cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
+ cjs-module-lexer@2.1.1:
+ resolution: {integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==}
+
clean-regexp@1.0.0:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'}
@@ -5791,8 +5821,8 @@ packages:
es-get-iterator@1.1.3:
resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==}
- es-module-lexer@1.7.0:
- resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
@@ -13534,6 +13564,8 @@ snapshots:
cjs-module-lexer@1.4.3: {}
+ cjs-module-lexer@2.1.1: {}
+
clean-regexp@1.0.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -14094,7 +14126,7 @@ snapshots:
isarray: 2.0.5
stop-iteration-iterator: 1.0.0
- es-module-lexer@1.7.0: {}
+ es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1:
dependencies:
diff --git a/test/cli/test/no-module-runner.test.ts b/test/cli/test/no-module-runner.test.ts
new file mode 100644
index 000000000000..d1a38f1cf2a6
--- /dev/null
+++ b/test/cli/test/no-module-runner.test.ts
@@ -0,0 +1,540 @@
+import module from 'node:module'
+import { expect, test } from 'vitest'
+import { replaceRoot, runInlineTests } from '../../test-utils'
+
+describe.runIf(module.registerHooks)('when module.registerHooks is supported', () => {
+ test.skip('cannot run viteModuleRunner: false in "vmForks"', async () => {
+ const { stderr } = await runInlineTests({
+ 'base.test.js': ``,
+ 'vitest.config.js': {
+ test: {
+ pool: 'vmForks',
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ expect(stderr).toContain(`Pool "vmForks" cannot run with "experimental.viteModuleRunner: false". Please, use "threads" or "forks" instead.`)
+ })
+
+ test.skip('cannot run viteModuleRunner: false in "vmThreads"', async () => {
+ const { stderr } = await runInlineTests({
+ 'base.test.js': ``,
+ 'vitest.config.js': {
+ test: {
+ pool: 'vmThreads',
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ expect(stderr).toContain(`Pool "vmThreads" cannot run with "experimental.viteModuleRunner: false". Please, use "threads" or "forks" instead.`)
+ })
+
+ test('can run tests in threads worker', async () => {
+ const { stderr, testTree } = await runInlineTests({
+ 'base1.test.js': `
+test('hello world', () => {})
+ `,
+ 'base2.test.js': `
+test('hello world', () => {})
+ `,
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ pool: 'threads',
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ expect(stderr).toBe('')
+ expect(testTree()).toMatchInlineSnapshot(`
+ {
+ "base1.test.js": {
+ "hello world": "passed",
+ },
+ "base2.test.js": {
+ "hello world": "passed",
+ },
+ }
+ `)
+ })
+
+ test('can run tests in forks worker', async () => {
+ const { stderr, testTree } = await runInlineTests({
+ 'base1.test.js': `
+test('hello world', () => {})
+ `,
+ 'base2.test.js': `
+test('hello world', () => {})
+ `,
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ pool: 'forks',
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ expect(stderr).toBe('')
+ expect(testTree()).toMatchInlineSnapshot(`
+ {
+ "base1.test.js": {
+ "hello world": "passed",
+ },
+ "base2.test.js": {
+ "hello world": "passed",
+ },
+ }
+ `)
+ })
+
+ test('ESM files don\'t have access to CJS globals', async () => {
+ const { stderr, testTree } = await runInlineTests({
+ 'base.test.js': `
+test('no globals', () => {
+ expect(typeof __dirname).toBe('undefined')
+ expect(typeof __filename).toBe('undefined')
+ expect(typeof exports).toBe('undefined')
+ expect(typeof module).toBe('undefined')
+})
+
+test('no vite globals', () => {
+ expect(typeof import.meta).toBe('object')
+ expect(typeof import.meta.env).toBe('undefined')
+})
+ `,
+ 'package.json': JSON.stringify({
+ name: '@test/no-globals-cjs-esm-native-module-runner',
+ type: 'module',
+ }),
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ expect(stderr).toBe('')
+ expect(testTree()).toMatchInlineSnapshot(`
+ {
+ "base.test.js": {
+ "no globals": "passed",
+ "no vite globals": "passed",
+ },
+ }
+ `)
+ })
+
+ test('CJS files don\'t have access to ESM globals', async () => {
+ const { stderr, testTree } = await runInlineTests({
+ 'base.test.js': `
+test('has CJS globals', () => {
+ expect(typeof __dirname).toBe('string')
+ expect(typeof __filename).toBe('string')
+ expect(typeof exports).toBe('object')
+ expect(typeof module).toBe('object')
+})
+ `,
+ 'esm.test.js': `
+test('no esm globals', () => {
+ expect(typeof import.meta).toBe('undefined')
+})
+ `,
+ 'package.json': JSON.stringify({
+ name: '@test/no-globals-esm-cjs-native-module-runner',
+ type: 'commonjs',
+ }),
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ expect(stderr).toContain('Cannot use \'import.meta\' outside a module')
+ expect(testTree()).toMatchInlineSnapshot(`
+ {
+ "base.test.js": {
+ "has CJS globals": "passed",
+ },
+ "esm.test.js": {},
+ }
+ `)
+ })
+
+ test('in-source tests in CJS work', async () => {
+ const { stderr, testTree } = await runInlineTests({
+ 'in-source.js': `
+if (import.meta.vitest) {
+ const { test, expect } = import.meta.vitest
+ test('works', () => {
+ expect(import.meta.vitest).toBeDefined()
+ })
+}
+ `,
+ 'package.json': JSON.stringify({
+ name: '@test/no-globals-esm-cjs-native-module-runner',
+ type: 'commonjs',
+ }),
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ includeSource: ['./in-source.js'],
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+ expect(stderr).toBe('')
+ expect(testTree()).toMatchInlineSnapshot(`
+ {
+ "in-source.js": {
+ "works": "passed",
+ },
+ }
+ `)
+ })
+
+ test('in-source tests in ESM work', async () => {
+ const { stderr, testTree } = await runInlineTests({
+ 'in-source.js': `
+if (import.meta.vitest) {
+ const { test, expect } = import.meta.vitest
+ test('works', () => {
+ expect(import.meta.vitest).toBeDefined()
+ })
+}
+ `,
+ 'package.json': JSON.stringify({
+ name: '@test/no-globals-cjs-esm-native-module-runner',
+ type: 'module',
+ }),
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ includeSource: ['./in-source.js'],
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+ expect(stderr).toBe('')
+ expect(testTree()).toMatchInlineSnapshot(`
+ {
+ "in-source.js": {
+ "works": "passed",
+ },
+ }
+ `)
+ })
+
+ test('in-source test doesn\'t run when imported by actual test', async () => {
+ const { stderr, testTree } = await runInlineTests({
+ 'add.js': /* js */`
+export function add(a, b) {
+ return a + b
+}
+
+if (import.meta.vitest) {
+ const { test, expect } = import.meta.vitest
+ test('adds', () => {
+ expect(add(1, 1)).toBe(2)
+ })
+}
+ `,
+ 'add.test.js': /* js */`
+import { add } from './add.js'
+import { test, expect } from 'vitest'
+test('add is only once', () => {
+ expect(add(1, 1)).toBe(2)
+})
+ `,
+ 'package.json': JSON.stringify({
+ name: '@test/native-module-runner',
+ type: 'module',
+ }),
+ 'vitest.config.js': {
+ test: {
+ includeSource: ['./in-source.js'],
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+ expect(stderr).toBe('')
+ expect(testTree()).toMatchInlineSnapshot(`
+ {
+ "add.test.js": {
+ "add is only once": "passed",
+ },
+ }
+ `)
+ })
+
+ test('cannot import JS file without extension in ESM', async () => {
+ const { stderr, root } = await runInlineTests({
+ 'add.js': /* js */`
+export function add(a, b) {
+ return a + b
+}
+ `,
+ 'add.test.js': /* js */`
+import { add } from './add' // no extension
+test('not reported')
+ `,
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+ expect(replaceRoot(stderr, root)).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL add.test.js [ add.test.js ]
+ Error: Cannot find module '/add' imported from /add.test.js
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
+ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND', url: '/add' }
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
+
+ "
+ `)
+ })
+
+ test('cannot import TS without extension in ESM', async () => {
+ const { stderr, root } = await runInlineTests({
+ 'add.ts': /* js */`
+export function add(a, b) {
+ return a + b
+}
+ `,
+ 'add.test.js': /* js */`
+import { add } from './add.js' // JS extension is NOT valid
+test('not reported')
+ `,
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+ expect(replaceRoot(stderr, root)).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL add.test.js [ add.test.js ]
+ Error: Cannot find module '/add.js' imported from /add.test.js
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
+ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND', url: '/add.js' }
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
+
+ "
+ `)
+ })
+
+ test.runIf(process.features.typescript)('an error in in-source tests is shown correctly', async () => {
+ const { stderr, errorTree } = await runInlineTests({
+ 'in-source.ts': `
+interface HelloWorld {
+ isStripped: true
+}
+
+if (import.meta.vitest) {
+ const {
+ test,
+ expect
+ } = import.meta.vitest
+
+ test('works', () => {
+ throw new Error('test throws correctly')
+ })
+}
+ `,
+ 'package.json': JSON.stringify({
+ name: '@test/no-globals-cjs-esm-native-module-runner',
+ type: 'module',
+ }),
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ includeTaskLocation: true,
+ includeSource: ['./in-source.ts'],
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+ expect(stderr).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL in-source.ts:12:3 > works
+ Error: test throws correctly
+ ❯ in-source.ts:13:11
+ 11|
+ 12| test('works', () => {
+ 13| throw new Error('test throws correctly')
+ | ^
+ 14| })
+ 15| }
+
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
+
+ "
+ `)
+ expect(errorTree()).toMatchInlineSnapshot(`
+ {
+ "in-source.ts": {
+ "works": [
+ "test throws correctly",
+ ],
+ },
+ }
+ `)
+ })
+
+ test('error in the sync mock factory is reporter', async () => {
+ const { stderr } = await runInlineTests({
+ 'add.js': /* js */`
+export function add(a, b) {
+ return a + b
+}
+ `,
+ 'add.test.js': /* js */`
+import { add } from './add.js'
+vi.mock('./add.js', () => {
+ throw new Error('error from factory')
+})
+test('not reported')
+ `,
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ expect(stderr).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL add.test.js [ add.test.js ]
+ Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock
+ ❯ add.js?mock=manual:2:65
+ ❯ add.test.js:2:1
+ 1|
+ 2| import { add } from './add.js'
+ | ^
+ 3| vi.mock('./add.js', () => {
+ 4| throw new Error('error from factory')
+
+ Caused by: Error: error from factory
+ ❯ add.test.js:4:9
+ ❯ add.js?mock=manual:2:65
+ ❯ add.test.js:2:1
+
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
+
+ "
+ `)
+ })
+
+ test('error in the async mock factory is reporter', async () => {
+ // async error is reported also as unhandled exception
+ // I wasn't able to figure out what doesn't handle it properly
+ // and assume it is something internal in Node.js
+ // If it wasn't caught by us, we wouldn't have gotten the "suite" issue
+ const { stderr } = await runInlineTests({
+ 'add.js': /* js */`
+export function add(a, b) {
+ return a + b
+}
+ `,
+ 'add.test.js': /* js */`
+import { add } from './add.js'
+vi.mock('./add.js', async () => {
+ await Promise.resolve()
+ throw new Error('error from factory')
+})
+test('not reported')
+ `,
+ 'vitest.config.js': {
+ test: {
+ globals: true,
+ experimental: {
+ viteModuleRunner: false,
+ },
+ },
+ },
+ })
+
+ // "slice" remove the stack from unhandled error because it referenced built artifacts
+ expect(stderr.split('\n').slice(0, 17).join('\n')).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL add.test.js [ add.test.js ]
+ Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock
+ Caused by: Error: error from factory
+ ❯ add.test.js:5:9
+
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
+
+ ⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯
+
+ Vitest caught 1 unhandled error during the test run.
+ This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
+
+ ⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
+ Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock"
+ `)
+ })
+})
+
+describe.runIf(!module.registerHooks)('when module.registerHooks is not supported', () => {
+ test('prints a warning if nodeLoader is not enabled', async () => {
+ const { stderr } = await runInlineTests({
+ 'basic.test.js': `test('skip')`,
+ }, {
+ globals: true,
+ experimental: {
+ viteModuleRunner: false,
+ },
+ })
+ expect(stderr).toContain(`WARNING "module.registerHooks" is not supported in Node.js ${process.version}. This means that some features like module mocking or in-source testing are not supported. Upgrade your Node.js version to at least 22.15 or disable "experimental.nodeLoader" flag manually.`)
+ })
+})
+
+// TODO: watch mode tests
+// TODO: inline snapshot tests
diff --git a/test/core/test/browserAutomocker.test.ts b/test/core/test/browserAutomocker.test.ts
index 5bff499de1d5..eb816a821d8e 100644
--- a/test/core/test/browserAutomocker.test.ts
+++ b/test/core/test/browserAutomocker.test.ts
@@ -17,7 +17,7 @@ export function test() {}
__esModule: true,
["test"]: test,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["test"]
export {
__vitest_mocked_0__ as test,
@@ -37,7 +37,7 @@ export class Test {}
__esModule: true,
["Test"]: Test,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["Test"]
export {
__vitest_mocked_0__ as Test,
@@ -57,7 +57,7 @@ export default class Test {}
__esModule: true,
["__vitest_default"]: __vitest_default,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"]
export {
__vitest_mocked_0__ as default,
@@ -75,7 +75,7 @@ export default function test() {}
__esModule: true,
["__vitest_default"]: __vitest_default,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"]
export {
__vitest_mocked_0__ as default,
@@ -93,7 +93,7 @@ export default someVariable
__esModule: true,
["__vitest_default"]: __vitest_default,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"]
export {
__vitest_mocked_0__ as default,
@@ -111,7 +111,7 @@ export default 'test'
__esModule: true,
["__vitest_default"]: __vitest_default,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"]
export {
__vitest_mocked_0__ as default,
@@ -129,7 +129,7 @@ export default null
__esModule: true,
["__vitest_default"]: __vitest_default,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"]
export {
__vitest_mocked_0__ as default,
@@ -149,7 +149,7 @@ export default test
__esModule: true,
["__vitest_default"]: __vitest_default,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"]
export {
__vitest_mocked_0__ as default,
@@ -175,7 +175,7 @@ export const test3 = function test4() {}
["test2"]: test2,
["test3"]: test3,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["test"]
const __vitest_mocked_1__ = __vitest_mocked_module__["test2"]
const __vitest_mocked_2__ = __vitest_mocked_module__["test3"]
@@ -203,7 +203,7 @@ export const [...rest2] = []
["rest"]: rest,
["rest2"]: rest2,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["test"]
const __vitest_mocked_1__ = __vitest_mocked_module__["rest"]
const __vitest_mocked_2__ = __vitest_mocked_module__["rest2"]
@@ -230,7 +230,7 @@ export const test = 2, test2 = 3, test4 = () => {}, test5 = function() {};
["test4"]: test4,
["test5"]: test5,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["test"]
const __vitest_mocked_1__ = __vitest_mocked_module__["test2"]
const __vitest_mocked_2__ = __vitest_mocked_module__["test4"]
@@ -263,7 +263,7 @@ export const { ...rest2 } = {}
["alias"]: alias,
["rest2"]: rest2,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["test"]
const __vitest_mocked_1__ = __vitest_mocked_module__["rest"]
const __vitest_mocked_2__ = __vitest_mocked_module__["alias"]
@@ -293,7 +293,7 @@ it('correctly parses export specifiers', () => {
["test"]: test,
["test"]: test,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["test"]
const __vitest_mocked_1__ = __vitest_mocked_module__["test"]
const __vitest_mocked_2__ = __vitest_mocked_module__["test"]
@@ -324,7 +324,7 @@ export { testing as name4 } from './another-module'
["__vitest_imported_2__"]: __vitest_imported_2__,
["__vitest_imported_3__"]: __vitest_imported_3__,
}
- const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock")
+ const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, undefined, "automock")
const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_imported_0__"]
const __vitest_mocked_1__ = __vitest_mocked_module__["__vitest_imported_1__"]
const __vitest_mocked_2__ = __vitest_mocked_module__["__vitest_imported_2__"]
diff --git a/test/core/test/dynamic-import.test.ts b/test/core/test/dynamic-import.test.ts
index 0285a44389cf..233c2dbde9df 100644
--- a/test/core/test/dynamic-import.test.ts
+++ b/test/core/test/dynamic-import.test.ts
@@ -7,7 +7,7 @@ test('dynamic import', async () => {
}
catch (err: any) {
expect(err.message).toBe(
- `Cannot find package 'non-existing-module' imported from '${import.meta.filename.replace(/\\/g, '/')}'`,
+ `Cannot find package 'non-existing-module' imported from ${import.meta.filename.replace(/\\/g, '/')}`,
)
expect(err.code).toBe('ERR_MODULE_NOT_FOUND')
}
diff --git a/test/core/test/imports.test.ts b/test/core/test/imports.test.ts
index eed9d40a91a4..65933178f3f6 100644
--- a/test/core/test/imports.test.ts
+++ b/test/core/test/imports.test.ts
@@ -88,9 +88,9 @@ test('dynamic import has null prototype', async () => {
test('dynamic import throws an error', async () => {
const path = './some-unknown-path'
const imported = import(path)
- await expect(imported).rejects.toThrowError(`Cannot find module '/test/some-unknown-path' imported from '${resolve(import.meta.filename)}'`)
+ await expect(imported).rejects.toThrowError(`Cannot find module '/test/some-unknown-path' imported from ${resolve(import.meta.filename)}`)
// @ts-expect-error path does not exist
- await expect(() => import('./some-unknown-path')).rejects.toThrowError(`Cannot find module '/test/some-unknown-path' imported from '${resolve(import.meta.filename)}'`)
+ await expect(() => import('./some-unknown-path')).rejects.toThrowError(`Cannot find module '/test/some-unknown-path' imported from ${resolve(import.meta.filename)}`)
})
test('can import @vite/client', async () => {
diff --git a/test/core/test/injector-mock.test.ts b/test/core/test/injector-mock.test.ts
index 8e0f93831b20..bd2a0abe0673 100644
--- a/test/core/test/injector-mock.test.ts
+++ b/test/core/test/injector-mock.test.ts
@@ -2,7 +2,7 @@ import type { HoistMocksPluginOptions } from '../../../packages/mocker/src/node/
import { stripVTControlCharacters } from 'node:util'
import { parseAst } from 'vite'
import { describe, expect, it, test } from 'vitest'
-import { hoistMocks } from '../../../packages/mocker/src/node/hoistMocksPlugin'
+import { hoistMockAndResolve } from '../../../packages/mocker/src/node/hoistMocksPlugin'
import { generateCodeFrame } from '../../../packages/vitest/src/node/printError.js'
function parse(code: string, options: any): any {
@@ -20,11 +20,11 @@ const hoistMocksOptions: HoistMocksPluginOptions = {
}
function hoistSimple(code: string, url = '') {
- return hoistMocks(code, url, parse, hoistMocksOptions)
+ return hoistMockAndResolve(code, url, parse, hoistMocksOptions)
}
function hoistSimpleCode(code: string) {
- return hoistMocks(code, '/test.js', parse, hoistMocksOptions)?.code.trim()
+ return hoistMockAndResolve(code, '/test.js', parse, hoistMocksOptions)?.code.trim()
}
test('hoists mock, unmock, hoisted', () => {
@@ -111,7 +111,7 @@ test('correctly access import', () => {
describe('transform', () => {
const hoistSimpleCodeWithoutMocks = (code: string) => {
- return hoistMocks(`import {vi} from "vitest";\n${code}\nvi.mock('faker');\n`, '/test.js', parse, hoistMocksOptions)?.code.trim()
+ return hoistMockAndResolve(`import {vi} from "vitest";\n${code}\nvi.mock('faker');\n`, '/test.js', parse, hoistMocksOptions)?.code.trim()
}
test('default import', () => {
expect(
@@ -126,7 +126,7 @@ describe('transform', () => {
test('can use imported variables inside the mock', () => {
expect(
- hoistMocks(`
+ hoistMockAndResolve(`
import { vi } from 'vitest'
import user from './user'
import { admin } from './admin'
@@ -153,7 +153,7 @@ vi.mock('./mock.js', () => ({
test('can use hoisted variables inside the mock', () => {
expect(
- hoistMocks(`
+ hoistMockAndResolve(`
import { vi } from 'vitest'
const { user, admin } = await vi.hoisted(async () => {
const { default: user } = await import('./user')
@@ -1352,7 +1352,7 @@ test('hi', () => {
describe('throws an error when nodes are incompatible', () => {
const getErrorWhileHoisting = (code: string) => {
try {
- hoistMocks(code, '/test.js', parse, hoistMocksOptions)?.code.trim()
+ hoistMockAndResolve(code, '/test.js', parse, hoistMocksOptions)?.code.trim()
}
catch (err: any) {
return err
diff --git a/test/native/jsSetup.js b/test/native/jsSetup.js
new file mode 100644
index 000000000000..ed68693ea36c
--- /dev/null
+++ b/test/native/jsSetup.js
@@ -0,0 +1,3 @@
+import { initJsSetup } from './src/setups.ts'
+
+initJsSetup()
diff --git a/test/native/package.json b/test/native/package.json
new file mode 100644
index 000000000000..48d30e989641
--- /dev/null
+++ b/test/native/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@vitest/test-native",
+ "type": "module",
+ "private": true,
+ "license": "MIT",
+ "main": "index.js",
+ "scripts": {
+ "test": "vitest",
+ "test:ui": "vitest --ui",
+ "test:run": "vitest run"
+ },
+ "devDependencies": {
+ "@types/node": "^24.10.1",
+ "@vitest/ui": "latest",
+ "tinyspy": "^4.0.4",
+ "vite": "latest",
+ "vitest": "latest"
+ },
+ "stackblitz": {
+ "startCommand": "npm run test:ui"
+ }
+}
diff --git a/test/native/src/__mocks__/redirect.ts b/test/native/src/__mocks__/redirect.ts
new file mode 100644
index 000000000000..186b120756be
--- /dev/null
+++ b/test/native/src/__mocks__/redirect.ts
@@ -0,0 +1 @@
+export default true
diff --git a/test/native/src/basic.ts b/test/native/src/basic.ts
new file mode 100644
index 000000000000..8d0cff0ac972
--- /dev/null
+++ b/test/native/src/basic.ts
@@ -0,0 +1,4 @@
+export const squared = (n: number) => n * n
+export * from './dependency.ts'
+export { helloMe as hello } from './index.ts'
+export default 'hello world'
diff --git a/test/native/src/dependency.ts b/test/native/src/dependency.ts
new file mode 100644
index 000000000000..6a1a78a6ae52
--- /dev/null
+++ b/test/native/src/dependency.ts
@@ -0,0 +1,3 @@
+export function add(a: number, b: number) {
+ return a + b
+}
diff --git a/test/native/src/from-async.ts b/test/native/src/from-async.ts
new file mode 100644
index 000000000000..89dce9551671
--- /dev/null
+++ b/test/native/src/from-async.ts
@@ -0,0 +1,4 @@
+import { answer } from './mock-async.ts'
+
+const topLevelAnswer = answer
+export { topLevelAnswer }
diff --git a/test/native/src/in-source/add.ts b/test/native/src/in-source/add.ts
new file mode 100644
index 000000000000..b24eb2fd46d0
--- /dev/null
+++ b/test/native/src/in-source/add.ts
@@ -0,0 +1,13 @@
+export function add(...args: number[]) {
+ return args.reduce((a, b) => a + b, 0)
+}
+
+// in-source test suites
+if (import.meta.vitest) {
+ const { it, expect } = import.meta.vitest
+ it('add', () => {
+ expect(add()).toBe(0)
+ expect(add(1)).toBe(1)
+ expect(add(1, 2, 3)).toBe(6)
+ })
+}
diff --git a/test/native/src/in-source/fibonacci.ts b/test/native/src/in-source/fibonacci.ts
new file mode 100644
index 000000000000..47a80cefe7c6
--- /dev/null
+++ b/test/native/src/in-source/fibonacci.ts
@@ -0,0 +1,24 @@
+import { add } from './add.ts'
+
+export function fibonacci(n: number): number {
+ if (n < 2) {
+ return n
+ }
+ return add(fibonacci(n - 1), fibonacci(n - 2))
+}
+
+if (import.meta.vitest) {
+ const { it, expect } = import.meta.vitest
+ it('fibonacci', () => {
+ expect(fibonacci(0)).toBe(0)
+ expect(fibonacci(1)).toBe(1)
+ expect(fibonacci(2)).toBe(1)
+ expect(fibonacci(3)).toBe(2)
+ expect(fibonacci(4)).toBe(3)
+ expect(fibonacci(5)).toBe(5)
+ expect(fibonacci(6)).toBe(8)
+ expect(fibonacci(7)).toBe(13)
+ expect(fibonacci(8)).toBe(21)
+ expect(fibonacci(9)).toMatchInlineSnapshot('34')
+ })
+}
diff --git a/test/native/src/in-source/index.ts b/test/native/src/in-source/index.ts
new file mode 100644
index 000000000000..e2baf82e9cec
--- /dev/null
+++ b/test/native/src/in-source/index.ts
@@ -0,0 +1,2 @@
+export * from './add.ts'
+export * from './fibonacci.ts'
diff --git a/test/native/src/index.ts b/test/native/src/index.ts
new file mode 100644
index 000000000000..35d08a14e4d5
--- /dev/null
+++ b/test/native/src/index.ts
@@ -0,0 +1,2 @@
+export { add, hello, squared } from './basic.ts'
+export const helloMe = 'world'
diff --git a/test/native/src/minus.ts b/test/native/src/minus.ts
new file mode 100644
index 000000000000..96fc2c21324e
--- /dev/null
+++ b/test/native/src/minus.ts
@@ -0,0 +1,3 @@
+export function minus(a: number, b: number): number {
+ return a - b
+}
diff --git a/test/native/src/mock-async.ts b/test/native/src/mock-async.ts
new file mode 100644
index 000000000000..6206fbace5a0
--- /dev/null
+++ b/test/native/src/mock-async.ts
@@ -0,0 +1 @@
+export const answer = 0
diff --git a/test/native/src/mock-js.d.ts b/test/native/src/mock-js.d.ts
new file mode 100644
index 000000000000..475978aa79b0
--- /dev/null
+++ b/test/native/src/mock-js.d.ts
@@ -0,0 +1 @@
+export declare function mockJs(): number
diff --git a/test/native/src/mock-js.js b/test/native/src/mock-js.js
new file mode 100644
index 000000000000..3da476882268
--- /dev/null
+++ b/test/native/src/mock-js.js
@@ -0,0 +1,3 @@
+export function mockJs() {
+ return 0
+}
diff --git a/test/native/src/mock-sync.ts b/test/native/src/mock-sync.ts
new file mode 100644
index 000000000000..926657346d75
--- /dev/null
+++ b/test/native/src/mock-sync.ts
@@ -0,0 +1,3 @@
+export function syncMock() {
+ return 0
+}
diff --git a/test/native/src/no-mock.ts b/test/native/src/no-mock.ts
new file mode 100644
index 000000000000..fb296d9e2b55
--- /dev/null
+++ b/test/native/src/no-mock.ts
@@ -0,0 +1,3 @@
+export function notMocked() {
+ return true
+}
diff --git a/test/native/src/redirect.ts b/test/native/src/redirect.ts
new file mode 100644
index 000000000000..2069ec8470a3
--- /dev/null
+++ b/test/native/src/redirect.ts
@@ -0,0 +1,2 @@
+throw new Error('Should be redirected!')
+export default false
diff --git a/test/native/src/setups.ts b/test/native/src/setups.ts
new file mode 100644
index 000000000000..971f9e407198
--- /dev/null
+++ b/test/native/src/setups.ts
@@ -0,0 +1,19 @@
+import { vi } from 'vitest'
+
+let jsSetup = false
+let tsSetup = false
+
+export const initJsSetup = vi.fn(() => {
+ jsSetup = true
+})
+
+export const initTsSetup = vi.fn(() => {
+ tsSetup = true
+})
+
+export function getSetupStates() {
+ return {
+ jsSetup,
+ tsSetup,
+ }
+}
diff --git a/test/native/test/__snapshots__/suite.test.ts.snap b/test/native/test/__snapshots__/suite.test.ts.snap
new file mode 100644
index 000000000000..8e09188b06ac
--- /dev/null
+++ b/test/native/test/__snapshots__/suite.test.ts.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`suite name > snapshot 1`] = `
+{
+ "foo": "bar",
+}
+`;
diff --git a/test/native/test/automock.test.ts b/test/native/test/automock.test.ts
new file mode 100644
index 000000000000..2134cebfbbec
--- /dev/null
+++ b/test/native/test/automock.test.ts
@@ -0,0 +1,9 @@
+import { expect, test, vi } from 'vitest'
+import { squared } from '../src/basic.ts'
+
+vi.mock(import('../src/basic.ts'))
+
+test('squared is mocked', () => {
+ expect(vi.isMockFunction(squared)).toBe(true)
+ expect(squared(2)).toBe(undefined)
+})
diff --git a/test/native/test/autospy.test.ts b/test/native/test/autospy.test.ts
new file mode 100644
index 000000000000..bcc56bfc4895
--- /dev/null
+++ b/test/native/test/autospy.test.ts
@@ -0,0 +1,10 @@
+import { expect, test, vi } from 'vitest'
+import { squared } from '../src/basic.ts'
+
+vi.mock(import('../src/basic.ts'), { spy: true })
+
+test('squared is mocked', () => {
+ expect(vi.isMockFunction(squared)).toBe(true)
+ expect(squared(2)).toBe(4)
+ expect(squared).toHaveBeenCalled()
+})
diff --git a/test/native/test/basic.test.ts b/test/native/test/basic.test.ts
new file mode 100644
index 000000000000..9dc5288b132f
--- /dev/null
+++ b/test/native/test/basic.test.ts
@@ -0,0 +1,33 @@
+import { assert, expect, test } from 'vitest'
+import { squared } from '../src/basic.ts'
+// importing from the barrel file
+import { add } from '../src/in-source/index.ts'
+
+// Edit an assertion and save to see HMR in action
+
+test('Math.sqrt()', () => {
+ expect(Math.sqrt(4)).toBe(2)
+ expect(Math.sqrt(144)).toBe(12)
+ expect(Math.sqrt(2)).toBe(Math.SQRT2)
+})
+
+test('add', () => {
+ expect(add(1, 2)).toBe(3)
+})
+
+test('Squared', () => {
+ expect(squared(2)).toBe(4)
+ expect(squared(12)).toBe(144)
+})
+
+test('JSON', () => {
+ const input = {
+ foo: 'hello',
+ bar: 'world',
+ }
+
+ const output = JSON.stringify(input)
+
+ expect(output).eq('{"foo":"hello","bar":"world"}')
+ assert.deepEqual(JSON.parse(output), input, 'matches original')
+})
diff --git a/test/native/test/manual-mock.test.ts b/test/native/test/manual-mock.test.ts
new file mode 100644
index 000000000000..ef137c99c959
--- /dev/null
+++ b/test/native/test/manual-mock.test.ts
@@ -0,0 +1,99 @@
+import { readFileSync } from 'node:fs'
+import * as tinyspy from 'tinyspy'
+import { expect, test, vi } from 'vitest'
+import { add, hello, helloMe, squared } from '../src/index.ts'
+import { minus } from '../src/minus.ts'
+import { mockJs } from '../src/mock-js.js'
+import { syncMock } from '../src/mock-sync.ts'
+
+// automocked
+vi.mock(import('tinyspy'))
+
+vi.mock('node:fs', async (importOriginal) => {
+ // can import actual built-in module
+ const _originalModule = await importOriginal()
+ return {
+ readFileSync: vi.fn<() => string>(() => 'mock'),
+ }
+})
+
+vi.mock(import('../src/minus.ts'), async (importOriginal) => {
+ // original module can be imported
+ const _originalModule = await importOriginal()
+ return {
+ minus() {
+ return 42
+ },
+ }
+})
+
+vi.mock(import('../src/mock-js.js'), async () => {
+ return {
+ mockJs() {
+ return 42
+ },
+ }
+})
+
+vi.mock(import('../src/mock-sync.ts'), () => {
+ return {
+ syncMock() {
+ return 42
+ },
+ }
+})
+
+vi.mock(import('../src/index.ts'), async (importOriginal) => {
+ // doesn't hang even though it's circular!
+ const originalModule = await importOriginal()
+ // doesn't have the "hello" value yet because this factory is not resolved
+ expect(originalModule.hello).toBe(undefined)
+ return {
+ squared() {
+ return 42
+ },
+ add() {
+ return 42
+ },
+ helloMe: originalModule.helloMe,
+ hello: 'mock' as 'world', // keep the TS syntax to check that file is transformed
+ } as const
+})
+
+test('builtin node modules are mocked', () => {
+ expect(vi.isMockFunction(readFileSync)).toBe(true)
+})
+
+test('deps in node_modules are mocked', () => {
+ expect(vi.isMockFunction(tinyspy.createInternalSpy)).toBe(true)
+})
+
+test('exports are mocked', () => {
+ expect(hello).toBe('mock')
+ expect(helloMe).toBe('world')
+ expect(add(1, 1)).toBe(42)
+ expect(squared(2)).toBe(42)
+ expect(minus(1, 2)).toBe(42)
+ expect(syncMock()).toBe(42)
+ expect(mockJs()).toBe(42)
+})
+
+test('importMock works', async () => {
+ // wasnt't mocked by vi.mock, but importMock did
+ const mockedUnmocked = await vi.importMock('../src/no-mock.ts')
+ expect(vi.isMockFunction(mockedUnmocked.notMocked)).toBe(true)
+ expect(mockedUnmocked.notMocked()).toBeUndefined()
+
+ // redirects to correct vi.mock
+ const mockedFs = await vi.importMock('node:fs')
+ expect(mockedFs.readFileSync('return-value-is-mocked')).toBe('mock')
+
+ // automocks to correct vi.mock
+ const mockedIndex = await vi.importMock('../src/index.ts')
+ expect(mockedIndex.squared(2)).toBe(42)
+ expect(mockedIndex.hello).toBe('mock')
+
+ // redirects to __mocks__ even though vi.mock is not present in this file
+ const mockedRedirect = await vi.importMock('../src/redirect.ts')
+ expect(mockedRedirect.default).toBe(true)
+})
diff --git a/test/native/test/mock-async-factory.test.ts b/test/native/test/mock-async-factory.test.ts
new file mode 100644
index 000000000000..68e5691f4394
--- /dev/null
+++ b/test/native/test/mock-async-factory.test.ts
@@ -0,0 +1,13 @@
+import { expect, test, vi } from 'vitest'
+import { topLevelAnswer } from '../src/from-async.ts'
+
+// for the test to be accurate, the factory has to be async
+vi.mock(import('../src/mock-async.ts'), async () => {
+ return {
+ answer: 42 as 0,
+ }
+})
+
+test('imported value is defined', () => {
+ expect(topLevelAnswer).toBe(42)
+})
diff --git a/test/native/test/redirect-mock.test.ts b/test/native/test/redirect-mock.test.ts
new file mode 100644
index 000000000000..2bb918d665f0
--- /dev/null
+++ b/test/native/test/redirect-mock.test.ts
@@ -0,0 +1,8 @@
+import { expect, test, vi } from 'vitest'
+import redirect from '../src/redirect.ts'
+
+vi.mock(import('../src/redirect.ts'))
+
+test('squared is mocked', () => {
+ expect(redirect).toBe(true)
+})
diff --git a/test/native/test/suite.test.ts b/test/native/test/suite.test.ts
new file mode 100644
index 000000000000..dba27a7bbd17
--- /dev/null
+++ b/test/native/test/suite.test.ts
@@ -0,0 +1,31 @@
+import { assert, describe, expect, it } from 'vitest'
+import { getSetupStates, initJsSetup, initTsSetup } from '../src/setups.ts'
+
+describe('suite name', () => {
+ it('foo', () => {
+ assert.equal(Math.sqrt(4), 2)
+ })
+
+ it('setups work', () => {
+ // TODO: a separate CLI test that confirms --maxWorkers=1 --no-isolate runs the setup file for every test file
+ expect(initJsSetup).toHaveBeenCalled()
+ expect(initTsSetup).toHaveBeenCalled()
+
+ expect(getSetupStates()).toEqual({
+ jsSetup: true,
+ tsSetup: true,
+ })
+ })
+
+ it('snapshot', () => {
+ expect({ foo: 'bar' }).toMatchSnapshot()
+ })
+
+ it('inline snapshot', () => {
+ expect({ foo: 'bar' }).toMatchInlineSnapshot(`
+ {
+ "foo": "bar",
+ }
+ `)
+ })
+})
diff --git a/test/native/tsSetup.ts b/test/native/tsSetup.ts
new file mode 100644
index 000000000000..4f330d4aae5a
--- /dev/null
+++ b/test/native/tsSetup.ts
@@ -0,0 +1,3 @@
+import { initTsSetup } from './src/setups.ts'
+
+initTsSetup()
diff --git a/test/native/tsconfig.json b/test/native/tsconfig.json
new file mode 100644
index 000000000000..aa059f097897
--- /dev/null
+++ b/test/native/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "es2020",
+ "module": "nodenext",
+ "moduleResolution": "node16",
+ "types": ["vitest/importMeta", "@types/node"],
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "declaration": true,
+ "declarationMap": true,
+ "noEmit": true,
+ "sourceMap": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src", "test"],
+ "exclude": ["node_modules"]
+}
diff --git a/test/native/vite.config.ts b/test/native/vite.config.ts
new file mode 100644
index 000000000000..d1dc44b4f20a
--- /dev/null
+++ b/test/native/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ // the hardest to support
+ // TODO: ideally, this should run in a matrix
+ isolate: false,
+ maxWorkers: 1,
+ setupFiles: [
+ './jsSetup.js',
+ './tsSetup.ts',
+ ],
+ includeSource: ['./src/in-source/*'],
+ experimental: {
+ viteModuleRunner: false,
+ // nodeLoader: false,
+ },
+ },
+})
diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts
index b24a22d02718..f7c58439a2a3 100644
--- a/test/test-utils/index.ts
+++ b/test/test-utils/index.ts
@@ -14,7 +14,7 @@ import type {
import { webcrypto as crypto } from 'node:crypto'
import fs from 'node:fs'
import { Readable, Writable } from 'node:stream'
-import { fileURLToPath } from 'node:url'
+import { fileURLToPath, pathToFileURL } from 'node:url'
import { inspect } from 'node:util'
import { dirname, relative, resolve } from 'pathe'
import { x } from 'tinyexec'
@@ -410,6 +410,23 @@ export async function runInlineTests(
}
}
+export function replaceRoot(string: string, root: string) {
+ const schemaRoot = root.startsWith('file://') ? root : pathToFileURL(root).toString()
+ if (!root.endsWith('/')) {
+ root += process.platform !== 'win32' ? '?/' : '?\\\\'
+ }
+ if (process.platform !== 'win32') {
+ return string
+ .replace(new RegExp(schemaRoot, 'g'), '')
+ .replace(new RegExp(root, 'g'), '/')
+ }
+ const normalizedRoot = root.replaceAll('/', '\\\\')
+ return string
+ .replace(new RegExp(schemaRoot, 'g'), '')
+ .replace(new RegExp(root, 'g'), '/')
+ .replace(new RegExp(normalizedRoot, 'g'), '/')
+}
+
export const ts = String.raw
export class StableTestFileOrderSorter {
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 4e56cc8d9f69..0c13d990efbe 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -17,6 +17,7 @@
"@vitest/mocker": ["./packages/mocker/src/index.ts"],
"@vitest/mocker/node": ["./packages/mocker/src/node/index.ts"],
"@vitest/mocker/browser": ["./packages/mocker/src/browser/index.ts"],
+ "@vitest/mocker/transforms": ["./packages/mocker/src/node/transforms.ts"],
"@vitest/runner": ["./packages/runner/src/index.ts"],
"@vitest/runner/*": ["./packages/runner/src/*"],
"@vitest/browser-playwright": ["./packages/browser-playwright/src/index.ts"],
@@ -30,6 +31,7 @@
"vitest/browser": ["./packages/vitest/browser/context.d.ts"],
"vitest/*": ["./packages/vitest/src/public/*"]
},
+ "allowImportingTsExtensions": true,
"strict": true,
"declaration": true,
"noEmit": true,