Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: avoid Vite transforms for non-vite projects #7234

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
92 changes: 92 additions & 0 deletions docs/guide/native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
outline: deep
---

# Native Mode <Badge type="warning">Experimental</Badge> {#native-mode}

By default, Vitest runs tests in a very permissive "vite-node" sandbox powered by the [vite-node package](https://www.npmjs.com/package/vite-node), soon to be 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.

All "inline" modules run in the "vite-node" sandbox. It provides `import.meta`, `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 "vite-node" 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 browser 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.

## `nativeImports` Flag

Vitest 3.x comes with an experimental flag called `nativeImports` <!-- TBD --> that you can enabled under `experimental` field:

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

export default defineConfig({
test: {
experimental: {
nativeImports: true,
},
},
})
```

This flag disables all custom file transforms and removes polyfills that Vitest injects in the sandbox environment. Tests are still scheduled to run either in a worker thread or a child process, depending on your [`pool`](/config/#pool) option. This mode is recommended for tools using these environments natively. However, we still recommend running `jsdom`/`happy-dom` tests in a sandbox environment or in [the browser](/guide/browser/) as it doesn't reauire any configuration.

This flag disables _all_ file transforms:

- test files and your source code are not processed
- your global setup files are not processed
- your custom runner/pool/environment files are not processed
- your config files is still processed by Vite's config resolution mechanism (this happens before Vitest knows the flag)

### TypeScript

If you are using TypeScript and Node.js lower than 23.6, then you will need to either:

- build your test files and source code and run those
- define a [custom loader](https://nodejs.org/api/module.html#customization-hooks) via `experimental.preload` flag

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

export default defineConfig({
test: {
experimental: {
nativeImports: true,
preload: ['tsx'],
},
},
})
```
<!-- TODO: example with tsx - just adding "tsx" is not enough -->

If you are using Node.js 23.6 or higher, then TypeScript will be [transformed natively](https://nodejs.org/en/learn/typescript/run-natively) by Node.js.

::: warning TypeScript with Node.js 22
If you are using Node.js 22.6, 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 Deno, TypeScript files should be processed as they usually are without any additional configurations.

### Disabled Features

Some Vitest features rely on files being transformed in some way. Since native mode disables file transformation, these features do not work with `nativeImport` flag:

- no `import.meta.env` in Node: `import.meta.env` is a Vite feature, use `process.env` instead
- no `import.meta.vitest`: in-source testing requires injecting values to `import.meta` which is not supported by any environment
- no `vi.mock` support: mocking modules is not supported because it relies on code transformations
- no `plugins`: plugins are not applied because there is no transformation phase
- no `alias`: aliases are not applied because there is no transformation/resolution phase
<!-- - no watch mode yet (watch mode relies on the Vite module graph) -->

::: warning Support is Coming
We are planning to support some of these features by using the [Node.js Loaders API](https://nodejs.org/api/module.html#customization-hooks) in the future.
:::
1 change: 1 addition & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
json: null,
provide: null,
filesOnly: null,
experimental: null,
}

export const benchCliOptionsConfig: Pick<
Expand Down
10 changes: 10 additions & 0 deletions packages/vitest/src/node/config/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,16 @@ export function resolveConfig(
resolved.testTimeout ??= resolved.browser.enabled ? 15000 : 5000
resolved.hookTimeout ??= resolved.browser.enabled ? 30000 : 10000

resolved.experimental ??= {}
if (resolved.experimental.preload) {
resolved.experimental.preload = toArray(resolved.experimental.preload).map((preload) => {
if (preload[0] === '.') {
return resolve(resolved.root, preload)
}
return preload
})
}

return resolved
}

Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/node/config/serializeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,5 +164,7 @@ export function serializeConfig(
benchmark: config.benchmark && {
includeSamples: config.benchmark.includeSamples,
},
experimentalNativeImport: config.experimental?.nativeImport ?? false,
experimentalPreload: config.experimental?.preload ?? [],
}
}
9 changes: 7 additions & 2 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CancelReason, File } from '@vitest/runner'
import type { Awaitable } from '@vitest/utils'
import type { Writable } from 'node:stream'
import type { ViteDevServer } from 'vite'
import type { ViteNodeRunnerOptions } from 'vite-node'
import type { defineWorkspace } from 'vitest/config'
import type { SerializedCoverageConfig } from '../runtime/config'
import type { ArgumentsType, ProvidedContext, UserConsoleLog } from '../types/general'
Expand Down Expand Up @@ -30,6 +31,7 @@ import { VitestCache } from './cache'
import { resolveConfig } from './config/resolveConfig'
import { FilesNotFoundError } from './errors'
import { Logger } from './logger'
import { NativeRunner } from './nativeRunner'
import { VitestPackageInstaller } from './packageInstaller'
import { createPool } from './pool'
import { TestProject } from './project'
Expand Down Expand Up @@ -227,7 +229,7 @@ export class Vitest {
this.vitenode = new ViteNodeServer(server, this.config.server)

const node = this.vitenode
this.runner = new ViteNodeRunner({
const viteNodeOptions: ViteNodeRunnerOptions = {
root: server.config.root,
base: server.config.base,
fetchModule(id: string) {
Expand All @@ -236,7 +238,10 @@ export class Vitest {
resolveId(id: string, importer?: string) {
return node.resolveId(id, importer)
},
})
}
this.runner = resolved.experimental.nativeImport
? new NativeRunner(viteNodeOptions)
: new ViteNodeRunner(viteNodeOptions)

if (this.config.watch) {
// hijack server restart
Expand Down
12 changes: 12 additions & 0 deletions packages/vitest/src/node/nativeRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { resolve } from 'node:path'
import { ViteNodeRunner } from 'vite-node/client'

export class NativeRunner extends ViteNodeRunner {
override executeFile(file: string): Promise<any> {
return import(resolve(this.options.root, file))
}

override executeId(rawId: string): Promise<any> {
return import(resolve(this.options.root, rawId))
}
}
7 changes: 6 additions & 1 deletion packages/vitest/src/node/plugins/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ export function WorkspaceVitestPlugin(
test: {
name,
},
};
}

// TODO: inherit one by one, not all of it
if (project.vitest.config.experimental && !config.test?.experimental) {
config.test!.experimental = project.vitest.config.experimental
}

(config.test as ResolvedConfig).defines = defines

Expand Down
16 changes: 16 additions & 0 deletions packages/vitest/src/node/pools/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RuntimeRPC } from '../../types/rpc'
import type { TestProject } from '../project'
import type { ResolveSnapshotPathHandlerContext } from '../types/config'
import { mkdir, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'pathe'
import { hash } from '../hash'

Expand Down Expand Up @@ -120,6 +121,21 @@ export function createMethodsRPC(project: TestProject, options: MethodsOptions =
getCountOfFailedTests() {
return ctx.state.getCountOfFailedTests()
},

// TODO: make sure watcher works
async ensureModuleGraphEntry(id, importer) {
id = id.startsWith('file') ? fileURLToPath(id) : id
importer = importer.startsWith('file') ? fileURLToPath(importer) : importer

let importerNode = project.vite.moduleGraph.getModuleById(importer)
if (!importerNode) {
importerNode = project.vite.moduleGraph.createFileOnlyEntry(importer)
}

const moduleNode = project.vite.moduleGraph.getModuleById(id) || project.vite.moduleGraph.createFileOnlyEntry(id)
importerNode.ssrImportedModules.add(moduleNode)
moduleNode.importers.add(importerNode)
},
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/vitest/src/node/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ViteDevServer,
InlineConfig as ViteInlineConfig,
} from 'vite'
import type { ViteNodeRunnerOptions } from 'vite-node'
import type { Typechecker } from '../typecheck/typechecker'
import type { ProvidedContext } from '../types/general'
import type { OnTestsRerunHandler, Vitest } from './core'
Expand All @@ -30,6 +31,7 @@ import { setup } from '../api/setup'
import { isBrowserEnabled, resolveConfig } from './config/resolveConfig'
import { serializeConfig } from './config/serializeConfig'
import { loadGlobalSetupFiles } from './globalSetup'
import { NativeRunner } from './nativeRunner'
import { CoverageTransform } from './plugins/coverageTransform'
import { MocksPlugins } from './plugins/mocks'
import { WorkspaceVitestPlugin } from './plugins/workspace'
Expand Down Expand Up @@ -600,7 +602,7 @@ export class TestProject {

this.vitenode = new ViteNodeServer(server, this.config.server)
const node = this.vitenode
this.runner = new ViteNodeRunner({
const viteNodeOptions: ViteNodeRunnerOptions = {
root: server.config.root,
base: server.config.base,
fetchModule(id: string) {
Expand All @@ -609,7 +611,10 @@ export class TestProject {
resolveId(id: string, importer?: string) {
return node.resolveId(id, importer)
},
})
}
this.runner = this._config.experimental.nativeImport
? new NativeRunner(viteNodeOptions)
: new ViteNodeRunner(viteNodeOptions)
}

private _serializeOverriddenConfig(): SerializedConfig {
Expand Down
18 changes: 17 additions & 1 deletion packages/vitest/src/node/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,6 @@ export interface InlineConfig {

/**
* Handling for dependencies inlining or externalizing
*
*/
deps?: DepsOptions

Expand Down Expand Up @@ -836,6 +835,23 @@ export interface InlineConfig {
* @default false
*/
includeTaskLocation?: boolean

experimental?: {
/**
* Should Vitest use the native "import" instead of Vite Node.
* This can break certain functionality, like `import.meta`
* @experimental
*/
nativeImport?: boolean
/**
* Natively import files before Vitest resolves the environment.
* This is the first import of the test file, you can define loaders here.
* The file is resoled relative to the root of the project.
* `data:*` modules are left as is.
* @experimental
*/
preload?: string[]
}
}

export interface TypecheckConfig {
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/runtime/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export interface SerializedConfig {
benchmark?: {
includeSamples: boolean
}
experimentalNativeImport: boolean
experimentalPreload: string[]
}

export interface SerializedCoverageConfig {
Expand Down
62 changes: 62 additions & 0 deletions packages/vitest/src/runtime/native-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { WorkerGlobalState } from '../types/worker'
import type { ExecuteOptions } from './execute'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { ModuleCacheMap } from 'vite-node/client'
import { NativeMocker } from './native-mocker'

export class NativeExecutor {
public mocker: NativeMocker
public moduleCache = new ModuleCacheMap()

public options: ExecuteOptions

constructor(state: WorkerGlobalState) {
this.options = {
state,
root: state.config.root,
async fetchModule() {
throw new Error(`fetchModule is not implemented in native executor`)
},
}
this.mocker = new NativeMocker(state)
// TODO: don't support mocker for now
// Object.defineProperty(globalThis, '__vitest_mocker__', {
// value: this.mocker,
// configurable: true,
// })
}

executeId(id: string) {
return import(resolve(this.state.config.root, id))
}

executeFile(id: string) {
return import(resolve(this.state.config.root, id))
}

cachedRequest(id: string) {
return import(id)
}

// used by vi.importActual
async originalResolveUrl(id: string, importer?: string) {
try {
const path = import.meta.resolve(id, importer ? pathToFileURL(importer) : undefined)
return [path, path]
}
catch (error: any) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
Object.defineProperty(error, Symbol.for('vitest.error.not_found.data'), {
value: { id },
})
}
throw error
}
}

get state(): WorkerGlobalState {
// @ts-expect-error injected untyped global
return globalThis.__vitest_worker__ || this.options.state
}
}
Loading
Loading