diff --git a/.npmrc b/.npmrc index 80bce8dff8bd..ec3a0020dde2 100644 --- a/.npmrc +++ b/.npmrc @@ -3,4 +3,4 @@ strict-peer-dependencies=false provenance=true shell-emulator=true registry=https://registry.npmjs.org/ -VITE_NODE_DEPS_MODULE_DIRECTORIES=/node_modules/,/packages/ +VITEST_MODULE_DIRECTORIES=/node_modules/,/packages/ diff --git a/docs/advanced/runner.md b/docs/advanced/runner.md index 144a1607834b..52b1d342d6f5 100644 --- a/docs/advanced/runner.md +++ b/docs/advanced/runner.md @@ -121,14 +121,14 @@ export default CustomRunner ``` ::: warning -Vitest also injects an instance of `ViteNodeRunner` as `__vitest_executor` property. You can use it to process files in `importFile` method (this is default behavior of `TestRunner` and `BenchmarkRunner`). +Vitest also injects an instance of `ModuleRunner` from `vite/module-runner` as `moduleRunner` property. You can use it to process files in `importFile` method (this is default behavior of `TestRunner` and `BenchmarkRunner`). -`ViteNodeRunner` exposes `executeId` method, which is used to import test files in a Vite-friendly environment. Meaning, it will resolve imports and transform file content at runtime so that Node can understand it: +`ModuleRunner` exposes `import` method, which is used to import test files in a Vite-friendly environment. Meaning, it will resolve imports and transform file content at runtime so that Node can understand it: ```ts export default class Runner { async importFile(filepath: string) { - await this.__vitest_executor.executeId(filepath) + await this.moduleRunner.import(filepath) } } ``` diff --git a/docs/config/index.md b/docs/config/index.md index 46d0c10a2169..796fa980121f 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -233,7 +233,7 @@ Handling for dependencies resolution. #### deps.optimizer {#deps-optimizer} -- **Type:** `{ ssr?, web? }` +- **Type:** `{ ssr?, client? }` - **See also:** [Dep Optimization Options](https://vitejs.dev/config/dep-optimization-options.html) Enable dependency optimization. If you have a lot of tests, this might improve their performance. @@ -245,7 +245,7 @@ When Vitest encounters the external library listed in `include`, it will be bund - Your `alias` configuration is now respected inside bundled packages - Code in your tests is running closer to how it's running in the browser -Be aware that only packages in `deps.optimizer?.[mode].include` option are bundled (some plugins populate this automatically, like Svelte). You can read more about available options in [Vite](https://vitejs.dev/config/dep-optimization-options.html) docs (Vitest doesn't support `disable` and `noDiscovery` options). By default, Vitest uses `optimizer.web` for `jsdom` and `happy-dom` environments, and `optimizer.ssr` for `node` and `edge` environments, but it is configurable by [`transformMode`](#testtransformmode). +Be aware that only packages in `deps.optimizer?.[mode].include` option are bundled (some plugins populate this automatically, like Svelte). You can read more about available options in [Vite](https://vitejs.dev/config/dep-optimization-options.html) docs (Vitest doesn't support `disable` and `noDiscovery` options). By default, Vitest uses `optimizer.client` for `jsdom` and `happy-dom` environments, and `optimizer.ssr` for `node` and `edge` environments. This options also inherits your `optimizeDeps` configuration (for web Vitest will extend `optimizeDeps`, for ssr - `ssr.optimizeDeps`). If you redefine `include`/`exclude` option in `deps.optimizer` it will extend your `optimizeDeps` when running tests. Vitest automatically removes the same options from `include`, if they are listed in `exclude`. @@ -260,15 +260,15 @@ You will not be able to edit your `node_modules` code for debugging, since the c Enable dependency optimization. -#### deps.web {#deps-web} +#### deps.client {#deps-client} - **Type:** `{ transformAssets?, ... }` -Options that are applied to external files when transform mode is set to `web`. By default, `jsdom` and `happy-dom` use `web` mode, while `node` and `edge` environments use `ssr` transform mode, so these options will have no affect on files inside those environments. +Options that are applied to external files when the environment is set to `client`. By default, `jsdom` and `happy-dom` use `client` environment, while `node` and `edge` environments use `ssr`, so these options will have no affect on files inside those environments. Usually, files inside `node_modules` are externalized, but these options also affect files in [`server.deps.external`](#server-deps-external). -#### deps.web.transformAssets +#### deps.client.transformAssets - **Type:** `boolean` - **Default:** `true` @@ -281,7 +281,7 @@ This module will have a default export equal to the path to the asset, if no que At the moment, this option only works with [`vmThreads`](#vmthreads) and [`vmForks`](#vmforks) pools. ::: -#### deps.web.transformCss +#### deps.client.transformCss - **Type:** `boolean` - **Default:** `true` @@ -294,7 +294,7 @@ If CSS files are disabled with [`css`](#css) options, this option will just sile At the moment, this option only works with [`vmThreads`](#vmthreads) and [`vmForks`](#vmforks) pools. ::: -#### deps.web.transformGlobPattern +#### deps.client.transformGlobPattern - **Type:** `RegExp | RegExp[]` - **Default:** `[]` @@ -560,7 +560,7 @@ import type { Environment } from 'vitest' export default { name: 'custom', - transformMode: 'ssr', + viteEnvironment: 'ssr', setup() { // custom setup return { @@ -1676,28 +1676,6 @@ Will call [`vi.unstubAllEnvs`](/api/vi#vi-unstuballenvs) before each test. Will call [`vi.unstubAllGlobals`](/api/vi#vi-unstuballglobals) before each test. -### testTransformMode {#testtransformmode} - - - **Type:** `{ web?, ssr? }` - - Determine the transform method for all modules imported inside a test that matches the glob pattern. By default, relies on the environment. For example, tests with JSDOM environment will process all files with `ssr: false` flag and tests with Node environment process all modules with `ssr: true`. - - #### testTransformMode.ssr - - - **Type:** `string[]` - - **Default:** `[]` - - Use SSR transform pipeline for all modules inside specified tests.
- Vite plugins will receive `ssr: true` flag when processing those files. - - #### testTransformMode.web - - - **Type:** `string[]` - - **Default:** `[]` - - First do a normal transform pipeline (targeting browser), then do a SSR rewrite to run the code in Node.
- Vite plugins will receive `ssr: false` flag when processing those files. - ### snapshotFormat - **Type:** `PrettyFormatOptions` diff --git a/docs/guide/environment.md b/docs/guide/environment.md index c76c964b49d0..a5759f16f56a 100644 --- a/docs/guide/environment.md +++ b/docs/guide/environment.md @@ -48,7 +48,7 @@ import type { Environment } from 'vitest/environments' export default { name: 'custom', - transformMode: 'ssr', + viteEnvironment: 'ssr', // optional - only if you support "experimental-vm" pool async setupVM() { const vm = await import('node:vm') @@ -74,7 +74,7 @@ export default { ``` ::: warning -Vitest requires `transformMode` option on environment object. It should be equal to `ssr` or `web`. This value determines how plugins will transform source code. If it's set to `ssr`, plugin hooks will receive `ssr: true` when transforming or resolving files. Otherwise, `ssr` is set to `false`. +Vitest requires `viteEnvironment` option on environment object (fallbacks to the Vitest environment name by default). It should be equal to `ssr`, `client` or any custom [Vite environment](https://vite.dev/guide/api-environment) name. This value determines which environment is used to process file. ::: You also have access to default Vitest environments through `vitest/environments` entry: diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 532e74650d03..a4d29e7da233 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -145,6 +145,21 @@ $ pnpm run test:dev math.test.ts ``` ::: +### Replacing `vite-node` with [Module Runner](https://vite.dev/guide/api-environment-runtimes.html#modulerunner) + +Module Runner is a successor to `vite-node` implemented directly in Vite. Vitest now uses it directly instead of having a wrapper around Vite SSR handler. This means that certain features are no longer available: + +- `VITE_NODE_DEPS_MODULE_DIRECTORIES` environment variable was replaced with `VITEST_MODULE_DIRECTORIES` +- Vitest no longer injects `__vitest_executor` into every [test runner](/advanced/runner). Instead, it injects `moduleRunner` which is an instance of [`ModuleRunner`](https://vite.dev/guide/api-environment-runtimes.html#modulerunner) +- `vitest/execute` entry point was removed. It was always meant to be internal +- [Custom environments](/guide/environment) no longer need to provide a `transformMode` property. Instead, provide `viteEnvironment`. If it is not provided, Vitest will use the environment name to transform files on the server (see [`server.environments`](https://vite.dev/guide/api-environment-instances.html)) +- `vite-node` is no longer a dependency of Vitest +- `deps.optimizer.web` was renamed to [`deps.optimizer.client`](/config/#deps-optimizer-client). You can also use any custom names to apply optimizer configs when using other server environments + +Vite has its own externalization mechanism, but we decided to keep using the old one to reduce the amount of breaking changes. You can keep using [`server.deps`](/config/#server-deps) to inline or externalize packages. + +This update should not be noticeable unless you rely on advanced features mentioned above. + ### Deprecated APIs are Removed Vitest 4.0 removes some deprecated APIs, including: @@ -152,8 +167,9 @@ Vitest 4.0 removes some deprecated APIs, including: - `poolMatchGlobs` config option. Use [`projects`](/guide/projects) instead. - `environmentMatchGlobs` config option. Use [`projects`](/guide/projects) instead. - `workspace` config option. Use [`projects`](/guide/projects) instead. +- `deps.external`, `deps.inline`, `deps.fallbackCJS` config options. Use `server.deps.external`, `server.deps.inline`, or `server.deps.fallbackCJS` instead. -This release also removes all deprecated types. This finally fixes an issue where Vitest accidentally pulled in `node` types (see [#5481](https://github.com/vitest-dev/vitest/issues/5481) and [#6141](https://github.com/vitest-dev/vitest/issues/6141)). +This release also removes all deprecated types. This finally fixes an issue where Vitest accidentally pulled in `@types/node` (see [#5481](https://github.com/vitest-dev/vitest/issues/5481) and [#6141](https://github.com/vitest-dev/vitest/issues/6141)). ## Migrating from Jest {#jest} diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index ffe5e960125c..936e41091f87 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -12,7 +12,7 @@ import { } from 'vitest/internal/browser' import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners' import { createStackString, parseStacktrace } from '../../../../utils/src/source-map' -import { executor, getWorkerState } from '../utils' +import { getWorkerState, moduleRunner } from '../utils' import { rpc } from './rpc' import { VitestBrowserSnapshotEnvironment } from './snapshot' @@ -117,7 +117,7 @@ export function createBrowserRunner( await rpc().onAfterSuiteRun({ coverage, testFiles: files.map(file => file.name), - transformMode: 'browser', + environment: '__browser__', projectName: this.config.name, }) } @@ -223,7 +223,7 @@ export async function initiateRunner( const BrowserRunner = createBrowserRunner(runnerClass, mocker, state, { takeCoverage: () => - takeCoverageInsideWorker(config.coverage, executor), + takeCoverageInsideWorker(config.coverage, moduleRunner), }) if (!config.snapshotOptions.snapshotEnvironment) { config.snapshotOptions.snapshotEnvironment = new VitestBrowserSnapshotEnvironment() @@ -238,8 +238,8 @@ export async function initiateRunner( }) const [diffOptions] = await Promise.all([ - loadDiffConfig(config, executor as any), - loadSnapshotSerializers(config, executor as any), + loadDiffConfig(config, moduleRunner as any), + loadSnapshotSerializers(config, moduleRunner as any), ]) runner.config.diffOptions = diffOptions getWorkerState().onFilterStackTrace = (stack: string) => { diff --git a/packages/browser/src/client/tester/state.ts b/packages/browser/src/client/tester/state.ts index 8bd0e6c41917..3604b3c189dd 100644 --- a/packages/browser/src/client/tester/state.ts +++ b/packages/browser/src/client/tester/state.ts @@ -25,14 +25,16 @@ const state: WorkerGlobalState = { config, environment: { name: 'browser', - transformMode: 'web', + viteEnvironment: 'client', setup() { throw new Error('Not called in the browser') }, }, onCleanup: fn => getBrowserState().cleanups.push(fn), - moduleCache: getBrowserState().moduleCache, + evaluatedModules: getBrowserState().evaluatedModules, + resolvingModules: getBrowserState().resolvingModules, moduleExecutionInfo: new Map(), + metaEnv: null as any, rpc: null as any, durations: { environment: 0, diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index cacce1b6b480..811a7d29f1a7 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -10,7 +10,7 @@ import { startTests, stopCoverageInsideWorker, } from 'vitest/internal/browser' -import { executor, getBrowserState, getConfig, getWorkerState } from '../utils' +import { getBrowserState, getConfig, getWorkerState, moduleRunner } from '../utils' import { setupDialogsSpy } from './dialog' import { setupConsoleLogSpy } from './logger' import { VitestBrowserClientMocker } from './mocker' @@ -101,6 +101,7 @@ async function prepareTestEnvironment(options: PrepareOptions) { const state = getWorkerState() + state.metaEnv = import.meta.env state.onCancel = onCancel state.rpc = rpc as any @@ -207,7 +208,7 @@ async function prepare(options: PrepareOptions) { await Promise.all([ setupCommonEnv(config), - startCoverageInsideWorker(config.coverage, executor, { isolate: config.browser.isolate }), + startCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.browser.isolate }), (async () => { const VitestIndex = await import('vitest') Object.defineProperty(window, '__vitest_index__', { @@ -249,7 +250,7 @@ async function cleanup() { .catch(error => unhandledError(error, 'Cleanup Error')) } state.environmentTeardownRun = true - await stopCoverageInsideWorker(config.coverage, executor, { isolate: config.browser.isolate }).catch((error) => { + await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.browser.isolate }).catch((error) => { return unhandledError(error, 'Coverage Error') }) } diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index a6ecd955627d..0df93d4e44d6 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -1,5 +1,5 @@ import type { VitestRunner } from '@vitest/runner' -import type { SerializedConfig, WorkerGlobalState } from 'vitest' +import type { EvaluatedModules, SerializedConfig, WorkerGlobalState } from 'vitest' import type { IframeOrchestrator } from './orchestrator' import type { CommandsManager } from './tester/utils' @@ -13,10 +13,10 @@ export async function importFs(id: string): Promise { return getBrowserState().wrapModule(() => import(/* @vite-ignore */ name)) } -export const executor = { +export const moduleRunner = { isBrowser: true, - executeId: (id: string): Promise => { + import: (id: string): Promise => { if (id[0] === '/' || id[1] === ':') { return importFs(id) } @@ -65,7 +65,8 @@ export function ensureAwaited(promise: (error?: Error) => Promise): Promis export interface BrowserRunnerState { files: string[] runningFiles: string[] - moduleCache: Map + resolvingModules: Set + evaluatedModules: EvaluatedModules config: SerializedConfig provider: string runner: VitestRunner diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 9cf916831ca7..ed2777715b58 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -122,7 +122,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke function setupClient(project: TestProject, rpcId: string, ws: WebSocket) { const mockResolver = new ServerMockResolver(globalServer.vite, { - moduleDirectories: project.config.server?.deps?.moduleDirectories, + moduleDirectories: project.config?.deps?.moduleDirectories, }) const mocker = project.browser?.provider.mocker diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 2ceddd4f8d53..cc6a97b5b6e7 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -89,19 +89,19 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider({ onFileRead(coverage) { - coverageMapByTransformMode.merge(coverage) + coverageMapByEnvironment.merge(coverage) }, onFinished: async () => { // Source maps can change based on projectName and transform mode. // Coverage transform re-uses source maps so we need to separate transforms from each other. - const transformedCoverage = await transformCoverage(coverageMapByTransformMode) + const transformedCoverage = await transformCoverage(coverageMapByEnvironment) coverageMap.merge(transformedCoverage) - coverageMapByTransformMode = this.createCoverageMap() + coverageMapByEnvironment = this.createCoverageMap() }, onDebug: debug, }) diff --git a/packages/coverage-v8/package.json b/packages/coverage-v8/package.json index 0215cf1df90b..5563d7b79e05 100644 --- a/packages/coverage-v8/package.json +++ b/packages/coverage-v8/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "workspace:*", "ast-v8-to-istanbul": "^0.3.3", "debug": "catalog:", "istanbul-lib-coverage": "catalog:", @@ -73,7 +74,6 @@ "@types/istanbul-reports": "catalog:", "@vitest/browser": "workspace:*", "pathe": "catalog:", - "vite-node": "workspace:*", "vitest": "workspace:*" } } diff --git a/packages/coverage-v8/src/index.ts b/packages/coverage-v8/src/index.ts index a5bcb16e79c1..d99a8191149b 100644 --- a/packages/coverage-v8/src/index.ts +++ b/packages/coverage-v8/src/index.ts @@ -37,10 +37,12 @@ const mod: CoverageProviderModule = { try { const result = coverage.result .filter(filterResult) - .map(res => ({ - ...res, - startOffset: options?.moduleExecutionInfo?.get(fileURLToPath(res.url))?.startOffset || 0, - })) + .map((res) => { + return { + ...res, + startOffset: options?.moduleExecutionInfo?.get(fileURLToPath(res.url))?.startOffset || 0, + } + }) resolve({ result }) } diff --git a/packages/coverage-v8/src/provider.ts b/packages/coverage-v8/src/provider.ts index f98c3bd5ef48..7dffaa412e91 100644 --- a/packages/coverage-v8/src/provider.ts +++ b/packages/coverage-v8/src/provider.ts @@ -1,13 +1,12 @@ import type { CoverageMap } from 'istanbul-lib-coverage' import type { ProxifiedModule } from 'magicast' import type { Profiler } from 'node:inspector' -import type { EncodedSourceMap, FetchResult } from 'vite-node' -import type { AfterSuiteRunMeta } from 'vitest' -import type { CoverageProvider, ReportContext, ResolvedCoverageOptions, TestProject, Vitest } from 'vitest/node' +import type { CoverageProvider, ReportContext, ResolvedCoverageOptions, TestProject, Vite, Vitest } from 'vitest/node' import { promises as fs } from 'node:fs' import { fileURLToPath } from 'node:url' // @ts-expect-error -- untyped import { mergeProcessCovs } from '@bcoe/v8-coverage' +import { cleanUrl } from '@vitest/utils' import astV8ToIstanbul from 'ast-v8-to-istanbul' import createDebug from 'debug' import libCoverage from 'istanbul-lib-coverage' @@ -17,18 +16,17 @@ import reports from 'istanbul-reports' import { parseModule } from 'magicast' import { normalize } from 'pathe' import { provider } from 'std-env' -import c from 'tinyrainbow' -import { cleanUrl } from 'vite-node/utils' +import c from 'tinyrainbow' import { BaseCoverageProvider } from 'vitest/coverage' -import { parseAstAsync } from 'vitest/node' +import { isCSSRequest, parseAstAsync } from 'vitest/node' import { version } from '../package.json' with { type: 'json' } export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage { startOffset: number } -type TransformResults = Map +type TransformResults = Map interface RawCoverage { result: ScriptCoverageWithOffset[] } const FILE_PROTOCOL = 'file://' @@ -65,11 +63,11 @@ export class V8CoverageProvider extends BaseCoverageProvider { + onFinished: async (project, environment) => { const converted = await this.convertCoverage( merged, project, - transformMode, + environment, ) // Source maps can change based on projectName and transform mode. @@ -148,7 +146,7 @@ export class V8CoverageProvider extends BaseCoverageProvider { const transformResults = normalizeTransformResults( - this.ctx.vitenode.fetchCache, + this.ctx.vite.environments, ) const transform = this.createUncoveredFileTransformer(this.ctx) @@ -293,6 +291,17 @@ export class V8CoverageProvider extends BaseCoverageProvider externalRE.test(url) + +/** + * Prepend `/@id/` and replace null byte so the id is URL-safe. + * This is prepended to resolved ids that are not valid browser + * import specifiers by the importAnalysis plugin. + */ +export function wrapId(id: string): string { + return id.startsWith(VALID_ID_PREFIX) + ? id + : VALID_ID_PREFIX + id.replace('\0', NULL_BYTE_PLACEHOLDER) +} + +/** + * Undo {@link wrapId}'s `/@id/` and null byte replacements. + */ +export function unwrapId(id: string): string { + return id.startsWith(VALID_ID_PREFIX) + ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0') + : id +} + +export function withTrailingSlash(path: string): string { + if (path[path.length - 1] !== '/') { + return `${path}/` + } + return path +} + +const bareImportRE = /^(?![a-z]:)[\w@](?!.*:\/\/)/i + +export function isBareImport(id: string): boolean { + return bareImportRE.test(id) +} + // convert RegExp.toString to RegExp export function parseRegexp(input: string): RegExp { // Parse input diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 58b167463f80..4559167fb159 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,10 @@ +export { + CSS_LANGS_RE, + KNOWN_ASSET_RE, + KNOWN_ASSET_TYPES, + NULL_BYTE_PLACEHOLDER, + VALID_ID_PREFIX, +} from './constants' export { format, inspect, @@ -8,6 +15,7 @@ export type { LoupeOptions, StringifyOptions } from './display' export { assertTypes, + cleanUrl, clone, createDefer, createSimpleStackTrace, @@ -16,6 +24,8 @@ export { getCallLastIndex, getOwnProperties, getType, + isBareImport, + isExternalUrl, isNegativeNaN, isObject, isPrimitive, @@ -25,6 +35,9 @@ export { parseRegexp, slash, toArray, + unwrapId, + withTrailingSlash, + wrapId, } from './helpers' export type { DeferPromise } from './helpers' diff --git a/packages/utils/src/resolver.ts b/packages/utils/src/resolver.ts new file mode 100644 index 000000000000..1a764122ad16 --- /dev/null +++ b/packages/utils/src/resolver.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs' +import { dirname, join } from 'pathe' + +const packageCache = new Map() + +export function findNearestPackageData( + basedir: string, +): { type?: 'module' | 'commonjs' } { + const originalBasedir = basedir + while (basedir) { + const cached = getCachedData(packageCache, basedir, originalBasedir) + if (cached) { + return cached + } + + const pkgPath = join(basedir, 'package.json') + if (tryStatSync(pkgPath)?.isFile()) { + const pkgData = JSON.parse(stripBomTag(fs.readFileSync(pkgPath, 'utf8'))) + + if (packageCache) { + setCacheData(packageCache, pkgData, basedir, originalBasedir) + } + + return pkgData + } + + const nextBasedir = dirname(basedir) + if (nextBasedir === basedir) { + break + } + basedir = nextBasedir + } + + return {} +} + +function stripBomTag(content: string): string { + if (content.charCodeAt(0) === 0xFEFF) { + return content.slice(1) + } + + return content +} + +function tryStatSync(file: string): fs.Stats | undefined { + try { + // The "throwIfNoEntry" is a performance optimization for cases where the file does not exist + return fs.statSync(file, { throwIfNoEntry: false }) + } + catch { + // Ignore errors + } +} + +export function getCachedData( + cache: Map, + basedir: string, + originalBasedir: string, +): NonNullable | undefined { + const pkgData = cache.get(getFnpdCacheKey(basedir)) + if (pkgData) { + traverseBetweenDirs(originalBasedir, basedir, (dir) => { + cache.set(getFnpdCacheKey(dir), pkgData) + }) + return pkgData + } +} + +export function setCacheData( + cache: Map, + data: T, + basedir: string, + originalBasedir: string, +): void { + cache.set(getFnpdCacheKey(basedir), data) + traverseBetweenDirs(originalBasedir, basedir, (dir) => { + cache.set(getFnpdCacheKey(dir), data) + }) +} + +function getFnpdCacheKey(basedir: string) { + return `fnpd_${basedir}` +} + +/** + * Traverse between `longerDir` (inclusive) and `shorterDir` (exclusive) and call `cb` for each dir. + * @param longerDir Longer dir path, e.g. `/User/foo/bar/baz` + * @param shorterDir Shorter dir path, e.g. `/User/foo` + */ +function traverseBetweenDirs( + longerDir: string, + shorterDir: string, + cb: (dir: string) => void, +) { + while (longerDir !== shorterDir) { + cb(longerDir) + longerDir = dirname(longerDir) + } +} diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index 1ecf5c36c3af..0b66d04321a6 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -34,6 +34,8 @@ const stackIgnorePatterns = [ '/node_modules/chai/', '/node_modules/tinypool/', '/node_modules/tinyspy/', + '/vite/dist/node/module-runner', + '/rolldown-vite/dist/node/module-runner', // browser related deps '/deps/chunk-', '/deps/@vitest', diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 34ec8f6fe1b6..806eb4f0e8a8 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -22,6 +22,12 @@ "jest" ], "sideEffects": false, + "imports": { + "#module-evaluator": { + "types": "./dist/module-evaluator.d.ts", + "default": "./dist/module-evaluator.js" + } + }, "exports": { ".": { "import": { @@ -50,10 +56,6 @@ "types": "./dist/node.d.ts", "default": "./dist/node.js" }, - "./execute": { - "types": "./dist/execute.d.ts", - "default": "./dist/execute.js" - }, "./workers": { "types": "./dist/workers.d.ts", "import": "./dist/workers.js" @@ -62,6 +64,10 @@ "types": "./dist/browser.d.ts", "default": "./dist/browser.js" }, + "./internal/module-runner": { + "types": "./dist/module-runner.d.ts", + "default": "./dist/module-runner.js" + }, "./runners": { "types": "./dist/runners.d.ts", "default": "./dist/runners.js" @@ -160,6 +166,7 @@ "@vitest/utils": "workspace:*", "chai": "catalog:", "debug": "catalog:", + "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "catalog:", "pathe": "catalog:", @@ -171,7 +178,6 @@ "tinypool": "^1.1.1", "tinyrainbow": "catalog:", "vite": "^6.0.0 || ^7.0.0-0", - "vite-node": "workspace:*", "why-is-node-running": "^2.3.0" }, "devDependencies": { diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index de41304d083f..5dbbaade9aed 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -28,10 +28,11 @@ const entries = { 'mocker': 'src/public/mocker.ts', 'spy': 'src/integrations/spy.ts', 'coverage': 'src/public/coverage.ts', - 'execute': 'src/public/execute.ts', 'reporters': 'src/public/reporters.ts', // TODO: advanced docs 'workers': 'src/public/workers.ts', + 'module-runner': 'src/public/module-runner.ts', + 'module-evaluator': 'src/runtime/moduleRunner/moduleEvaluator.ts', // for performance reasons we bundle them separately so we don't import everything at once 'worker': 'src/runtime/worker.ts', @@ -46,19 +47,19 @@ const entries = { } const dtsEntries = { - index: 'src/public/index.ts', - node: 'src/public/node.ts', - environments: 'src/public/environments.ts', - browser: 'src/public/browser.ts', - runners: 'src/public/runners.ts', - suite: 'src/public/suite.ts', - config: 'src/public/config.ts', - coverage: 'src/public/coverage.ts', - execute: 'src/public/execute.ts', - reporters: 'src/public/reporters.ts', - mocker: 'src/public/mocker.ts', - workers: 'src/public/workers.ts', - snapshot: 'src/public/snapshot.ts', + 'index': 'src/public/index.ts', + 'node': 'src/public/node.ts', + 'environments': 'src/public/environments.ts', + 'browser': 'src/public/browser.ts', + 'runners': 'src/public/runners.ts', + 'suite': 'src/public/suite.ts', + 'config': 'src/public/config.ts', + 'coverage': 'src/public/coverage.ts', + 'reporters': 'src/public/reporters.ts', + 'mocker': 'src/public/mocker.ts', + 'workers': 'src/public/workers.ts', + 'snapshot': 'src/public/snapshot.ts', + 'module-evaluator': 'src/runtime/moduleRunner/moduleEvaluator.ts', } const external = [ @@ -75,11 +76,7 @@ const external = [ 'node:console', 'inspector', 'vitest/optional-types.js', - 'vite-node/source-map', - 'vite-node/client', - 'vite-node/server', - 'vite-node/constants', - 'vite-node/utils', + 'vite/module-runner', '@vitest/mocker', '@vitest/mocker/node', '@vitest/utils/diff', @@ -90,6 +87,8 @@ const external = [ '@vitest/runner/types', '@vitest/snapshot/environment', '@vitest/snapshot/manager', + + '#module-evaluator', ] const dir = dirname(fileURLToPath(import.meta.url)) diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 260692f6dac7..dd0d0d8c1c35 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -94,7 +94,7 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void { const project = ctx.getProjectByName(projectName) const result: TransformResultWithSource | null | undefined = browser ? await project.browser!.vite.transformRequest(id) - : await project.vitenode.transformRequest(id) + : await project.vite.transformRequest(id) if (result) { try { result.source = result.source || (await fs.readFile(id, 'utf-8')) diff --git a/packages/vitest/src/constants.ts b/packages/vitest/src/constants.ts index dfa6c6883d22..dcb053b63461 100644 --- a/packages/vitest/src/constants.ts +++ b/packages/vitest/src/constants.ts @@ -5,13 +5,6 @@ export const defaultInspectPort = 9229 export const API_PATH = '/__vitest_api__' -export const extraInlineDeps: RegExp[] = [ - /^(?!.*node_modules).*\.mjs$/, - /^(?!.*node_modules).*\.cjs\.js$/, - // Vite client - /vite\w*\/dist\/client\/env.mjs/, -] - export const CONFIG_NAMES: string[] = ['vitest.config', 'vite.config'] export const CONFIG_EXTENSIONS: string[] = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'] diff --git a/packages/vitest/src/integrations/env/edge-runtime.ts b/packages/vitest/src/integrations/env/edge-runtime.ts index 4741ed8272f9..b564819602bc 100644 --- a/packages/vitest/src/integrations/env/edge-runtime.ts +++ b/packages/vitest/src/integrations/env/edge-runtime.ts @@ -4,7 +4,7 @@ import { populateGlobal } from './utils' export default { name: 'edge-runtime', - transformMode: 'ssr', + viteEnvironment: 'ssr', async setupVM() { const { EdgeVM } = await import('@edge-runtime/vm') const vm = new EdgeVM({ diff --git a/packages/vitest/src/integrations/env/happy-dom.ts b/packages/vitest/src/integrations/env/happy-dom.ts index ed883211d1a1..324c52d62f3b 100644 --- a/packages/vitest/src/integrations/env/happy-dom.ts +++ b/packages/vitest/src/integrations/env/happy-dom.ts @@ -16,7 +16,7 @@ async function teardownWindow(win: { export default { name: 'happy-dom', - transformMode: 'web', + viteEnvironment: 'client', async setupVM({ happyDOM = {} }) { const { Window } = await import('happy-dom') let win = new Window({ diff --git a/packages/vitest/src/integrations/env/jsdom.ts b/packages/vitest/src/integrations/env/jsdom.ts index d9020c27cf2c..eecf8e0d56e6 100644 --- a/packages/vitest/src/integrations/env/jsdom.ts +++ b/packages/vitest/src/integrations/env/jsdom.ts @@ -35,7 +35,7 @@ function catchWindowErrors(window: Window) { export default { name: 'jsdom', - transformMode: 'web', + viteEnvironment: 'client', async setupVM({ jsdom = {} }) { const { CookieJar, JSDOM, ResourceLoader, VirtualConsole } = await import( 'jsdom' diff --git a/packages/vitest/src/integrations/env/loader.ts b/packages/vitest/src/integrations/env/loader.ts index 0ca8154bb4f2..6f174cf7c467 100644 --- a/packages/vitest/src/integrations/env/loader.ts +++ b/packages/vitest/src/integrations/env/loader.ts @@ -1,10 +1,12 @@ -import type { ViteNodeRunnerOptions } from 'vite-node' import type { BuiltinEnvironment, VitestEnvironment } from '../../node/types/config' import type { Environment } from '../../types/environment' import type { ContextRPC, WorkerRPC } from '../../types/worker' import { readFileSync } from 'node:fs' -import { normalize, resolve } from 'pathe' -import { ViteNodeRunner } from 'vite-node/client' +import { isBuiltin } from 'node:module' +import { pathToFileURL } from 'node:url' +import { resolve } from 'pathe' +import { ModuleRunner } from 'vite/module-runner' +import { VitestTransport } from '../../runtime/moduleRunner/moduleTransport' import { environments } from './index' function isBuiltinEnvironment( @@ -13,43 +15,60 @@ function isBuiltinEnvironment( return env in environments } -const _loaders = new Map() +const isWindows = process.platform === 'win32' +const _loaders = new Map() -export async function createEnvironmentLoader(options: ViteNodeRunnerOptions): Promise { - if (!_loaders.has(options.root)) { - const loader = new ViteNodeRunner(options) - await loader.executeId('/@vite/env') - _loaders.set(options.root, loader) +export async function createEnvironmentLoader(root: string, rpc: WorkerRPC): Promise { + const cachedLoader = _loaders.get(root) + if (!cachedLoader || cachedLoader.isClosed()) { + _loaders.delete(root) + + const moduleRunner = new ModuleRunner({ + hmr: false, + sourcemapInterceptor: 'prepareStackTrace', + transport: new VitestTransport({ + async fetchModule(id, importer, options) { + const result = await rpc.fetch(id, importer, '__vitest__', options) + if ('cached' in result) { + const code = readFileSync(result.tmp, 'utf-8') + return { code, ...result } + } + if (isWindows && 'externalize' in result) { + // TODO: vitest returns paths for external modules, but Vite returns file:// + // https://github.com/vitejs/vite/pull/20449 + result.externalize = isBuiltin(id) || /^(?:node:|data:|http:|https:|file:)/.test(id) + ? result.externalize + : pathToFileURL(result.externalize).toString() + } + return result + }, + async resolveId(id, importer) { + return rpc.resolve(id, importer, '__vitest__') + }, + }), + }) + _loaders.set(root, moduleRunner) + await moduleRunner.import('/@vite/env') } - return _loaders.get(options.root)! + return _loaders.get(root)! } export async function loadEnvironment( ctx: ContextRPC, rpc: WorkerRPC, -): Promise { +): Promise<{ environment: Environment; loader?: ModuleRunner }> { const name = ctx.environment.name if (isBuiltinEnvironment(name)) { - return environments[name] + return { environment: environments[name] } } - const loader = await createEnvironmentLoader({ - root: ctx.config.root, - fetchModule: async (id) => { - const result = await rpc.fetch(id, 'ssr') - if (result.id) { - return { code: readFileSync(result.id, 'utf-8') } - } - return result - }, - resolveId: (id, importer) => rpc.resolveId(id, importer, 'ssr'), - }) - const root = loader.root + const root = ctx.config.root + const loader = await createEnvironmentLoader(root, rpc) const packageId = name[0] === '.' || name[0] === '/' ? resolve(root, name) - : (await rpc.resolveId(`vitest-environment-${name}`, undefined, 'ssr')) + : (await rpc.resolve(`vitest-environment-${name}`, undefined, '__vitest__')) ?.id ?? resolve(root, name) - const pkg = await loader.executeId(normalize(packageId)) + const pkg = await loader.import(packageId) as { default: Environment } if (!pkg || !pkg.default || typeof pkg.default !== 'object') { throw new TypeError( `Environment "${name}" is not a valid environment. ` @@ -58,13 +77,24 @@ export async function loadEnvironment( } const environment = pkg.default if ( - environment.transformMode !== 'web' + environment.transformMode != null + && environment.transformMode !== 'web' && environment.transformMode !== 'ssr' ) { throw new TypeError( `Environment "${name}" is not a valid environment. ` - + `Path "${packageId}" should export default object with a "transformMode" method equal to "ssr" or "web".`, + + `Path "${packageId}" should export default object with a "transformMode" method equal to "ssr" or "web", received "${environment.transformMode}".`, ) } - return environment + if (environment.transformMode) { + console.warn(`The Vitest environment ${environment.name} defines the "transformMode". This options was deprecated in Vitest 4 and will be removed in the next major version. Please, use "viteEnvironment" instead.`) + // keep for backwards compat + environment.viteEnvironment ??= environment.transformMode === 'ssr' + ? 'ssr' + : 'client' + } + return { + environment, + loader, + } } diff --git a/packages/vitest/src/integrations/env/node.ts b/packages/vitest/src/integrations/env/node.ts index b7546cf2a89b..533ff1ac0f30 100644 --- a/packages/vitest/src/integrations/env/node.ts +++ b/packages/vitest/src/integrations/env/node.ts @@ -32,7 +32,7 @@ const nodeGlobals = new Map( export default { name: 'node', - transformMode: 'ssr', + viteEnvironment: 'ssr', // this is largely copied from jest's node environment async setupVM() { const vm = await import('node:vm') diff --git a/packages/vitest/src/integrations/snapshot/environments/resolveSnapshotEnvironment.ts b/packages/vitest/src/integrations/snapshot/environments/resolveSnapshotEnvironment.ts index 8b656b0d4122..46345abba0ff 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 { VitestExecutor } from '../../../runtime/execute' +import type { VitestModuleRunner } from '../../../runtime/moduleRunner/moduleRunner' export async function resolveSnapshotEnvironment( config: SerializedConfig, - executor: VitestExecutor, + executor: VitestModuleRunner, ): Promise { if (!config.snapshotEnvironment) { const { VitestNodeSnapshotEnvironment } = await import('./node') return new VitestNodeSnapshotEnvironment() } - const mod = await executor.executeId(config.snapshotEnvironment) + const mod = await executor.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/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 6075aabbb308..c8030ccd3a03 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -7,7 +7,7 @@ import type { MockInstance, } from '@vitest/spy' import type { RuntimeOptions, SerializedConfig } from '../runtime/config' -import type { VitestMocker } from '../runtime/mocker' +import type { VitestMocker } from '../runtime/moduleRunner/moduleMocker' import type { MockFactoryWithHelper, MockOptions } from '../types/mocker' import { fn, isMockFunction, mocks, spyOn } from '@vitest/spy' import { assertTypes, createSimpleStackTrace } from '@vitest/utils' @@ -687,17 +687,19 @@ function createVitest(): VitestUtils { }, stubEnv(name: string, value: string | boolean | undefined) { + const state = getWorkerState() + const env = state.metaEnv if (!_stubsEnv.has(name)) { - _stubsEnv.set(name, process.env[name]) + _stubsEnv.set(name, env[name]) } if (_envBooleans.includes(name)) { - process.env[name] = value ? '1' : '' + env[name] = value ? '1' : '' } else if (value === undefined) { - delete process.env[name] + delete env[name] } else { - process.env[name] = String(value) + env[name] = String(value) } return utils }, @@ -716,12 +718,14 @@ function createVitest(): VitestUtils { }, unstubAllEnvs() { + const state = getWorkerState() + const env = state.metaEnv _stubsEnv.forEach((original, name) => { if (original === undefined) { - delete process.env[name] + delete env[name] } else { - process.env[name] = original + env[name] = original } }) _stubsEnv.clear() @@ -729,7 +733,7 @@ function createVitest(): VitestUtils { }, resetModules() { - resetModules(workerState.moduleCache as any) + resetModules(workerState.evaluatedModules) return utils }, diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 65f3c9a722ee..1037e9742823 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -856,7 +856,6 @@ export const cliOptionsConfig: VitestCLIOptions = { uiBase: null, benchmark: null, include: null, - testTransformMode: null, fakeTimers: null, chaiConfig: null, clearMocks: null, diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 507e32863787..a78708ec13d4 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -20,7 +20,6 @@ import { defaultBrowserPort, defaultInspectPort, defaultPort, - extraInlineDeps, } from '../../constants' import { benchmarkConfigDefaults, configDefaults } from '../../defaults' import { isCI, stdProvider } from '../../utils/env' @@ -337,6 +336,15 @@ export function resolveConfig( resolved.deps ??= {} resolved.deps.moduleDirectories ??= [] + + const envModuleDirectories + = process.env.VITEST_MODULE_DIRECTORIES + || process.env.npm_config_VITEST_MODULE_DIRECTORIES + + if (envModuleDirectories) { + resolved.deps.moduleDirectories.push(...envModuleDirectories.split(',')) + } + resolved.deps.moduleDirectories = resolved.deps.moduleDirectories.map( (dir) => { if (!dir.startsWith('/')) { @@ -354,9 +362,9 @@ export function resolveConfig( resolved.deps.optimizer ??= {} resolved.deps.optimizer.ssr ??= {} - resolved.deps.optimizer.ssr.enabled ??= true - resolved.deps.optimizer.web ??= {} - resolved.deps.optimizer.web.enabled ??= true + resolved.deps.optimizer.ssr.enabled ??= false + resolved.deps.optimizer.client ??= {} + resolved.deps.optimizer.client.enabled ??= false resolved.deps.web ??= {} resolved.deps.web.transformAssets ??= true @@ -403,72 +411,10 @@ export function resolveConfig( ...resolved.setupFiles, ] - resolved.server ??= {} - resolved.server.deps ??= {} - - const deprecatedDepsOptions = ['inline', 'external', 'fallbackCJS'] as const - deprecatedDepsOptions.forEach((option) => { - if (resolved.deps[option] === undefined) { - return - } - - if (option === 'fallbackCJS') { - logger.console.warn( - c.yellow( - `${c.inverse( - c.yellow(' Vitest '), - )} "deps.${option}" is deprecated. Use "server.deps.${option}" instead`, - ), - ) - } - else { - const transformMode - = resolved.environment === 'happy-dom' || resolved.environment === 'jsdom' - ? 'web' - : 'ssr' - logger.console.warn( - c.yellow( - `${c.inverse( - c.yellow(' Vitest '), - )} "deps.${option}" is deprecated. If you rely on vite-node directly, use "server.deps.${option}" instead. Otherwise, consider using "deps.optimizer.${transformMode}.${ - option === 'external' ? 'exclude' : 'include' - }"`, - ), - ) - } - - if (resolved.server.deps![option] === undefined) { - resolved.server.deps![option] = resolved.deps[option] as any - } - }) - if (resolved.cliExclude) { resolved.exclude.push(...resolved.cliExclude) } - // vitenode will try to import such file with native node, - // but then our mocker will not work properly - if (resolved.server.deps.inline !== true) { - const ssrOptions = viteConfig.ssr - if ( - ssrOptions?.noExternal === true - && resolved.server.deps.inline == null - ) { - resolved.server.deps.inline = true - } - else { - resolved.server.deps.inline ??= [] - resolved.server.deps.inline.push(...extraInlineDeps) - } - } - - resolved.server.deps.inlineFiles ??= [] - resolved.server.deps.inlineFiles.push(...resolved.setupFiles) - resolved.server.deps.moduleDirectories ??= [] - resolved.server.deps.moduleDirectories.push( - ...resolved.deps.moduleDirectories, - ) - if (resolved.runner) { resolved.runner = resolvePath(resolved.runner, resolved.root) } @@ -858,7 +804,8 @@ export function resolveConfig( resolved.includeTaskLocation ??= true } - resolved.testTransformMode ??= {} + resolved.server ??= {} + resolved.server.deps ??= {} resolved.testTimeout ??= resolved.browser.enabled ? 15000 : 5000 resolved.hookTimeout ??= resolved.browser.enabled ? 30000 : 10000 diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 4eda6a8f5d24..594c875983fa 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -6,7 +6,7 @@ export function serializeConfig( coreConfig: ResolvedConfig, viteConfig: ViteConfig | undefined, ): SerializedConfig { - const optimizer = config.deps?.optimizer + const optimizer = config.deps?.optimizer || {} const poolOptions = config.poolOptions // Resolve from server.config to avoid comparing against default value @@ -103,14 +103,10 @@ export function serializeConfig( }, deps: { web: config.deps.web || {}, - optimizer: { - web: { - enabled: optimizer?.web?.enabled ?? true, - }, - ssr: { - enabled: optimizer?.ssr?.enabled ?? true, - }, - }, + optimizer: Object.entries(optimizer).reduce((acc, [name, option]) => { + acc[name] = { enabled: option?.enabled ?? false } + return acc + }, {} as Record), interopDefault: config.deps.interopDefault, moduleDirectories: config.deps.moduleDirectories, }, diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 90d8071020d1..82ce2afed56c 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -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 { ModuleRunner } from 'vite/module-runner' import type { SerializedCoverageConfig } from '../runtime/config' import type { ArgumentsType, ProvidedContext, UserConsoleLog } from '../types/general' import type { CliOptions } from './cli/cli-api' @@ -15,8 +16,6 @@ import { getTasks, hasFailed } from '@vitest/runner/utils' import { SnapshotManager } from '@vitest/snapshot/manager' import { noop, toArray } from '@vitest/utils' import { normalize, relative } from 'pathe' -import { ViteNodeRunner } from 'vite-node/client' -import { ViteNodeServer } from 'vite-node/server' import { version } from '../../package.json' with { type: 'json' } import { WebSocketReporter } from '../api/setup' import { distDir } from '../paths' @@ -26,6 +25,7 @@ import { BrowserSessions } from './browser/sessions' import { VitestCache } from './cache' import { resolveConfig } from './config/resolveConfig' import { getCoverageProvider } from './coverage' +import { ServerModuleRunner } from './environments/serverRunner' import { FilesNotFoundError } from './errors' import { Logger } from './logger' import { VitestPackageInstaller } from './packageInstaller' @@ -35,6 +35,7 @@ import { getDefaultTestProject, resolveBrowserProjects, resolveProjects } from ' import { BlobReporter, readBlobs } from './reporters/blob' import { HangingProcessReporter } from './reporters/hanging-process' import { createBenchmarkReporters, createReporters } from './reporters/utils' +import { VitestResolver } from './resolver' import { VitestSpecifications } from './specifications' import { StateManager } from './state' import { TestRun } from './test-run' @@ -91,9 +92,9 @@ export class Vitest { /** @internal */ _browserSessions = new BrowserSessions() /** @internal */ _cliOptions: CliOptions = {} /** @internal */ reporters: Reporter[] = [] - /** @internal */ vitenode: ViteNodeServer = undefined! - /** @internal */ runner: ViteNodeRunner = undefined! + /** @internal */ runner!: ModuleRunner /** @internal */ _testRun: TestRun = undefined! + /** @internal */ _resolver!: VitestResolver private isFirstRun = true private restartsCount = 0 @@ -220,19 +221,13 @@ export class Vitest { this.watcher.registerWatcher() } - this.vitenode = new ViteNodeServer(server, this.config.server) - - const node = this.vitenode - this.runner = new ViteNodeRunner({ - root: server.config.root, - base: server.config.base, - fetchModule(id: string) { - return node.fetchModule(id) - }, - resolveId(id: string, importer?: string) { - return node.resolveId(id, importer) - }, - }) + this._resolver = new VitestResolver(server.config.cacheDir, resolved) + const environment = server.environments.__vitest__ + this.runner = new ServerModuleRunner( + environment, + this._resolver, + resolved, + ) if (this.config.watch) { // hijack server restart @@ -387,7 +382,7 @@ export class Vitest { * @param moduleId The ID of the module in Vite module graph */ public import(moduleId: string): Promise { - return this.runner.executeId(moduleId) + return this.runner.import(moduleId) } private async resolveProjects(cliOptions: UserConfig): Promise { diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts index 32c0a999b12e..e6de85171937 100644 --- a/packages/vitest/src/node/coverage.ts +++ b/packages/vitest/src/node/coverage.ts @@ -6,11 +6,11 @@ import type { SerializedCoverageConfig } from '../runtime/config' import type { AfterSuiteRunMeta } from '../types/general' import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs' import path from 'node:path' +import { cleanUrl, slash } from '@vitest/utils' import { relative, resolve } from 'pathe' import pm from 'picomatch' import { glob } from 'tinyglobby' import c from 'tinyrainbow' -import { cleanUrl, slash } from 'vite-node/utils' import { coverageConfigDefaults } from '../defaults' import { resolveCoverageReporters } from '../node/config/resolveConfig' import { resolveCoverageProviderModule } from '../utils/coverage' @@ -42,7 +42,7 @@ interface ResolvedThreshold { type CoverageFiles = Map< NonNullable | symbol, Record< - AfterSuiteRunMeta['transformMode'], + AfterSuiteRunMeta['environment'], { [TestFilenames: string]: string } > > @@ -253,19 +253,15 @@ export class BaseCoverageProvider void /** Callback invoked once all results of a project for specific transform mode are read */ - onFinished: (project: Vitest['projects'][number], transformMode: AfterSuiteRunMeta['transformMode']) => Promise + onFinished: (project: Vitest['projects'][number], environment: string) => Promise onDebug: ((...logs: any[]) => void) & { enabled: boolean } }): Promise { let index = 0 @@ -296,7 +293,7 @@ export class BaseCoverageProvider) { + for (const [environment, coverageByTestfiles] of Object.entries(coveragePerProject) as Entries) { const filenames = Object.values(coverageByTestfiles) const project = this.ctx.getProjectByName(projectName as string) @@ -315,7 +312,7 @@ export class BaseCoverageProvider ({ root: project.config.root, isBrowserEnabled: project.isBrowserEnabled(), - vitenode: project.vitenode, + vite: project.vite, })), // Check core last as it will match all files anyway - { root: ctx.config.root, vitenode: ctx.vitenode, isBrowserEnabled: ctx.getRootProject().isBrowserEnabled() }, + { root: ctx.config.root, vite: ctx.vite, isBrowserEnabled: ctx.getRootProject().isBrowserEnabled() }, ] return async function transformFile(filename: string): Promise { let lastError - for (const { root, vitenode, isBrowserEnabled } of servers) { + for (const { root, vite, isBrowserEnabled } of servers) { // On Windows root doesn't start with "/" while filenames do if (!filename.startsWith(root) && !filename.startsWith(`/${root}`)) { continue } if (isBrowserEnabled) { - const result = await vitenode.transformRequest(filename, undefined, 'web').catch(() => null) + const result = await vite.environments.client.transformRequest(filename).catch(() => null) if (result) { return result @@ -664,7 +661,7 @@ export class BaseCoverageProvider>() + +export function createFetchModuleFunction( + resolver: VitestResolver, + cacheFs: boolean = false, + tmpDir: string = join(tmpdir(), nanoid()), +): ( + url: string, + importer: string | undefined, + environment: DevEnvironment, + options?: FetchFunctionOptions +) => Promise { + const cachedFsResults = new Map() + return async ( + url, + importer, + environment, + options, + ) => { + // We are copy pasting Vite's externalization logic from `fetchModule` because + // we instead rely on our own `shouldExternalize` method because Vite + // doesn't support `resolve.external` in non SSR environments (jsdom/happy-dom) + if (url.startsWith('data:')) { + return { externalize: url, type: 'builtin' } + } + + if (url === '/@vite/client' || url === '@vite/client') { + // this will be stubbed + return { externalize: '/@vite/client', type: 'module' } + } + + const isFileUrl = url.startsWith('file://') + + if (isExternalUrl(url) && !isFileUrl) { + return { externalize: url, type: 'network' } + } + + // Vite does the same in `fetchModule`, but we want to externalize modules ourselves, + // so we do this first to resolve the module and check its `id`. The next call of + // `ensureEntryFromUrl` inside `fetchModule` is cached and should take no time + // This also makes it so externalized modules are inside the module graph. + const moduleGraphModule = await environment.moduleGraph.ensureEntryFromUrl(unwrapId(url)) + const cached = !!moduleGraphModule.transformResult + + // if url is already cached, we can just confirm it's also cached on the server + if (options?.cached && cached) { + return { cache: true } + } + + if (moduleGraphModule.id) { + const externalize = await resolver.shouldExternalize(moduleGraphModule.id) + if (externalize) { + return { externalize, type: 'module' } + } + } + + const moduleRunnerModule = await fetchModule( + environment, + url, + importer, + { + ...options, + inlineSourceMap: false, + }, + ).catch(handleRollupError) + + const result = processResultSource(environment, moduleRunnerModule) + + if (!cacheFs || !('code' in result)) { + return result + } + + const code = result.code + // to avoid serialising large chunks of code, + // we store them in a tmp file and read in the test thread + if (cachedFsResults.has(result.id)) { + return getCachedResult(result, cachedFsResults) + } + const dir = join(tmpDir, environment.name) + const name = hash('sha1', result.id, 'hex') + const tmp = join(dir, name) + if (!created.has(dir)) { + mkdirSync(dir, { recursive: true }) + created.add(dir) + } + if (promises.has(tmp)) { + await promises.get(tmp) + cachedFsResults.set(result.id, tmp) + return getCachedResult(result, cachedFsResults) + } + promises.set( + tmp, + + atomicWriteFile(tmp, code) + // Fallback to non-atomic write for windows case where file already exists: + .catch(() => writeFile(tmp, code, 'utf-8')) + .finally(() => promises.delete(tmp)), + ) + await promises.get(tmp) + cachedFsResults.set(result.id, tmp) + return getCachedResult(result, cachedFsResults) + } +} + +let SOURCEMAPPING_URL = 'sourceMa' +SOURCEMAPPING_URL += 'ppingURL' + +const MODULE_RUNNER_SOURCEMAPPING_SOURCE = '//# sourceMappingSource=vite-generated' + +function processResultSource(environment: DevEnvironment, result: FetchResult): FetchResult { + if (!('code' in result)) { + return result + } + + const node = environment.moduleGraph.getModuleById(result.id) + if (node?.transformResult) { + // this also overrides node.transformResult.code which is also what the module + // runner does under the hood by default (we disable source maps inlining) + inlineSourceMap(node.transformResult) + } + + return { + ...result, + code: node?.transformResult?.code || result.code, + } +} + +const OTHER_SOURCE_MAP_REGEXP = new RegExp( + `//# ${SOURCEMAPPING_URL}=data:application/json[^,]+base64,([A-Za-z0-9+/=]+)$`, + 'gm', +) + +// we have to inline the source map ourselves, because +// - we don't need //# sourceURL since we are running code in VM +// - important in stack traces and the V8 coverage +// - we need to inject an empty line for --inspect-brk +function inlineSourceMap(result: TransformResult) { + const map = result.map + let code = result.code + + if ( + !map + || !('version' in map) + || code.includes(MODULE_RUNNER_SOURCEMAPPING_SOURCE) + ) { + return result + } + + // to reduce the payload size, we only inline vite node source map, because it's also the only one we use + OTHER_SOURCE_MAP_REGEXP.lastIndex = 0 + if (OTHER_SOURCE_MAP_REGEXP.test(code)) { + code = code.replace(OTHER_SOURCE_MAP_REGEXP, '') + } + + const sourceMap = { ...map } + + // If the first line is not present on source maps, add simple 1:1 mapping ([0,0,0,0], [1,0,0,0]) + // so that debuggers can be set to break on first line + if (sourceMap.mappings.startsWith(';')) { + sourceMap.mappings = `AAAA,CAAA${sourceMap.mappings}` + } + + result.code = `${code.trimEnd()}\n${ + MODULE_RUNNER_SOURCEMAPPING_SOURCE + }\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n` + + return result +} + +function genSourceMapUrl(map: Rollup.SourceMap | string): string { + if (typeof map !== 'string') { + map = JSON.stringify(map) + } + return `data:application/json;base64,${Buffer.from(map).toString('base64')}` +} + +function getCachedResult(result: Extract, cachedFsResults: Map): FetchCachedFileSystemResult { + const tmp = cachedFsResults.get(result.id) + if (!tmp) { + throw new Error(`The cached result was returned too early for ${result.id}.`) + } + return { + cached: true as const, + file: result.file, + id: result.id, + tmp, + url: result.url, + invalidate: result.invalidate, + } +} + +// serialize rollup error on server to preserve details as a test error +export function handleRollupError(e: unknown): never { + if ( + e instanceof Error + && ('plugin' in e || 'frame' in e || 'id' in e) + ) { + // eslint-disable-next-line no-throw-literal + throw { + name: e.name, + message: e.message, + stack: e.stack, + cause: e.cause, + __vitest_rollup_error__: { + plugin: (e as any).plugin, + id: (e as any).id, + loc: (e as any).loc, + frame: (e as any).frame, + }, + } + } + throw e +} + +/** + * Performs an atomic write operation using the write-then-rename pattern. + * + * Why we need this: + * - Ensures file integrity by never leaving partially written files on disk + * - Prevents other processes from reading incomplete data during writes + * - Particularly important for test files where incomplete writes could cause test failures + * + * The implementation writes to a temporary file first, then renames it to the target path. + * This rename operation is atomic on most filesystems (including POSIX-compliant ones), + * guaranteeing that other processes will only ever see the complete file. + * + * Added in https://github.com/vitest-dev/vitest/pull/7531 + */ +async function atomicWriteFile(realFilePath: string, data: string): Promise { + const dir = dirname(realFilePath) + const tmpFilePath = join(dir, `.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`) + + try { + await writeFile(tmpFilePath, data, 'utf-8') + await rename(tmpFilePath, realFilePath) + } + finally { + try { + if (await stat(tmpFilePath)) { + await unlink(tmpFilePath) + } + } + catch {} + } +} diff --git a/packages/vitest/src/node/environments/normalizeUrl.ts b/packages/vitest/src/node/environments/normalizeUrl.ts new file mode 100644 index 000000000000..490558d7683d --- /dev/null +++ b/packages/vitest/src/node/environments/normalizeUrl.ts @@ -0,0 +1,47 @@ +import type { DevEnvironment } from 'vite' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { cleanUrl, withTrailingSlash, wrapId } from '@vitest/utils' + +// this is copy pasted from vite +export function normalizeResolvedIdToUrl( + environment: DevEnvironment, + resolvedId: string, +): string { + const root = environment.config.root + const depsOptimizer = environment.depsOptimizer + + let url: string + + // normalize all imports into resolved URLs + // e.g. `import 'foo'` -> `import '/@fs/.../node_modules/foo/index.js'` + if (resolvedId.startsWith(withTrailingSlash(root))) { + // in root: infer short absolute path from root + url = resolvedId.slice(root.length) + } + else if ( + depsOptimizer?.isOptimizedDepFile(resolvedId) + // vite-plugin-react isn't following the leading \0 virtual module convention. + // This is a temporary hack to avoid expensive fs checks for React apps. + // We'll remove this as soon we're able to fix the react plugins. + || (resolvedId !== '/@react-refresh' + && path.isAbsolute(resolvedId) + && existsSync(cleanUrl(resolvedId))) + ) { + // an optimized deps may not yet exists in the filesystem, or + // a regular file exists but is out of root: rewrite to absolute /@fs/ paths + url = path.posix.join('/@fs/', resolvedId) + } + else { + url = resolvedId + } + + // if the resolved id is not a valid browser import specifier, + // prefix it to make it valid. We will strip this before feeding it + // back into the transform pipeline + if (url[0] !== '.' && url[0] !== '/') { + url = wrapId(resolvedId) + } + + return url +} diff --git a/packages/vitest/src/node/environments/serverRunner.ts b/packages/vitest/src/node/environments/serverRunner.ts new file mode 100644 index 000000000000..7dd8278ab48b --- /dev/null +++ b/packages/vitest/src/node/environments/serverRunner.ts @@ -0,0 +1,56 @@ +import type { DevEnvironment } from 'vite' +import type { VitestResolver } from '../resolver' +import type { ResolvedConfig } from '../types/config' +import { VitestModuleEvaluator } from '#module-evaluator' +import { ModuleRunner } from 'vite/module-runner' +import { createFetchModuleFunction } from './fetchModule' +import { normalizeResolvedIdToUrl } from './normalizeUrl' + +export class ServerModuleRunner extends ModuleRunner { + constructor( + private environment: DevEnvironment, + resolver: VitestResolver, + private config: ResolvedConfig, + ) { + const fetchModule = createFetchModuleFunction( + resolver, + false, + ) + super( + { + hmr: false, + sourcemapInterceptor: 'node', + transport: { + async invoke(event) { + if (event.type !== 'custom') { + throw new Error(`Vitest Module Runner doesn't support Vite HMR events.`) + } + const { data } = event.data + try { + const result = await fetchModule(data[0], data[1], environment, data[2]) + return { result } + } + catch (error) { + return { error } + } + }, + }, + }, + new VitestModuleEvaluator(), + ) + } + + async import(rawId: string): Promise { + const resolved = await this.environment.pluginContainer.resolveId( + rawId, + this.config.root, + ) + if (!resolved) { + return super.import(rawId) + } + // Vite will make "@vitest/coverage-v8" into "@vitest/coverage-v8.js" url + // instead of using an actual file path-like URL, so we resolve it here first + const url = normalizeResolvedIdToUrl(this.environment, resolved.id) + return super.import(url) + } +} diff --git a/packages/vitest/src/node/globalSetup.ts b/packages/vitest/src/node/globalSetup.ts index 51e5174f38ef..ce553bf0e056 100644 --- a/packages/vitest/src/node/globalSetup.ts +++ b/packages/vitest/src/node/globalSetup.ts @@ -1,4 +1,4 @@ -import type { ViteNodeRunner } from 'vite-node/client' +import type { ModuleRunner } from 'vite/module-runner' import type { TestProject } from './project' import { toArray } from '@vitest/utils' @@ -9,7 +9,7 @@ export interface GlobalSetupFile { } export async function loadGlobalSetupFiles( - runner: ViteNodeRunner, + runner: ModuleRunner, globalSetup: string | string[], ): Promise { const globalSetupFiles = toArray(globalSetup) @@ -20,9 +20,9 @@ export async function loadGlobalSetupFiles( async function loadGlobalSetupFile( file: string, - runner: ViteNodeRunner, + runner: ModuleRunner, ): Promise { - const m = await runner.executeFile(file) + const m = await runner.import(file) for (const exp of ['default', 'setup', 'teardown']) { if (m[exp] != null && typeof m[exp] !== 'function') { throw new Error( diff --git a/packages/vitest/src/node/plugins/coverageTransform.ts b/packages/vitest/src/node/plugins/coverageTransform.ts index 6091c92cf7e2..474666b57e85 100644 --- a/packages/vitest/src/node/plugins/coverageTransform.ts +++ b/packages/vitest/src/node/plugins/coverageTransform.ts @@ -1,15 +1,13 @@ import type { Plugin as VitePlugin } from 'vite' import type { Vitest } from '../core' -import { normalizeRequestId } from 'vite-node/utils' - export function CoverageTransform(ctx: Vitest): VitePlugin { return { name: 'vitest:coverage-transform', transform(srcCode, id) { return ctx.coverageProvider?.onFileTransform?.( srcCode, - normalizeRequestId(id), + id, this, ) }, diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index e7a66526a88b..6d8be4d281d9 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -4,7 +4,6 @@ import { deepClone, deepMerge, notNullish, - toArray, } from '@vitest/utils' import { relative } from 'pathe' import { defaultPort } from '../../constants' @@ -15,10 +14,11 @@ import { Vitest } from '../core' import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger' import { CoverageTransform } from './coverageTransform' import { CSSEnablerPlugin } from './cssEnabler' +import { MetaEnvReplacerPlugin } from './metaEnvReplacer' import { MocksPlugins } from './mocks' import { NormalizeURLPlugin } from './normalizeURL' import { VitestOptimizer } from './optimizer' -import { SsrReplacerPlugin } from './ssrReplacer' +import { ModuleRunnerTransform } from './runnerTransform' import { deleteDefineConfig, getDefaultResolveOptions, @@ -121,6 +121,9 @@ export async function VitestPlugin( ssr: { resolve: resolveOptions, }, + __vitest__: { + dev: {}, + }, }, test: { poolOptions: { @@ -164,29 +167,6 @@ export async function VitestPlugin( ) config.customLogger = silenceImportViteIgnoreWarning(config.customLogger) - // we want inline dependencies to be resolved by analyser plugin so module graph is populated correctly - if (viteConfig.ssr?.noExternal !== true) { - const inline = testConfig.server?.deps?.inline - if (inline === true) { - config.ssr = { noExternal: true } - } - else { - const noExternal = viteConfig.ssr?.noExternal - const noExternalArray - = typeof noExternal !== 'undefined' - ? toArray(noExternal) - : undefined - // filter the same packages - const uniqueInline - = inline && noExternalArray - ? inline.filter(dep => !noExternalArray.includes(dep)) - : inline - config.ssr = { - noExternal: uniqueInline, - } - } - } - // chokidar fsevents is unstable on macos when emitting "ready" event if ( process.platform === 'darwin' @@ -298,13 +278,14 @@ export async function VitestPlugin( }, }, }, - SsrReplacerPlugin(), + MetaEnvReplacerPlugin(), ...CSSEnablerPlugin(vitest), CoverageTransform(vitest), VitestCoreResolver(vitest), ...MocksPlugins(), VitestOptimizer(), NormalizeURLPlugin(), + ModuleRunnerTransform(), ].filter(notNullish) } function removeUndefinedValues>( diff --git a/packages/vitest/src/node/plugins/ssrReplacer.ts b/packages/vitest/src/node/plugins/metaEnvReplacer.ts similarity index 75% rename from packages/vitest/src/node/plugins/ssrReplacer.ts rename to packages/vitest/src/node/plugins/metaEnvReplacer.ts index 5a3afa244187..5e4a6c362d7e 100644 --- a/packages/vitest/src/node/plugins/ssrReplacer.ts +++ b/packages/vitest/src/node/plugins/metaEnvReplacer.ts @@ -1,13 +1,13 @@ import type { Plugin } from 'vite' +import { cleanUrl } from '@vitest/utils' import MagicString from 'magic-string' import { stripLiteral } from 'strip-literal' -import { cleanUrl } from 'vite-node/utils' // so people can reassign envs at runtime // import.meta.env.VITE_NAME = 'app' -> process.env.VITE_NAME = 'app' -export function SsrReplacerPlugin(): Plugin { +export function MetaEnvReplacerPlugin(): Plugin { return { - name: 'vitest:ssr-replacer', + name: 'vitest:meta-env-replacer', enforce: 'pre', transform(code, id) { if (!/\bimport\.meta\.env\b/.test(code)) { @@ -24,7 +24,11 @@ export function SsrReplacerPlugin(): Plugin { const startIndex = env.index! const endIndex = startIndex + env[0].length - s.overwrite(startIndex, endIndex, '__vite_ssr_import_meta__.env') + s.overwrite( + startIndex, + endIndex, + `Object.assign(/* istanbul ignore next */ globalThis.__vitest_worker__?.metaEnv ?? import.meta.env)`, + ) } if (s) { diff --git a/packages/vitest/src/node/plugins/normalizeURL.ts b/packages/vitest/src/node/plugins/normalizeURL.ts index 875d11cda5e1..ff00cf624aac 100644 --- a/packages/vitest/src/node/plugins/normalizeURL.ts +++ b/packages/vitest/src/node/plugins/normalizeURL.ts @@ -12,10 +12,9 @@ export function NormalizeURLPlugin(): Plugin { return { name: 'vitest:normalize-url', enforce: 'post', - transform(code, id, options) { - const ssr = options?.ssr === true + transform(code) { if ( - ssr + this.environment.name !== 'client' || !code.includes('new URL') || !code.includes('import.meta.url') ) { diff --git a/packages/vitest/src/node/plugins/optimizer.ts b/packages/vitest/src/node/plugins/optimizer.ts index 4c2f00ffe7ba..2404f4f3a8ba 100644 --- a/packages/vitest/src/node/plugins/optimizer.ts +++ b/packages/vitest/src/node/plugins/optimizer.ts @@ -1,7 +1,6 @@ import type { Plugin } from 'vite' import { resolve } from 'pathe' import { VitestCache } from '../cache' -import { resolveOptimizerConfig } from './utils' export function VitestOptimizer(): Plugin { return { @@ -10,14 +9,6 @@ export function VitestOptimizer(): Plugin { order: 'post', handler(viteConfig) { const testConfig = viteConfig.test || {} - const webOptimizer = resolveOptimizerConfig( - testConfig.deps?.optimizer?.web, - viteConfig.optimizeDeps, - ) - const ssrOptimizer = resolveOptimizerConfig( - testConfig.deps?.optimizer?.ssr, - viteConfig.ssr?.optimizeDeps, - ) const root = resolve(viteConfig.root || process.cwd()) const name = viteConfig.test?.name @@ -30,9 +21,6 @@ export function VitestOptimizer(): Plugin { : viteConfig.cacheDir, label, ) - viteConfig.optimizeDeps = webOptimizer.optimizeDeps - viteConfig.ssr ??= {} - viteConfig.ssr.optimizeDeps = ssrOptimizer.optimizeDeps }, }, } diff --git a/packages/vitest/src/node/plugins/runnerTransform.ts b/packages/vitest/src/node/plugins/runnerTransform.ts new file mode 100644 index 000000000000..781599b70ccb --- /dev/null +++ b/packages/vitest/src/node/plugins/runnerTransform.ts @@ -0,0 +1,163 @@ +import type { ResolvedConfig, UserConfig, Plugin as VitePlugin } from 'vite' +import { builtinModules } from 'node:module' +import { mergeConfig } from 'vite' +import { resolveOptimizerConfig } from './utils' + +export function ModuleRunnerTransform(): VitePlugin { + // make sure Vite always applies the module runner transform + return { + name: 'vitest:environments-module-runner', + config: { + order: 'post', + handler(config) { + const testConfig = config.test || {} + + config.environments ??= {} + + const names = new Set(Object.keys(config.environments)) + names.add('client') + names.add('ssr') + + const pool = config.test?.pool + if (pool === 'vmForks' || pool === 'vmThreads') { + names.add('__vitest_vm__') + } + + const external: (string | RegExp)[] = [] + const noExternal: (string | RegExp)[] = [] + + let noExternalAll: true | undefined + + for (const name of names) { + config.environments[name] ??= {} + + const environment = config.environments[name] + environment.dev ??= {} + // vm tests run using the native import mechanism + if (name === '__vitest_vm__') { + environment.dev.moduleRunnerTransform = false + environment.consumer = 'client' + } + else { + environment.dev.moduleRunnerTransform = true + } + environment.dev.preTransformRequests = false + environment.keepProcessEnv = true + + const resolveExternal = name === 'client' + ? config.resolve?.external + : [] + const resolveNoExternal = name === 'client' + ? config.resolve?.noExternal + : [] + + const topLevelResolveOptions: UserConfig['resolve'] = {} + if (resolveExternal != null) { + topLevelResolveOptions.external = resolveExternal + } + if (resolveNoExternal != null) { + topLevelResolveOptions.noExternal = resolveNoExternal + } + + const currentResolveOptions = mergeConfig( + topLevelResolveOptions, + environment.resolve || {}, + ) as ResolvedConfig['resolve'] + + const envNoExternal = resolveViteResolveOptions('noExternal', currentResolveOptions) + if (envNoExternal === true) { + noExternalAll = true + } + else { + noExternal.push(...envNoExternal) + } + + const envExternal = resolveViteResolveOptions('external', currentResolveOptions) + if (envExternal !== true) { + external.push(...envExternal) + } + + // remove Vite's externalization logic because we have our own (unfortunetly) + environment.resolve ??= {} + + environment.resolve.external = [ + ...builtinModules, + ...builtinModules.map(m => `node:${m}`), + ] + // by setting `noExternal` to `true`, we make sure that + // Vite will never use its own externalization mechanism + // to externalize modules and always resolve static imports + // in both SSR and Client environments + environment.resolve.noExternal = true + + if (name === '__vitest_vm__' || name === '__vitest__') { + continue + } + + const currentOptimizeDeps = environment.optimizeDeps || ( + name === 'client' + ? config.optimizeDeps + : name === 'ssr' + ? config.ssr?.optimizeDeps + : undefined + ) + + const optimizeDeps = resolveOptimizerConfig( + testConfig.deps?.optimizer?.[name], + currentOptimizeDeps, + ) + + // Vite respects the root level optimize deps, so we override it instead + if (name === 'client') { + config.optimizeDeps = optimizeDeps + environment.optimizeDeps = undefined + } + else if (name === 'ssr') { + config.ssr ??= {} + config.ssr.optimizeDeps = optimizeDeps + environment.optimizeDeps = undefined + } + else { + environment.optimizeDeps = optimizeDeps + } + } + + testConfig.server ??= {} + testConfig.server.deps ??= {} + + if (testConfig.server.deps.inline !== true) { + if (noExternalAll) { + testConfig.server.deps.inline = true + } + else if (noExternal.length) { + testConfig.server.deps.inline ??= [] + testConfig.server.deps.inline.push(...noExternal) + } + } + if (external.length) { + testConfig.server.deps.external ??= [] + testConfig.server.deps.external.push(...external) + } + }, + }, + } +} + +function resolveViteResolveOptions( + key: 'noExternal' | 'external', + options: ResolvedConfig['resolve'], +): true | (string | RegExp)[] { + if (Array.isArray(options[key])) { + return options[key] + } + else if ( + typeof options[key] === 'string' + || options[key] instanceof RegExp + ) { + return [options[key]] + } + else if (typeof options[key] === 'boolean') { + return true + } + return [] +} diff --git a/packages/vitest/src/node/plugins/utils.ts b/packages/vitest/src/node/plugins/utils.ts index 17b3553e21d2..27b3b64b13d3 100644 --- a/packages/vitest/src/node/plugins/utils.ts +++ b/packages/vitest/src/node/plugins/utils.ts @@ -11,27 +11,13 @@ import { rootDir } from '../../paths' export function resolveOptimizerConfig( _testOptions: DepsOptimizationOptions | undefined, viteOptions: DepOptimizationOptions | undefined, -): { cacheDir?: string; optimizeDeps: DepOptimizationOptions } { +): DepOptimizationOptions { const testOptions = _testOptions || {} - const newConfig: { cacheDir?: string; optimizeDeps: DepOptimizationOptions } - = {} as any - const [major, minor, fix] = viteVersion.split('.').map(Number) - const allowed - = major >= 5 - || (major === 4 && minor >= 4) - || (major === 4 && minor === 3 && fix >= 2) - if (!allowed && testOptions?.enabled === true) { - console.warn( - `Vitest: "deps.optimizer" is only available in Vite >= 4.3.2, current Vite version: ${viteVersion}`, - ) - } - // disabled by default - else { + let optimizeDeps: DepOptimizationOptions + if (testOptions.enabled !== true) { testOptions.enabled ??= false - } - if (!allowed || testOptions?.enabled !== true) { - newConfig.cacheDir = undefined - newConfig.optimizeDeps = { + + optimizeDeps = { // experimental in Vite >2.9.2, entries remains to help with older versions disabled: true, entries: [], @@ -55,7 +41,7 @@ export function resolveOptimizerConfig( (n: string) => !exclude.includes(n), ) - newConfig.optimizeDeps = { + optimizeDeps = { ...viteOptions, ...testOptions, noDiscovery: true, @@ -68,15 +54,13 @@ export function resolveOptimizerConfig( // `optimizeDeps.disabled` is deprecated since v5.1.0-beta.1 // https://github.com/vitejs/vite/pull/15184 - if ((major >= 5 && minor >= 1) || major >= 6) { - if (newConfig.optimizeDeps.disabled) { - newConfig.optimizeDeps.noDiscovery = true - newConfig.optimizeDeps.include = [] - } - delete newConfig.optimizeDeps.disabled + if (optimizeDeps.disabled) { + optimizeDeps.noDiscovery = true + optimizeDeps.include = [] } + delete optimizeDeps.disabled - return newConfig + return optimizeDeps } export function deleteDefineConfig(viteConfig: ViteConfig): Record { diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts index 4a1c255a0cd7..d4567b16db1e 100644 --- a/packages/vitest/src/node/plugins/workspace.ts +++ b/packages/vitest/src/node/plugins/workspace.ts @@ -10,10 +10,11 @@ import { VitestFilteredOutProjectError } from '../errors' import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger' import { CoverageTransform } from './coverageTransform' import { CSSEnablerPlugin } from './cssEnabler' +import { MetaEnvReplacerPlugin } from './metaEnvReplacer' import { MocksPlugins } from './mocks' import { NormalizeURLPlugin } from './normalizeURL' import { VitestOptimizer } from './optimizer' -import { SsrReplacerPlugin } from './ssrReplacer' +import { ModuleRunnerTransform } from './runnerTransform' import { deleteDefineConfig, getDefaultResolveOptions, @@ -92,6 +93,11 @@ export function WorkspaceVitestPlugin( } return { + environments: { + __vitest__: { + dev: {}, + }, + }, test: { name: { label: name, color }, }, @@ -202,12 +208,13 @@ export function WorkspaceVitestPlugin( await server.watcher.close() }, }, - SsrReplacerPlugin(), + MetaEnvReplacerPlugin(), ...CSSEnablerPlugin(project), CoverageTransform(project.vitest), ...MocksPlugins(), VitestProjectResolver(project.vitest), VitestOptimizer(), NormalizeURLPlugin(), + ModuleRunnerTransform(), ] } diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index 254c07625577..e5a179aceba6 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -144,7 +144,7 @@ export function createPool(ctx: Vitest): ProcessPool { return customPools.get(filepath)! } - const pool = await ctx.runner.executeId(filepath) + const pool = await ctx.runner.import(filepath) if (typeof pool.default !== 'function') { throw new TypeError( `Custom pool "${filepath}" must export a function as default export`, diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 351043ad2fb4..89726a957022 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -1,13 +1,10 @@ import type { RuntimeRPC } from '../../types/rpc' import type { TestProject } from '../project' import type { ResolveSnapshotPathHandlerContext } from '../types/config' -import { mkdirSync } from 'node:fs' -import { rename, stat, unlink, writeFile } from 'node:fs/promises' -import { dirname, join } from 'pathe' -import { hash } from '../hash' - -const created = new Set() -const promises = new Map>() +import { fileURLToPath } from 'node:url' +import { cleanUrl } from '@vitest/utils' +import { createFetchModuleFunction, handleRollupError } from '../environments/fetchModule' +import { normalizeResolvedIdToUrl } from '../environments/normalizeUrl' interface MethodsOptions { cacheFs?: boolean @@ -18,7 +15,44 @@ interface MethodsOptions { export function createMethodsRPC(project: TestProject, options: MethodsOptions = {}): RuntimeRPC { const ctx = project.vitest const cacheFs = options.cacheFs ?? false + const fetch = createFetchModuleFunction(project._resolver, cacheFs, project.tmpDir) return { + async fetch( + url, + importer, + environmentName, + options, + ) { + const environment = project.vite.environments[environmentName] + if (!environment) { + throw new Error(`The environment ${environmentName} was not defined in the Vite config.`) + } + + const start = performance.now() + + try { + return await fetch(url, importer, environment, options) + } + finally { + project.vitest.state.transformTime += (performance.now() - start) + } + }, + async resolve(id, importer, environmentName) { + const environment = project.vite.environments[environmentName] + if (!environment) { + throw new Error(`The environment ${environmentName} was not defined in the Vite config.`) + } + const resolved = await environment.pluginContainer.resolveId(id, importer) + if (!resolved) { + return null + } + return { + file: cleanUrl(resolved.id), + url: normalizeResolvedIdToUrl(environment, resolved.id), + id: resolved.id, + } + }, + snapshotSaved(snapshot) { ctx.snapshot.add(snapshot) }, @@ -27,48 +61,15 @@ export function createMethodsRPC(project: TestProject, options: MethodsOptions = config: project.serializedConfig, }) }, - async fetch(id, transformMode) { - const result = await project.vitenode.fetchResult(id, transformMode).catch(handleRollupError) - const code = result.code - if (!cacheFs || result.externalize) { - return result - } - if ('id' in result && typeof result.id === 'string') { - return { id: result.id } - } - - if (code == null) { - throw new Error(`Failed to fetch module ${id}`) + async transform(id) { + const environment = project.vite.environments.__vitest_vm__ + if (!environment) { + throw new Error(`The VM environment was not defined in the Vite config. This is a bug in Vitest. Please, open a new issue with reproduction.`) } - const dir = join(project.tmpDir, transformMode) - const name = hash('sha1', id, 'hex') - const tmp = join(dir, name) - if (!created.has(dir)) { - mkdirSync(dir, { recursive: true }) - created.add(dir) - } - if (promises.has(tmp)) { - await promises.get(tmp) - return { id: tmp } - } - promises.set( - tmp, - - atomicWriteFile(tmp, code) - // Fallback to non-atomic write for windows case where file already exists: - .catch(() => writeFile(tmp, code, 'utf-8')) - .finally(() => promises.delete(tmp)), - ) - await promises.get(tmp) - Object.assign(result, { id: tmp }) - return { id: tmp } - }, - resolveId(id, importer, transformMode) { - return project.vitenode.resolveId(id, importer, transformMode).catch(handleRollupError) - }, - transform(id, environment) { - return project.vitenode.transformModule(id, environment).catch(handleRollupError) + const url = normalizeResolvedIdToUrl(environment, fileURLToPath(id)) + const result = await environment.transformRequest(url).catch(handleRollupError) + return { code: result?.code } }, async onQueued(file) { if (options.collect) { @@ -119,58 +120,3 @@ export function createMethodsRPC(project: TestProject, options: MethodsOptions = }, } } - -// serialize rollup error on server to preserve details as a test error -function handleRollupError(e: unknown): never { - if ( - e instanceof Error - && ('plugin' in e || 'frame' in e || 'id' in e) - ) { - // eslint-disable-next-line no-throw-literal - throw { - name: e.name, - message: e.message, - stack: e.stack, - cause: e.cause, - __vitest_rollup_error__: { - plugin: (e as any).plugin, - id: (e as any).id, - loc: (e as any).loc, - frame: (e as any).frame, - }, - } - } - throw e -} - -/** - * Performs an atomic write operation using the write-then-rename pattern. - * - * Why we need this: - * - Ensures file integrity by never leaving partially written files on disk - * - Prevents other processes from reading incomplete data during writes - * - Particularly important for test files where incomplete writes could cause test failures - * - * The implementation writes to a temporary file first, then renames it to the target path. - * This rename operation is atomic on most filesystems (including POSIX-compliant ones), - * guaranteeing that other processes will only ever see the complete file. - * - * Added in https://github.com/vitest-dev/vitest/pull/7531 - */ -async function atomicWriteFile(realFilePath: string, data: string): Promise { - const dir = dirname(realFilePath) - const tmpFilePath = join(dir, `.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`) - - try { - await writeFile(tmpFilePath, data, 'utf-8') - await rename(tmpFilePath, realFilePath) - } - finally { - try { - if (await stat(tmpFilePath)) { - await unlink(tmpFilePath) - } - } - catch {} - } -} diff --git a/packages/vitest/src/node/printError.ts b/packages/vitest/src/node/printError.ts index 499168dcbf02..fcda0aeebca5 100644 --- a/packages/vitest/src/node/printError.ts +++ b/packages/vitest/src/node/printError.ts @@ -129,9 +129,9 @@ function printErrorInner( ? error.stacks[0] : stacks.find((stack) => { try { + const module = project._vite && project.getModuleById(stack.file) return ( - project._vite - && project.getModuleById(stack.file) + (module?.transformResult || module?.ssrTransformResult) && existsSync(stack.file) ) } @@ -261,6 +261,7 @@ const skipErrorProperties = new Set([ 'actual', 'expected', 'diffOptions', + 'runnerError', // webkit props 'sourceURL', 'column', @@ -448,9 +449,14 @@ export function generateCodeFrame( } const lineLength = lines[j].length + const strippedContent = stripVTControlCharacters(lines[j]) + + if (strippedContent.startsWith('//# sourceMappingURL')) { + continue + } // too long, maybe it's a minified file, skip for codeframe - if (stripVTControlCharacters(lines[j]).length > 200) { + if (strippedContent.length > 200) { return '' } diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 488d9e401aca..56f05e8672ce 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -1,10 +1,6 @@ import type { GlobOptions } from 'tinyglobby' -import type { - ModuleNode, - TransformResult, - ViteDevServer, - InlineConfig as ViteInlineConfig, -} from 'vite' +import type { ModuleNode, TransformResult, 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' import type { OnTestsRerunHandler, Vitest } from './core' @@ -28,16 +24,17 @@ import { deepMerge, nanoid, slash } from '@vitest/utils' import { isAbsolute, join, relative } from 'pathe' import pm from 'picomatch' import { glob } from 'tinyglobby' -import { ViteNodeRunner } from 'vite-node/client' -import { ViteNodeServer } from 'vite-node/server' import { setup } from '../api/setup' import { isBrowserEnabled, resolveConfig } from './config/resolveConfig' import { serializeConfig } from './config/serializeConfig' +import { ServerModuleRunner } from './environments/serverRunner' import { loadGlobalSetupFiles } from './globalSetup' import { CoverageTransform } from './plugins/coverageTransform' +import { MetaEnvReplacerPlugin } from './plugins/metaEnvReplacer' import { MocksPlugins } from './plugins/mocks' import { WorkspaceVitestPlugin } from './plugins/workspace' import { getFilePoolName } from './pool' +import { VitestResolver } from './resolver' import { TestSpecification } from './spec' import { createViteServer } from './vite' @@ -66,13 +63,13 @@ export class TestProject { */ public readonly tmpDir: string = join(tmpdir(), nanoid()) - /** @internal */ vitenode!: ViteNodeServer /** @internal */ typechecker?: Typechecker /** @internal */ _config?: ResolvedConfig /** @internal */ _vite?: ViteDevServer /** @internal */ _hash?: string + /** @internal */ _resolver!: VitestResolver - private runner!: ViteNodeRunner + private runner!: ModuleRunner private closingPromise: Promise | undefined @@ -556,6 +553,7 @@ export class TestProject { return true }, }), + MetaEnvReplacerPlugin(), ], [CoverageTransform(this.vitest)], ) @@ -601,7 +599,7 @@ export class TestProject { * @param moduleId The ID of the module in Vite module graph */ public import(moduleId: string): Promise { - return this.runner.executeId(moduleId) + return this.runner.import(moduleId) } /** @deprecated use `name` instead */ @@ -642,20 +640,15 @@ export class TestProject { this.closingPromise = undefined + this._resolver = new VitestResolver(server.config.cacheDir, this._config) this._vite = server - this.vitenode = new ViteNodeServer(server, this.config.server) - const node = this.vitenode - this.runner = new ViteNodeRunner({ - root: server.config.root, - base: server.config.base, - fetchModule(id: string) { - return node.fetchModule(id) - }, - resolveId(id: string, importer?: string) { - return node.resolveId(id, importer) - }, - }) + const environment = server.environments.__vitest__ + this.runner = new ServerModuleRunner( + environment, + this._resolver, + this._config, + ) } private _serializeOverriddenConfig(): SerializedConfig { @@ -715,10 +708,10 @@ export class TestProject { vitest.config.name || vitest.config.root, vitest, ) - project.vitenode = vitest.vitenode project.runner = vitest.runner project._vite = vitest.server project._config = vitest.config + project._resolver = vitest._resolver project._setHash() project._provideObject(vitest.config.provide) return project @@ -730,9 +723,9 @@ export class TestProject { parent.path, parent.vitest, ) - clone.vitenode = parent.vitenode clone.runner = parent.runner clone._vite = parent._vite + clone._resolver = parent._resolver clone._config = config clone._setHash() clone._parent = parent diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index e7903302123b..1dbc93d1f932 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -524,7 +524,7 @@ export abstract class BaseReporter implements Reporter { const environmentTime = sum(files, file => file.environmentLoad) const prepareTime = sum(files, file => file.prepareDuration) - const transformTime = sum(this.ctx.projects, project => project.vitenode.getTotalDuration()) + const transformTime = this.ctx.state.transformTime const typecheck = sum(this.ctx.projects, project => project.typechecker?.getResult().time) const timers = [ diff --git a/packages/vitest/src/node/reporters/blob.ts b/packages/vitest/src/node/reporters/blob.ts index 6a2a54ac6e6a..664320d96a25 100644 --- a/packages/vitest/src/node/reporters/blob.ts +++ b/packages/vitest/src/node/reporters/blob.ts @@ -150,6 +150,11 @@ export async function readBlobs( const moduleNode = project.vite.moduleGraph.createFileOnlyEntry(file) moduleNode.url = url moduleNode.id = moduleId + moduleNode.transformResult = { + // print error checks that transformResult is set + code: ' ', + map: null, + } project.vite.moduleGraph.idToModuleMap.set(moduleId, moduleNode) }) }) diff --git a/packages/vitest/src/node/reporters/utils.ts b/packages/vitest/src/node/reporters/utils.ts index 6e366a6c35c0..cf1067f0d27d 100644 --- a/packages/vitest/src/node/reporters/utils.ts +++ b/packages/vitest/src/node/reporters/utils.ts @@ -1,4 +1,4 @@ -import type { ViteNodeRunner } from 'vite-node/client' +import type { ModuleRunner } from 'vite/module-runner' import type { Vitest } from '../core' import type { ResolvedConfig } from '../types/config' import type { Reporter } from '../types/reporter' @@ -8,11 +8,11 @@ import { BenchmarkReportsMap, ReportersMap } from './index' async function loadCustomReporterModule( path: string, - runner: ViteNodeRunner, + runner: ModuleRunner, ): Promise C> { let customReporterModule: { default: new () => C } try { - customReporterModule = await runner.executeId(path) + customReporterModule = await runner.import(path) } catch (customReporterModuleError) { throw new Error(`Failed to load custom Reporter from ${path}`, { @@ -43,7 +43,7 @@ function createReporters( const [reporterName, reporterOptions] = referenceOrInstance if (reporterName === 'html') { - await ctx.packageInstaller.ensureInstalled('@vitest/ui', runner.root, ctx.version) + await ctx.packageInstaller.ensureInstalled('@vitest/ui', ctx.config.root, ctx.version) const CustomReporter = await loadCustomReporterModule( '@vitest/ui/reporter', runner, @@ -72,7 +72,7 @@ function createReporters( function createBenchmarkReporters( reporterReferences: Array, - runner: ViteNodeRunner, + runner: ModuleRunner, ): Promise<(Reporter | BenchmarkReporter)[]> { const promisedReporters = reporterReferences.map( async (referenceOrInstance) => { diff --git a/packages/vitest/src/node/resolver.ts b/packages/vitest/src/node/resolver.ts new file mode 100644 index 000000000000..46473d23fe58 --- /dev/null +++ b/packages/vitest/src/node/resolver.ts @@ -0,0 +1,222 @@ +import type { ResolvedConfig, ServerDepsOptions } from './types/config' +import { existsSync, promises as fsp } from 'node:fs' +import { isBuiltin } from 'node:module' +import { pathToFileURL } from 'node:url' +import { KNOWN_ASSET_RE } from '@vitest/utils' +import { findNearestPackageData } from '@vitest/utils/resolver' +import * as esModuleLexer from 'es-module-lexer' +import { dirname, extname, join, resolve } from 'pathe' +import { isWindows } from '../utils/env' + +export class VitestResolver { + private options: ExternalizeOptions + private externalizeCache = new Map>() + + constructor(cacheDir: string, config: ResolvedConfig) { + this.options = { + moduleDirectories: config.deps.moduleDirectories, + inlineFiles: config.setupFiles.flatMap((file) => { + if (file.startsWith('file://')) { + return file + } + const resolvedId = resolve(file) + return [resolvedId, pathToFileURL(resolvedId).href] + }), + cacheDir, + inline: config.server.deps?.inline, + external: config.server.deps?.external, + } + } + + public shouldExternalize(file: string): Promise { + return shouldExternalize(normalizeId(file), this.options, this.externalizeCache) + } +} + +function normalizeId(id: string) { + if (id.startsWith('/@fs/')) { + id = id.slice(isWindows ? 5 : 4) + } + return id +} + +interface ExternalizeOptions extends ServerDepsOptions { + moduleDirectories?: string[] + inlineFiles?: string[] + cacheDir?: string +} + +const BUILTIN_EXTENSIONS = new Set(['.mjs', '.cjs', '.node', '.wasm']) + +const ESM_EXT_RE = /\.(es|esm|esm-browser|esm-bundler|es6|module)\.js$/ +const ESM_FOLDER_RE = /\/(es|esm)\/(.*\.js)$/ + +const defaultInline = [ + /virtual:/, + /\.[mc]?ts$/, + + // special Vite query strings + /[?&](init|raw|url|inline)\b/, + // Vite returns a string for assets imports, even if it's inside "node_modules" + KNOWN_ASSET_RE, + + /^(?!.*node_modules).*\.mjs$/, + /^(?!.*node_modules).*\.cjs\.js$/, + // Vite client + /vite\w*\/dist\/client\/env.mjs/, +] + +const depsExternal = [ + /\/node_modules\/.*\.cjs\.js$/, + /\/node_modules\/.*\.mjs$/, +] + +export function guessCJSversion(id: string): string | undefined { + if (id.match(ESM_EXT_RE)) { + for (const i of [ + id.replace(ESM_EXT_RE, '.mjs'), + id.replace(ESM_EXT_RE, '.umd.js'), + id.replace(ESM_EXT_RE, '.cjs.js'), + id.replace(ESM_EXT_RE, '.js'), + ]) { + if (existsSync(i)) { + return i + } + } + } + if (id.match(ESM_FOLDER_RE)) { + for (const i of [ + id.replace(ESM_FOLDER_RE, '/umd/$1'), + id.replace(ESM_FOLDER_RE, '/cjs/$1'), + id.replace(ESM_FOLDER_RE, '/lib/$1'), + id.replace(ESM_FOLDER_RE, '/$1'), + ]) { + if (existsSync(i)) { + return i + } + } + } +} + +// The code from https://github.com/unjs/mlly/blob/c5bcca0cda175921344fd6de1bc0c499e73e5dac/src/syntax.ts#L51-L98 +async function isValidNodeImport(id: string) { + const extension = extname(id) + + if (BUILTIN_EXTENSIONS.has(extension)) { + return true + } + + if (extension !== '.js') { + return false + } + + id = id.replace('file:///', '') + + const package_ = findNearestPackageData(dirname(id)) + + if (package_.type === 'module') { + return true + } + + if (/\.(?:\w+-)?esm?(?:-\w+)?\.js$|\/esm?\//.test(id)) { + return false + } + + try { + await esModuleLexer.init + const code = await fsp.readFile(id, 'utf8') + const [, , , hasModuleSyntax] = esModuleLexer.parse(code) + return !hasModuleSyntax + } + catch { + return false + } +} + +export async function shouldExternalize( + id: string, + options: ExternalizeOptions, + cache: Map>, +): Promise { + if (!cache.has(id)) { + cache.set(id, _shouldExternalize(id, options)) + } + return cache.get(id)! +} + +async function _shouldExternalize( + id: string, + options?: ExternalizeOptions, +): Promise { + if (isBuiltin(id)) { + return id + } + + // data: should be processed by native import, + // since it is a feature of ESM. + // also externalize network imports since nodejs allows it when --experimental-network-imports + if (id.startsWith('data:') || /^(?:https?:)?\/\//.test(id)) { + return id + } + + const moduleDirectories = options?.moduleDirectories || ['/node_modules/'] + + if (matchExternalizePattern(id, moduleDirectories, options?.inline)) { + return false + } + if (options?.inlineFiles && options?.inlineFiles.includes(id)) { + return false + } + if (matchExternalizePattern(id, moduleDirectories, options?.external)) { + return id + } + + // Unless the user explicitly opted to inline them, externalize Vite deps. + // They are too big to inline by default. + if (options?.cacheDir && id.includes(options.cacheDir)) { + return id + } + + const isLibraryModule = moduleDirectories.some(dir => id.includes(dir)) + const guessCJS = isLibraryModule && options?.fallbackCJS + id = guessCJS ? guessCJSversion(id) || id : id + + if (matchExternalizePattern(id, moduleDirectories, defaultInline)) { + return false + } + if (matchExternalizePattern(id, moduleDirectories, depsExternal)) { + return id + } + + if (isLibraryModule && (await isValidNodeImport(id))) { + return id + } + + return false +} + +function matchExternalizePattern( + id: string, + moduleDirectories: string[], + patterns?: (string | RegExp)[] | true, +) { + if (patterns == null) { + return false + } + if (patterns === true) { + return true + } + for (const ex of patterns) { + if (typeof ex === 'string') { + if (moduleDirectories.some(dir => id.includes(join(dir, ex)))) { + return true + } + } + else { + if (ex.test(id)) { + return true + } + } + } + return false +} diff --git a/packages/vitest/src/node/sequencers/BaseSequencer.ts b/packages/vitest/src/node/sequencers/BaseSequencer.ts index c83d3a527f97..c236408d60ff 100644 --- a/packages/vitest/src/node/sequencers/BaseSequencer.ts +++ b/packages/vitest/src/node/sequencers/BaseSequencer.ts @@ -1,8 +1,8 @@ import type { Vitest } from '../core' import type { TestSpecification } from '../spec' import type { TestSequencer } from './types' +import { slash } from '@vitest/utils' import { relative, resolve } from 'pathe' -import { slash } from 'vite-node/utils' import { hash } from '../hash' export class BaseSequencer implements TestSequencer { diff --git a/packages/vitest/src/node/specifications.ts b/packages/vitest/src/node/specifications.ts index f0d25b6fd9fa..9b6d205626fa 100644 --- a/packages/vitest/src/node/specifications.ts +++ b/packages/vitest/src/node/specifications.ts @@ -176,8 +176,8 @@ export class VitestSpecifications { } deps.add(filepath) - const mod = project.vite.moduleGraph.getModuleById(filepath) - const transformed = mod?.ssrTransformResult || await project.vitenode.transformRequest(filepath) + const mod = project.vite.environments.ssr.moduleGraph.getModuleById(filepath) + const transformed = mod?.transformResult || await project.vite.environments.ssr.transformRequest(filepath) if (!transformed) { return } diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts index 470d1906a695..7a68fdfc0f39 100644 --- a/packages/vitest/src/node/state.ts +++ b/packages/vitest/src/node/state.ts @@ -24,6 +24,7 @@ export class StateManager { processTimeoutCauses: Set = new Set() reportedTasksMap: WeakMap = new WeakMap() blobs?: MergedBlobs + transformTime = 0 onUnhandledError?: OnUnhandledErrorCallback diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 272f3749059d..b62f14e143ef 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -65,7 +65,6 @@ type UnsupportedProperties // non-browser options | 'api' | 'deps' - | 'testTransformMode' | 'environment' | 'environmentOptions' | 'server' diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index cdcc79546a45..335e2530ac72 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -4,7 +4,6 @@ import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' import type { SnapshotStateOptions } from '@vitest/snapshot' import type { SerializedDiffOptions } from '@vitest/utils/diff' import type { AliasOptions, ConfigEnv, DepOptimizationConfig, ServerOptions, UserConfig as ViteUserConfig } from 'vite' -import type { ViteNodeServerOptions } from 'vite-node' import type { ChaiConfig } from '../../integrations/chai/config' import type { SerializedConfig } from '../../runtime/config' import type { Arrayable, LabelColor, ParsedStack, ProvidedContext, TestError } from '../../types/general' @@ -135,32 +134,11 @@ export type DepsOptimizationOptions = Omit< enabled?: boolean } -export interface TransformModePatterns { - /** - * Use SSR transform pipeline for all modules inside specified tests. - * Vite plugins will receive `ssr: true` flag when processing those files. - * - * @default tests with node or edge environment - */ - ssr?: string[] - /** - * First do a normal transform pipeline (targeting browser), - * then then do a SSR rewrite to run the code in Node. - * Vite plugins will receive `ssr: false` flag when processing those files. - * - * @default tests with jsdom or happy-dom environment - */ - web?: string[] -} - interface DepsOptions { /** * Enable dependency optimization. This can improve the performance of your tests. */ - optimizer?: { - web?: DepsOptimizationOptions - ssr?: DepsOptimizationOptions - } + optimizer?: Partial> web?: { /** * Should Vitest process assets (.png, .svg, .jpg, etc) files and resolve them like Vite does in the browser. @@ -193,27 +171,6 @@ interface DepsOptions { */ transformGlobPattern?: RegExp | RegExp[] } - /** - * Externalize means that Vite will bypass the package to native Node. - * - * Externalized dependencies will not be applied Vite's transformers and resolvers. - * And does not support HMR on reload. - * - * Typically, packages under `node_modules` are externalized. - * - * @deprecated If you rely on vite-node directly, use `server.deps.external` instead. Otherwise, consider using `deps.optimizer.{web,ssr}.exclude`. - */ - external?: (string | RegExp)[] - /** - * Vite will process inlined modules. - * - * This could be helpful to handle packages that ship `.js` in ESM format (that Node can't handle). - * - * If `true`, every dependency will be inlined - * - * @deprecated If you rely on vite-node directly, use `server.deps.inline` instead. Otherwise, consider using `deps.optimizer.{web,ssr}.include`. - */ - inline?: (string | RegExp)[] | true /** * Interpret CJS module's default as named exports @@ -222,17 +179,6 @@ interface DepsOptions { */ interopDefault?: boolean - /** - * When a dependency is a valid ESM package, try to guess the cjs version based on the path. - * This will significantly improve the performance in huge repo, but might potentially - * cause some misalignment if a package have different logic in ESM and CJS mode. - * - * @default false - * - * @deprecated Use `server.deps.fallbackCJS` instead. - */ - fallbackCJS?: boolean - /** * A list of directories relative to the config file that should be treated as module directories. * @@ -297,10 +243,9 @@ export interface InlineConfig { */ deps?: DepsOptions - /** - * Vite-node server options - */ - server?: Omit + server?: { + deps?: ServerDepsOptions + } /** * Base directory to scan for the test files @@ -560,11 +505,6 @@ export interface InlineConfig { */ uiBase?: string - /** - * Determine the transform method for all modules imported inside a test that matches the glob pattern. - */ - testTransformMode?: TransformModePatterns - /** * Format options for snapshot testing. */ @@ -1108,6 +1048,31 @@ type NonProjectOptions | 'fileParallelism' | 'watchTriggerPatterns' +export interface ServerDepsOptions { + /** + * Externalize means that Vite will bpass the package to native Node. + * + * Externalized dependencies will not be applied Vite's transformers and resolvers. + * And does not support HMR on reload. + * + * Typically, packages under `node_modules` are externalized. + */ + external?: (string | RegExp)[] + /** + * Vite will process inlined modules. + * + * This could be helpful to handle packages that ship `.js` in ESM format (that Node can't handle). + * + * If `true`, every dependency will be inlined + */ + inline?: (string | RegExp)[] | true + /** + * Try to guess the CJS version of a package when it's invalid ESM + * @default false + */ + fallbackCJS?: boolean +} + export type ProjectConfig = Omit< InlineConfig, NonProjectOptions diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index 0023c778baca..eb4a249d7e72 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -59,7 +59,7 @@ export interface ReportContext { } export interface CoverageModuleLoader extends RuntimeCoverageModuleLoader { - executeId: (id: string) => Promise<{ default: CoverageProviderModule }> + import: (id: string) => Promise<{ default: CoverageProviderModule }> } export interface CoverageProviderModule extends RuntimeCoverageProviderModule { diff --git a/packages/vitest/src/public/config.ts b/packages/vitest/src/public/config.ts index 497535513762..ec4c9dbacb49 100644 --- a/packages/vitest/src/public/config.ts +++ b/packages/vitest/src/public/config.ts @@ -10,7 +10,6 @@ import type { } from '../node/types/config' import '../node/types/vite' -export { extraInlineDeps } from '../constants' // will import vitest declare test in module 'vite' export { configDefaults, diff --git a/packages/vitest/src/public/execute.ts b/packages/vitest/src/public/execute.ts deleted file mode 100644 index 05fef48145a4..000000000000 --- a/packages/vitest/src/public/execute.ts +++ /dev/null @@ -1 +0,0 @@ -export { VitestExecutor } from '../runtime/execute' diff --git a/packages/vitest/src/public/index.ts b/packages/vitest/src/public/index.ts index 5f6c10699c36..2f8f53314d35 100644 --- a/packages/vitest/src/public/index.ts +++ b/packages/vitest/src/public/index.ts @@ -136,3 +136,5 @@ export type { export type { SerializedError } from '@vitest/utils' export type { SerializedTestSpecification } export type { DiffOptions } from '@vitest/utils/diff' + +export { EvaluatedModules } from 'vite/module-runner' diff --git a/packages/vitest/src/public/module-runner.ts b/packages/vitest/src/public/module-runner.ts new file mode 100644 index 000000000000..841257fdb48f --- /dev/null +++ b/packages/vitest/src/public/module-runner.ts @@ -0,0 +1,14 @@ +export { + VitestModuleEvaluator, + type VitestModuleEvaluatorOptions, +} from '../runtime/moduleRunner/moduleEvaluator' +export { + VitestModuleRunner, + type VitestModuleRunnerOptions, +} from '../runtime/moduleRunner/moduleRunner' +export { + type ContextModuleRunnerOptions, + startVitestModuleRunner, + VITEST_VM_CONTEXT_SYMBOL, +} from '../runtime/moduleRunner/startModuleRunner' +export { getWorkerState } from '../runtime/utils' diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index 6540c99321e5..6e95b826a324 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -98,7 +98,6 @@ export type { SequenceHooks, SequenceSetupFiles, UserConfig as TestUserConfig, - TransformModePatterns, TypecheckConfig, UserWorkspaceConfig, VitestEnvironment, diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index a3b6b45f122d..fc452a4dd375 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -71,14 +71,7 @@ export interface SerializedConfig { transformCss?: boolean transformGlobPattern?: RegExp | RegExp[] } - optimizer: { - web: { - enabled: boolean - } - ssr: { - enabled: boolean - } - } + optimizer: Record interopDefault: boolean | undefined moduleDirectories: string[] | undefined } diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts deleted file mode 100644 index 862275a78eae..000000000000 --- a/packages/vitest/src/runtime/execute.ts +++ /dev/null @@ -1,404 +0,0 @@ -import type { ViteNodeRunnerOptions } from 'vite-node' -import type { ModuleCacheMap, ModuleExecutionInfo } from 'vite-node/client' -import type { WorkerGlobalState } from '../types/worker' -import type { ExternalModulesExecutor } from './external-executor' -import fs from 'node:fs' -import { pathToFileURL } from 'node:url' -import vm from 'node:vm' -import { processError } from '@vitest/utils/error' -import { normalize } from 'pathe' -import { DEFAULT_REQUEST_STUBS, ViteNodeRunner } from 'vite-node/client' -import { - isInternalRequest, - isNodeBuiltin, - isPrimitive, - toFilePath, -} from 'vite-node/utils' -import { distDir } from '../paths' -import { VitestMocker } from './mocker' - -const normalizedDistDir = normalize(distDir) - -const { readFileSync } = fs - -export interface ExecuteOptions extends ViteNodeRunnerOptions { - moduleDirectories?: string[] - state: WorkerGlobalState - context?: vm.Context - externalModulesExecutor?: ExternalModulesExecutor -} - -export async function createVitestExecutor(options: ExecuteOptions): Promise { - const runner = new VitestExecutor(options) - - await runner.executeId('/@vite/env') - await runner.mocker.initializeSpyModule() - - return runner -} - -const externalizeMap = new Map() - -export interface ContextExecutorOptions { - moduleCache?: ModuleCacheMap - context?: vm.Context - externalModulesExecutor?: ExternalModulesExecutor - state: WorkerGlobalState - requestStubs: Record -} - -const bareVitestRegexp = /^@?vitest(?:\/|$)/ - -const dispose: (() => void)[] = [] - -function listenForErrors(state: () => WorkerGlobalState) { - dispose.forEach(fn => fn()) - dispose.length = 0 - - function catchError(err: unknown, type: string, event: 'uncaughtException' | 'unhandledRejection') { - const worker = state() - - const listeners = process.listeners(event as 'uncaughtException') - // if there is another listener, assume that it's handled by user code - // one is Vitest's own listener - if (listeners.length > 1) { - return - } - - const error = processError(err) - if (!isPrimitive(error)) { - error.VITEST_TEST_NAME = worker.current?.type === 'test' ? worker.current.name : undefined - if (worker.filepath) { - error.VITEST_TEST_PATH = worker.filepath - } - error.VITEST_AFTER_ENV_TEARDOWN = worker.environmentTeardownRun - } - state().rpc.onUnhandledError(error, type) - } - - const uncaughtException = (e: Error) => catchError(e, 'Uncaught Exception', 'uncaughtException') - const unhandledRejection = (e: Error) => catchError(e, 'Unhandled Rejection', 'unhandledRejection') - - process.on('uncaughtException', uncaughtException) - process.on('unhandledRejection', unhandledRejection) - - dispose.push(() => { - process.off('uncaughtException', uncaughtException) - process.off('unhandledRejection', unhandledRejection) - }) -} - -const relativeIds: Record = {} - -function getVitestImport(id: string, state: () => WorkerGlobalState) { - if (externalizeMap.has(id)) { - return { externalize: externalizeMap.get(id)! } - } - // always externalize Vitest because we import from there before running tests - // so we already have it cached by Node.js - const root = state().config.root - const relativeRoot = relativeIds[root] ?? (relativeIds[root] = normalizedDistDir.slice(root.length)) - if ( - // full dist path - id.includes(distDir) - || id.includes(normalizedDistDir) - // "relative" to root path: - // /node_modules/.pnpm/vitest/dist - || (relativeRoot && relativeRoot !== '/' && id.startsWith(relativeRoot)) - ) { - const { path } = toFilePath(id, root) - const externalize = pathToFileURL(path).toString() - externalizeMap.set(id, externalize) - return { externalize } - } - if (bareVitestRegexp.test(id)) { - externalizeMap.set(id, id) - return { externalize: id } - } - return null -} - -export async function startVitestExecutor(options: ContextExecutorOptions): Promise { - const state = (): WorkerGlobalState => - // @ts-expect-error injected untyped global - globalThis.__vitest_worker__ || 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 getTransformMode = () => { - return state().environment.transformMode ?? 'ssr' - } - - return await createVitestExecutor({ - async fetchModule(id) { - const vitest = getVitestImport(id, state) - if (vitest) { - return vitest - } - - const result = await rpc().fetch(id, getTransformMode()) - if (result.id && !result.externalize) { - const code = readFileSync(result.id, 'utf-8') - return { code } - } - return result - }, - resolveId(id, importer) { - return rpc().resolveId(id, importer, getTransformMode()) - }, - get moduleCache() { - return state().moduleCache as ModuleCacheMap - }, - get moduleExecutionInfo() { - return state().moduleExecutionInfo - }, - get interopDefault() { - return state().config.deps.interopDefault - }, - get moduleDirectories() { - return state().config.deps.moduleDirectories - }, - get root() { - return state().config.root - }, - get base() { - return state().config.base - }, - ...options, - }) -} - -function updateStyle(id: string, css: string) { - if (typeof document === 'undefined') { - return - } - - const element = document.querySelector(`[data-vite-dev-id="${id}"]`) - if (element) { - element.textContent = css - return - } - - const head = document.querySelector('head') - const style = document.createElement('style') - style.setAttribute('type', 'text/css') - style.setAttribute('data-vite-dev-id', id) - style.textContent = css - head?.appendChild(style) -} - -function removeStyle(id: string) { - if (typeof document === 'undefined') { - return - } - const sheet = document.querySelector(`[data-vite-dev-id="${id}"]`) - if (sheet) { - document.head.removeChild(sheet) - } -} - -export function getDefaultRequestStubs(context?: vm.Context): { - '/@vite/client': any - '@vite/client': any -} { - if (!context) { - const clientStub = { - ...DEFAULT_REQUEST_STUBS['@vite/client'], - updateStyle, - removeStyle, - } - return { - '/@vite/client': clientStub, - '@vite/client': clientStub, - } - } - const clientStub = vm.runInContext( - `(defaultClient) => ({ ...defaultClient, updateStyle: ${updateStyle.toString()}, removeStyle: ${removeStyle.toString()} })`, - context, - )(DEFAULT_REQUEST_STUBS['@vite/client']) - return { - '/@vite/client': clientStub, - '@vite/client': clientStub, - } -} - -export class VitestExecutor extends ViteNodeRunner { - public mocker: VitestMocker - public externalModules?: ExternalModulesExecutor - - private primitives: { - Object: typeof Object - Reflect: typeof Reflect - Symbol: typeof Symbol - } - - constructor(public options: ExecuteOptions) { - super({ - ...options, - // interop is done inside the external executor instead - interopDefault: options.context ? false : options.interopDefault, - }) - - this.mocker = new VitestMocker(this) - - if (!options.context) { - Object.defineProperty(globalThis, '__vitest_mocker__', { - value: this.mocker, - writable: true, - configurable: true, - }) - this.primitives = { Object, Reflect, Symbol } - } - else if (options.externalModulesExecutor) { - this.primitives = vm.runInContext( - '({ Object, Reflect, Symbol })', - options.context, - ) - this.externalModules = options.externalModulesExecutor - } - else { - throw new Error( - 'When context is provided, externalModulesExecutor must be provided as well.', - ) - } - } - - protected getContextPrimitives(): { - Object: typeof Object - Reflect: typeof Reflect - Symbol: typeof Symbol - } { - return this.primitives - } - - get state(): WorkerGlobalState { - // @ts-expect-error injected untyped global - return globalThis.__vitest_worker__ || this.options.state - } - - get moduleExecutionInfo(): ModuleExecutionInfo | undefined { - return this.options.moduleExecutionInfo - } - - shouldResolveId(id: string, _importee?: string | undefined): boolean { - if (isInternalRequest(id) || id.startsWith('data:')) { - return false - } - const transformMode = this.state.environment?.transformMode ?? 'ssr' - // do not try and resolve node builtins in Node - // import('url') returns Node internal even if 'url' package is installed - return transformMode === 'ssr' - ? !isNodeBuiltin(id) - : !id.startsWith('node:') - } - - async originalResolveUrl(id: string, importer?: string): Promise<[url: string, fsPath: string]> { - return super.resolveUrl(id, importer) - } - - async resolveUrl(id: string, importer?: string): Promise<[url: string, fsPath: string]> { - if (VitestMocker.pendingIds.length) { - await this.mocker.resolveMocks() - } - - if (importer && importer.startsWith('mock:')) { - importer = importer.slice(5) - } - try { - return await super.resolveUrl(id, importer) - } - catch (error: any) { - if (error.code === 'ERR_MODULE_NOT_FOUND') { - const { id } = error[Symbol.for('vitest.error.not_found.data')] - const path = this.mocker.normalizePath(id) - const mock = this.mocker.getDependencyMock(path) - if (mock !== undefined) { - return [id, id] as [string, string] - } - } - throw error - } - } - - protected async runModule(context: Record, transformed: string): Promise { - const vmContext = this.options.context - - if (!vmContext || !this.externalModules) { - return super.runModule(context, transformed) - } - - // add 'use strict' since ESM enables it by default - const codeDefinition = `'use strict';async (${Object.keys(context).join( - ',', - )})=>{{` - const code = `${codeDefinition}${transformed}\n}}` - const options = { - filename: context.__filename, - lineOffset: 0, - columnOffset: -codeDefinition.length, - } - - const finishModuleExecutionInfo = this.startCalculateModuleExecutionInfo(options.filename, codeDefinition.length) - - try { - const fn = vm.runInContext(code, vmContext, { - ...options, - // if we encountered an import, it's not inlined - importModuleDynamically: this.externalModules - .importModuleDynamically as any, - } as any) - await fn(...Object.values(context)) - } - finally { - this.options.moduleExecutionInfo?.set(options.filename, finishModuleExecutionInfo()) - } - } - - public async importExternalModule(path: string): Promise { - if (this.externalModules) { - return this.externalModules.import(path) - } - return super.importExternalModule(path) - } - - async dependencyRequest( - id: string, - fsPath: string, - callstack: string[], - ): Promise { - const mocked = await this.mocker.requestWithMock(fsPath, callstack) - - if (typeof mocked === 'string') { - return super.dependencyRequest(mocked, mocked, callstack) - } - if (mocked && typeof mocked === 'object') { - return mocked - } - return super.dependencyRequest(id, fsPath, callstack) - } - - prepareContext(context: Record): Record { - // support `import.meta.vitest` for test entry - if ( - this.state.filepath - && normalize(this.state.filepath) === normalize(context.__filename) - ) { - const globalNamespace = this.options.context || globalThis - Object.defineProperty(context.__vite_ssr_import_meta__, 'vitest', { - // @ts-expect-error injected untyped global - get: () => globalNamespace.__vitest_index__, - }) - } - - if (this.options.context && this.externalModules) { - context.require = this.externalModules.createRequire(context.__filename) - } - - return context - } -} diff --git a/packages/vitest/src/runtime/external-executor.ts b/packages/vitest/src/runtime/external-executor.ts index 680294603a23..f53f1b5cec1d 100644 --- a/packages/vitest/src/runtime/external-executor.ts +++ b/packages/vitest/src/runtime/external-executor.ts @@ -3,15 +3,16 @@ import type { RuntimeRPC } from '../types/rpc' import type { FileMap } from './vm/file-map' import type { VMModule } from './vm/types' import fs from 'node:fs' -import { dirname } from 'node:path' +import { isBuiltin } from 'node:module' import { fileURLToPath, pathToFileURL } from 'node:url' -import { extname, join, normalize } from 'pathe' -import { getCachedData, isBareImport, isNodeBuiltin, setCacheData } from 'vite-node/utils' +import { isBareImport } from '@vitest/utils' +import { findNearestPackageData } from '@vitest/utils/resolver' +import { extname, normalize } from 'pathe' import { CommonjsExecutor } from './vm/commonjs-executor' import { EsmExecutor } from './vm/esm-executor' import { ViteExecutor } from './vm/vite-executor' -const { existsSync, statSync } = fs +const { existsSync } = fs // always defined when we use vm pool const nativeResolve = import.meta.resolve! @@ -119,48 +120,13 @@ export class ExternalModulesExecutor { return nativeResolve(specifier, parent) } - private findNearestPackageData(basedir: string): { - type?: 'module' | 'commonjs' - } { - const originalBasedir = basedir - const packageCache = this.options.packageCache - while (basedir) { - const cached = getCachedData(packageCache, basedir, originalBasedir) - if (cached) { - return cached - } - - const pkgPath = join(basedir, 'package.json') - try { - if (statSync(pkgPath, { throwIfNoEntry: false })?.isFile()) { - const pkgData = JSON.parse(this.fs.readFile(pkgPath)) - - if (packageCache) { - setCacheData(packageCache, pkgData, basedir, originalBasedir) - } - - return pkgData - } - } - catch {} - - const nextBasedir = dirname(basedir) - if (nextBasedir === basedir) { - break - } - basedir = nextBasedir - } - - return {} - } - private getModuleInformation(identifier: string): ModuleInformation { if (identifier.startsWith('data:')) { return { type: 'data', url: identifier, path: identifier } } const extension = extname(identifier) - if (extension === '.node' || isNodeBuiltin(identifier)) { + if (extension === '.node' || isBuiltin(identifier)) { return { type: 'builtin', url: identifier, path: identifier } } @@ -193,7 +159,7 @@ export class ExternalModulesExecutor { type = 'wasm' } else { - const pkgData = this.findNearestPackageData(normalize(pathUrl)) + const pkgData = findNearestPackageData(normalize(pathUrl)) type = pkgData.type === 'module' ? 'module' : 'commonjs' } diff --git a/packages/vitest/src/runtime/moduleRunner/cachedResolver.ts b/packages/vitest/src/runtime/moduleRunner/cachedResolver.ts new file mode 100644 index 000000000000..b182448900d2 --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/cachedResolver.ts @@ -0,0 +1,49 @@ +import type { WorkerGlobalState } from '../../types/worker' +import { pathToFileURL } from 'node:url' +import { join, normalize } from 'pathe' +import { distDir } from '../../paths' + +const bareVitestRegexp = /^@?vitest(?:\/|$)/ +const normalizedDistDir = normalize(distDir) +const relativeIds: Record = {} +const externalizeMap = new Map() + +// all Vitest imports always need to be externalized +export function getCachedVitestImport( + id: string, + state: () => WorkerGlobalState, +): null | { externalize: string; type: 'module' } { + if (id.startsWith('/@fs/') || id.startsWith('\\@fs\\')) { + id = id.slice(process.platform === 'win32' ? 5 : 4) + } + + if (externalizeMap.has(id)) { + return { externalize: externalizeMap.get(id)!, type: 'module' } + } + // always externalize Vitest because we import from there before running tests + // so we already have it cached by Node.js + const root = state().config.root + const relativeRoot = relativeIds[root] ?? (relativeIds[root] = normalizedDistDir.slice(root.length)) + if (id.includes(distDir) || id.includes(normalizedDistDir)) { + const externalize = id.startsWith('file://') + ? id + : pathToFileURL(id).toString() + externalizeMap.set(id, externalize) + return { externalize, type: 'module' } + } + if ( + // "relative" to root path: + // /node_modules/.pnpm/vitest/dist + (relativeRoot && relativeRoot !== '/' && id.startsWith(relativeRoot)) + ) { + const path = join(root, id) + const externalize = pathToFileURL(path).toString() + externalizeMap.set(id, externalize) + return { externalize, type: 'module' } + } + if (bareVitestRegexp.test(id)) { + externalizeMap.set(id, id) + return { externalize: id, type: 'module' } + } + return null +} diff --git a/packages/vitest/src/runtime/moduleRunner/errorCatcher.ts b/packages/vitest/src/runtime/moduleRunner/errorCatcher.ts new file mode 100644 index 000000000000..9099316a3a28 --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/errorCatcher.ts @@ -0,0 +1,41 @@ +import type { WorkerGlobalState } from '../../types/worker' +import { processError } from '@vitest/utils/error' + +const dispose: (() => void)[] = [] + +export function listenForErrors(state: () => WorkerGlobalState): void { + dispose.forEach(fn => fn()) + dispose.length = 0 + + function catchError(err: unknown, type: string, event: 'uncaughtException' | 'unhandledRejection') { + const worker = state() + + const listeners = process.listeners(event as 'uncaughtException') + // if there is another listener, assume that it's handled by user code + // one is Vitest's own listener + if (listeners.length > 1) { + return + } + + const error = processError(err) + if (typeof error === 'object' && error != null) { + error.VITEST_TEST_NAME = worker.current?.type === 'test' ? worker.current.name : undefined + if (worker.filepath) { + error.VITEST_TEST_PATH = worker.filepath + } + error.VITEST_AFTER_ENV_TEARDOWN = worker.environmentTeardownRun + } + state().rpc.onUnhandledError(error, type) + } + + const uncaughtException = (e: Error) => catchError(e, 'Uncaught Exception', 'uncaughtException') + const unhandledRejection = (e: Error) => catchError(e, 'Unhandled Rejection', 'unhandledRejection') + + process.on('uncaughtException', uncaughtException) + process.on('unhandledRejection', unhandledRejection) + + dispose.push(() => { + process.off('uncaughtException', uncaughtException) + process.off('unhandledRejection', unhandledRejection) + }) +} diff --git a/packages/vitest/src/runtime/moduleRunner/moduleDebug.ts b/packages/vitest/src/runtime/moduleRunner/moduleDebug.ts new file mode 100644 index 000000000000..61f8ea00e638 --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/moduleDebug.ts @@ -0,0 +1,61 @@ +export type ModuleExecutionInfo = Map + +export interface ModuleExecutionInfoEntry { + startOffset: number + + /** The duration that was spent executing the module. */ + duration: number + + /** The time that was spent executing the module itself and externalized imports. */ + selfTime: number +} + +/** Stack to track nested module execution for self-time calculation. */ +export type ExecutionStack = Array<{ + /** The file that is being executed. */ + filename: string + + /** The start time of this module's execution. */ + startTime: number + + /** Accumulated time spent importing all sub-imports. */ + subImportTime: number +}> + +const performanceNow = performance.now.bind(performance) + +export class ModuleDebug { + private executionStack: ExecutionStack = [] + + startCalculateModuleExecutionInfo(filename: string, startOffset: number): () => ModuleExecutionInfoEntry { + const startTime = performanceNow() + + this.executionStack.push({ + filename, + startTime, + subImportTime: 0, + }) + + return () => { + const duration = performanceNow() - startTime + + const currentExecution = this.executionStack.pop() + + if (currentExecution == null) { + throw new Error('Execution stack is empty, this should never happen') + } + + const selfTime = duration - currentExecution.subImportTime + + if (this.executionStack.length > 0) { + this.executionStack.at(-1)!.subImportTime += duration + } + + return { + startOffset, + duration, + selfTime, + } + } + } +} diff --git a/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts new file mode 100644 index 000000000000..b57750eeb005 --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts @@ -0,0 +1,496 @@ +import type { + EvaluatedModuleNode, + ModuleEvaluator, + ModuleRunnerContext, + ModuleRunnerImportMeta, +} from 'vite/module-runner' +import type { ModuleExecutionInfo } from './moduleDebug' +import type { VitestVmOptions } from './moduleRunner' +import { createRequire, isBuiltin } from 'node:module' +import { pathToFileURL } from 'node:url' +import vm from 'node:vm' +import { isAbsolute } from 'pathe' +import { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from 'vite/module-runner' +import { ModuleDebug } from './moduleDebug' + +const isWindows = process.platform === 'win32' + +export interface VitestModuleEvaluatorOptions { + interopDefault?: boolean | undefined + moduleExecutionInfo?: ModuleExecutionInfo + getCurrentTestFilepath?: () => string | undefined + compiledFunctionArgumentsNames?: string[] + compiledFunctionArgumentsValues?: unknown[] +} + +export class VitestModuleEvaluator implements ModuleEvaluator { + public stubs: Record = {} + public env: ModuleRunnerImportMeta['env'] = createImportMetaEnvProxy() + private vm: VitestVmOptions | undefined + + private compiledFunctionArgumentsNames?: string[] + private compiledFunctionArgumentsValues: unknown[] = [] + + private primitives: { + Object: typeof Object + Proxy: typeof Proxy + Reflect: typeof Reflect + } + + private debug = new ModuleDebug() + + constructor( + vmOptions?: VitestVmOptions | undefined, + private options: VitestModuleEvaluatorOptions = {}, + ) { + this.vm = vmOptions + this.stubs = getDefaultRequestStubs(vmOptions?.context) + if (options.compiledFunctionArgumentsNames) { + this.compiledFunctionArgumentsNames = options.compiledFunctionArgumentsNames + } + if (options.compiledFunctionArgumentsValues) { + this.compiledFunctionArgumentsValues = options.compiledFunctionArgumentsValues + } + if (vmOptions) { + this.primitives = vm.runInContext( + '({ Object, Proxy, Reflect })', + vmOptions.context, + ) + } + else { + this.primitives = { + Object, + Proxy, + Reflect, + } + } + } + + private convertIdToImportUrl(id: string) { + // TODO: vitest returns paths for external modules, but Vite returns file:// + // unfortunetly, there is a bug in Vite where ID is resolved incorrectly, so we can't return files until the fix is merged + // https://github.com/vitejs/vite/pull/20449 + if (!isWindows || isBuiltin(id) || /^(?:node:|data:|http:|https:|file:)/.test(id)) { + return id + } + const [filepath, query] = id.split('?') + if (query) { + return `${pathToFileURL(filepath).toString()}?${query}` + } + return pathToFileURL(filepath).toString() + } + + async runExternalModule(id: string): Promise { + if (id in this.stubs) { + return this.stubs[id] + } + + const file = this.convertIdToImportUrl(id) + + const namespace = this.vm + ? await this.vm.externalModulesExecutor.import(file) + : await import(file) + + if (!this.shouldInterop(file, namespace)) { + return namespace + } + + const { mod, defaultExport } = interopModule(namespace) + const { Proxy, Reflect } = this.primitives + + const proxy = new Proxy(mod, { + get(mod, prop) { + if (prop === 'default') { + return defaultExport + } + return mod[prop] ?? defaultExport?.[prop] + }, + has(mod, prop) { + if (prop === 'default') { + return defaultExport !== undefined + } + return prop in mod || (defaultExport && prop in defaultExport) + }, + getOwnPropertyDescriptor(mod, prop) { + const descriptor = Reflect.getOwnPropertyDescriptor(mod, prop) + if (descriptor) { + return descriptor + } + if (prop === 'default' && defaultExport !== undefined) { + return { + value: defaultExport, + enumerable: true, + configurable: true, + } + } + }, + }) + return proxy + } + + async runInlinedModule( + context: ModuleRunnerContext, + code: string, + module: Readonly, + ): Promise { + context.__vite_ssr_import_meta__.env = this.env + + const { Reflect, Proxy, Object } = this.primitives + + const exportsObject = context[ssrModuleExportsKey] + const SYMBOL_NOT_DEFINED = Symbol('not defined') + let moduleExports: unknown = SYMBOL_NOT_DEFINED + // this proxy is triggered only on exports.{name} and module.exports access + // inside the module itself. imported module is always "exports" + const cjsExports = new Proxy(exportsObject, { + get: (target, p, receiver) => { + if (Reflect.has(target, p)) { + return Reflect.get(target, p, receiver) + } + return Reflect.get(Object.prototype, p, receiver) + }, + getPrototypeOf: () => Object.prototype, + set: (_, p, value) => { + // treat "module.exports =" the same as "exports.default =" to not have nested "default.default", + // so "exports.default" becomes the actual module + if ( + p === 'default' + && this.shouldInterop(module.file, { default: value }) + && cjsExports !== value + ) { + exportAll(cjsExports, value) + exportsObject.default = value + return true + } + + if (!Reflect.has(exportsObject, 'default')) { + exportsObject.default = {} + } + + // returns undefined, when accessing named exports, if default is not an object + // but is still present inside hasOwnKeys, this is Node behaviour for CJS + if ( + moduleExports !== SYMBOL_NOT_DEFINED + && isPrimitive(moduleExports) + ) { + defineExport(exportsObject, p, () => undefined) + return true + } + + if (!isPrimitive(exportsObject.default)) { + exportsObject.default[p] = value + } + + if (p !== 'default') { + defineExport(exportsObject, p, () => value) + } + + return true + }, + }) + + const moduleProxy = { + set exports(value) { + exportAll(cjsExports, value) + exportsObject.default = value + moduleExports = value + }, + get exports() { + return cjsExports + }, + } + + const meta = context[ssrImportMetaKey] + + const testFilepath = this.options.getCurrentTestFilepath?.() + if (testFilepath === module.file) { + const globalNamespace = this.vm?.context || globalThis + Object.defineProperty(meta, 'vitest', { + // @ts-expect-error injected untyped global + get: () => globalNamespace.__vitest_index__, + }) + } + + const filename = meta.filename + const dirname = meta.dirname + + const require = this.createRequire(filename) + + const argumentsList = [ + ssrModuleExportsKey, + ssrImportMetaKey, + ssrImportKey, + ssrDynamicImportKey, + ssrExportAllKey, + // vite 7 support + '__vite_ssr_exportName__', + + // TODO@discuss deprecate in Vitest 5, remove in Vitest 6(?) + // backwards compat for vite-node + '__filename', + '__dirname', + 'module', + 'exports', + 'require', + ] + + if (this.compiledFunctionArgumentsNames) { + argumentsList.push(...this.compiledFunctionArgumentsNames) + } + + // add 'use strict' since ESM enables it by default + const codeDefinition = `'use strict';async (${argumentsList.join( + ',', + )})=>{{` + const wrappedCode = `${codeDefinition}${code}\n}}` + const options = { + // we are using a normalized file name by default because this is what + // Vite expects in the source maps handler + filename: module.file || filename, + lineOffset: 0, + columnOffset: -codeDefinition.length, + } + + const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(filename, codeDefinition.length) + + try { + const initModule = this.vm + ? vm.runInContext(wrappedCode, this.vm.context, options) + : vm.runInThisContext(wrappedCode, options) + + const dynamicRequest = async (dep: string, options: ImportCallOptions) => { + dep = String(dep) + // TODO: support more edge cases? + // vite doesn't support dynamic modules by design, but we have to + if (dep[0] === '#') { + return context[ssrDynamicImportKey](wrapId(dep), options) + } + return context[ssrDynamicImportKey](dep, options) + } + + await initModule( + context[ssrModuleExportsKey], + context[ssrImportMetaKey], + context[ssrImportKey], + dynamicRequest, + context[ssrExportAllKey], + // vite 7 support, remove when vite 7+ is supported + (context as any).__vite_ssr_exportName__ + || ((name: string, getter: () => unknown) => Object.defineProperty(exportsObject, name, { + enumerable: true, + configurable: true, + get: getter, + })), + + filename, + dirname, + moduleProxy, + cjsExports, + require, + + ...this.compiledFunctionArgumentsValues, + ) + } + finally { + // moduleExecutionInfo needs to use Node filename instead of the normalized one + // because we rely on this behaviour in coverage-v8, for example + this.options.moduleExecutionInfo?.set(filename, finishModuleExecutionInfo()) + } + } + + private createRequire(filename: string) { + // \x00 is a rollup convention for virtual files, + // it is not allowed in actual file names + if (filename.startsWith('\x00') || !isAbsolute(filename)) { + return () => ({}) + } + return this.vm + ? this.vm.externalModulesExecutor.createRequire(filename) + : createRequire(filename) + } + + private shouldInterop(path: string, mod: any): boolean { + if (this.options.interopDefault === false) { + return false + } + // never interop ESM modules + // TODO: should also skip for `.js` with `type="module"` + return !path.endsWith('.mjs') && 'default' in mod + } +} + +export function createImportMetaEnvProxy(): ModuleRunnerImportMeta['env'] { + // packages/vitest/src/node/plugins/index.ts:146 + const booleanKeys = ['DEV', 'PROD', 'SSR'] + return new Proxy(process.env, { + get(_, key) { + if (typeof key !== 'string') { + return undefined + } + if (booleanKeys.includes(key)) { + return !!process.env[key] + } + return process.env[key] + }, + set(_, key, value) { + if (typeof key !== 'string') { + return true + } + + if (booleanKeys.includes(key)) { + process.env[key] = value ? '1' : '' + } + else { + process.env[key] = value + } + + return true + }, + }) as ModuleRunnerImportMeta['env'] +} + +function updateStyle(id: string, css: string) { + if (typeof document === 'undefined') { + return + } + + const element = document.querySelector(`[data-vite-dev-id="${id}"]`) + if (element) { + element.textContent = css + return + } + + const head = document.querySelector('head') + const style = document.createElement('style') + style.setAttribute('type', 'text/css') + style.setAttribute('data-vite-dev-id', id) + style.textContent = css + head?.appendChild(style) +} + +function removeStyle(id: string) { + if (typeof document === 'undefined') { + return + } + const sheet = document.querySelector(`[data-vite-dev-id="${id}"]`) + if (sheet) { + document.head.removeChild(sheet) + } +} + +const defaultClientStub = { + injectQuery: (id: string) => id, + createHotContext: () => { + return { + accept: () => {}, + prune: () => {}, + dispose: () => {}, + decline: () => {}, + invalidate: () => {}, + on: () => {}, + send: () => {}, + } + }, + updateStyle: () => {}, + removeStyle: () => {}, +} + +export function getDefaultRequestStubs(context?: vm.Context): Record { + if (!context) { + const clientStub = { + ...defaultClientStub, + updateStyle, + removeStyle, + } + return { + '/@vite/client': clientStub, + } + } + const clientStub = vm.runInContext( + `(defaultClient) => ({ ...defaultClient, updateStyle: ${updateStyle.toString()}, removeStyle: ${removeStyle.toString()} })`, + context, + )(defaultClientStub) + return { + '/@vite/client': clientStub, + } +} + +function exportAll(exports: any, sourceModule: any) { + // #1120 when a module exports itself it causes + // call stack error + if (exports === sourceModule) { + return + } + + if ( + isPrimitive(sourceModule) + || Array.isArray(sourceModule) + || sourceModule instanceof Promise + ) { + return + } + + for (const key in sourceModule) { + if (key !== 'default' && !(key in exports)) { + try { + defineExport(exports, key, () => sourceModule[key]) + } + catch {} + } + } +} + +// keep consistency with Vite on how exports are defined +function defineExport(exports: any, key: string | symbol, value: () => any) { + Object.defineProperty(exports, key, { + enumerable: true, + configurable: true, + get: value, + }) +} + +export function isPrimitive(v: any): boolean { + const isObject = typeof v === 'object' || typeof v === 'function' + return !isObject || v == null +} + +function interopModule(mod: any) { + if (isPrimitive(mod)) { + return { + mod: { default: mod }, + defaultExport: mod, + } + } + + let defaultExport = 'default' in mod ? mod.default : mod + + if (!isPrimitive(defaultExport) && '__esModule' in defaultExport) { + mod = defaultExport + if ('default' in defaultExport) { + defaultExport = defaultExport.default + } + } + + return { mod, defaultExport } +} + +const VALID_ID_PREFIX = `/@id/` +const NULL_BYTE_PLACEHOLDER = `__x00__` + +export function wrapId(id: string): string { + return id.startsWith(VALID_ID_PREFIX) + ? id + : VALID_ID_PREFIX + id.replace('\0', NULL_BYTE_PLACEHOLDER) +} + +export function unwrapId(id: string): string { + return id.startsWith(VALID_ID_PREFIX) + ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0') + : id +} diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts similarity index 52% rename from packages/vitest/src/runtime/mocker.ts rename to packages/vitest/src/runtime/moduleRunner/moduleMocker.ts index 3f379fa00f04..f673f678e0ae 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts @@ -1,12 +1,13 @@ import type { ManualMockedModule, MockedModule, MockedModuleType } from '@vitest/mocker' -import type { MockFactory, MockOptions, PendingSuiteMock } from '../types/mocker' -import type { VitestExecutor } from './execute' +import type { EvaluatedModuleNode } from 'vite/module-runner' +import type { MockFactory, MockOptions, PendingSuiteMock } from '../../types/mocker' +import type { VitestModuleRunner } from './moduleRunner' import { isAbsolute, resolve } from 'node:path' import vm from 'node:vm' import { AutomockedModule, MockerRegistry, mockObject, RedirectedModule } from '@vitest/mocker' import { findMockRedirect } from '@vitest/mocker/redirect' import { highlight } from '@vitest/utils' -import { distDir } from '../paths' +import { distDir } from '../../paths' const spyModulePath = resolve(distDir, 'spy.js') @@ -17,6 +18,19 @@ interface MockContext { callstack: null | string[] } +export interface VitestMockerOptions { + context?: vm.Context + + 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') @@ -38,8 +52,8 @@ export class VitestMocker { callstack: null, } - constructor(public executor: VitestExecutor) { - const context = this.executor.options.context + constructor(public moduleRunner: VitestModuleRunner, private options: VitestMockerOptions) { + const context = this.options.context if (context) { this.primitives = vm.runInContext( '({ Object, Error, Function, RegExp, Symbol, Array, Map })', @@ -79,19 +93,23 @@ export class VitestMocker { } private get root() { - return this.executor.options.root + return this.options.root } - private get moduleCache() { - return this.executor.moduleCache + private get evaluatedModules() { + return this.moduleRunner.evaluatedModules } private get moduleDirectories() { - return this.executor.options.moduleDirectories || [] + return this.options.moduleDirectories || [] } public async initializeSpyModule(): Promise { - this.spyModule = await this.executor.executeId(spyModulePath) + if (this.spyModule) { + return + } + + this.spyModule = await this.moduleRunner.import(spyModulePath) } private getMockerRegistry() { @@ -106,10 +124,12 @@ export class VitestMocker { this.registries.clear() } - private deleteCachedItem(id: string) { + private invalidateModuleById(id: string) { const mockId = this.getMockPath(id) - if (this.moduleCache.has(mockId)) { - this.moduleCache.delete(mockId) + const node = this.evaluatedModules.getModuleById(mockId) + if (node) { + this.evaluatedModules.invalidateModule(node) + node.mockedExports = undefined } } @@ -118,7 +138,7 @@ export class VitestMocker { } public getSuiteFilepath(): string { - return this.executor.state.filepath || 'global' + return this.options.getCurrentTestFilepath() || 'global' } private createError(message: string, codeFrame?: string) { @@ -128,33 +148,28 @@ export class VitestMocker { return error } - private async resolvePath(rawId: string, importer: string) { - let id: string - let fsPath: string - try { - [id, fsPath] = await this.executor.originalResolveUrl(rawId, importer) - } - catch (error: any) { - // it's allowed to mock unresolved modules - if (error.code === 'ERR_MODULE_NOT_FOUND') { - const { id: unresolvedId } - = error[Symbol.for('vitest.error.not_found.data')] - id = unresolvedId - fsPath = unresolvedId - } - else { - throw error + public async resolveId(rawId: string, importer?: string): Promise<{ + id: string + url: string + external: string | null + }> { + const result = await this.options.resolveId(rawId, importer) + if (!result) { + const id = normalizeModuleId(rawId) + 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(fsPath) || this.isModuleDirectory(fsPath) ? rawId : null - + = !isAbsolute(result.file) || this.isModuleDirectory(result.file) ? normalizeModuleId(rawId) : null return { - id, - fsPath, - external: external ? this.normalizePath(external) : external, + ...result, + id: normalizeModuleId(result.id), + external, } } @@ -165,17 +180,18 @@ export class VitestMocker { await Promise.all( VitestMocker.pendingIds.map(async (mock) => { - const { fsPath, external } = await this.resolvePath( + const { id, url, external } = await this.resolveId( mock.id, mock.importer, ) if (mock.action === 'unmock') { - this.unmockPath(fsPath) + this.unmockPath(id) } if (mock.action === 'mock') { this.mockPath( mock.id, - fsPath, + id, + url, external, mock.type, mock.factory, @@ -187,10 +203,17 @@ export class VitestMocker { VitestMocker.pendingIds = [] } - private async callFunctionMock(dep: string, mock: ManualMockedModule) { - const cached = this.moduleCache.get(dep)?.exports - if (cached) { - return cached + private ensureModule(id: string, url: string) { + const node = this.evaluatedModules.ensureModule(id, url) + // TODO + node.meta = { id, url, code: '', file: null, invalidate: false } + return node + } + + private async callFunctionMock(id: string, url: string, mock: ManualMockedModule) { + const node = this.ensureModule(id, url) + if (node.exports) { + return node.exports } const exports = await mock.resolve() @@ -226,7 +249,7 @@ export class VitestMocker { }, }) - this.moduleCache.set(dep, { exports: moduleExports }) + node.exports = moduleExports return moduleExports } @@ -243,14 +266,10 @@ export class VitestMocker { public getDependencyMock(id: string): MockedModule | undefined { const registry = this.getMockerRegistry() - return registry.get(id) - } - - public normalizePath(path: string): string { - return this.moduleCache.normalizePath(path) + return registry.getById(fixLeadingSlashes(id)) } - public resolveMockPath(mockPath: string, external: string | null): string | null { + public findMockRedirect(mockPath: string, external: string | null): string | null { return findMockRedirect(this.root, mockPath, external) } @@ -265,49 +284,52 @@ export class VitestMocker { '[vitest] `spyModule` is not defined. This is a Vitest error. Please open a new issue with reproduction.', ) } - return mockObject({ - globalConstructors: this.primitives, - spyOn, - type: behavior, - }, object, mockExports) + return mockObject( + { + globalConstructors: this.primitives, + spyOn, + type: behavior, + }, + object, + mockExports, + ) } - public unmockPath(path: string): void { + public unmockPath(id: string): void { const registry = this.getMockerRegistry() - const id = this.normalizePath(path) - registry.delete(id) - this.deleteCachedItem(id) + registry.deleteById(id) + this.invalidateModuleById(id) } public mockPath( originalId: string, - path: string, + id: string, + url: string, external: string | null, mockType: MockedModuleType | undefined, factory: MockFactory | undefined, ): void { const registry = this.getMockerRegistry() - const id = this.normalizePath(path) if (mockType === 'manual') { - registry.register('manual', originalId, id, id, factory!) + registry.register('manual', originalId, id, url, factory!) } else if (mockType === 'autospy') { - registry.register('autospy', originalId, id, id) + registry.register('autospy', originalId, id, url) } else { - const redirect = this.resolveMockPath(id, external) + const redirect = this.findMockRedirect(id, external) if (redirect) { - registry.register('redirect', originalId, id, id, redirect) + registry.register('redirect', originalId, id, url, redirect) } else { - registry.register('automock', originalId, id, id) + registry.register('automock', originalId, id, url) } } // every time the mock is registered, we remove the previous one from the cache - this.deleteCachedItem(id) + this.invalidateModuleById(id) } public async importActual( @@ -315,80 +337,106 @@ export class VitestMocker { importer: string, callstack?: string[] | null, ): Promise { - const { id, fsPath } = await this.resolvePath(rawId, importer) - const result = await this.executor.cachedRequest( - id, - fsPath, + const { url } = await this.resolveId(rawId, importer) + const node = await this.moduleRunner.fetchModule(url, importer) + const result = await this.moduleRunner.cachedRequest( + node.url, + node, callstack || [importer], + undefined, + true, ) return result as T } - public async importMock(rawId: string, importee: string): Promise { - const { id, fsPath, external } = await this.resolvePath(rawId, importee) + public async importMock(rawId: string, importer: string): Promise { + const { id, url, external } = await this.resolveId(rawId, importer) - const normalizedId = this.normalizePath(fsPath) - let mock = this.getDependencyMock(normalizedId) + let mock = this.getDependencyMock(id) if (!mock) { - const redirect = this.resolveMockPath(normalizedId, external) + const redirect = this.findMockRedirect(id, external) if (redirect) { - mock = new RedirectedModule(rawId, normalizedId, normalizedId, redirect) + mock = new RedirectedModule(rawId, id, rawId, redirect) } else { - mock = new AutomockedModule(rawId, normalizedId, normalizedId) + mock = new AutomockedModule(rawId, id, rawId) } } if (mock.type === 'automock' || mock.type === 'autospy') { - const mod = await this.executor.cachedRequest(id, fsPath, [importee]) - return this.mockObject(mod, {}, mock.type) + const node = await this.moduleRunner.fetchModule(url, importer) + const mod = await this.moduleRunner.cachedRequest(url, node, [importer], undefined, true) + const Object = this.primitives.Object + return this.mockObject(mod, Object.create(Object.prototype), mock.type) } if (mock.type === 'manual') { - return this.callFunctionMock(fsPath, mock) + return this.callFunctionMock(id, url, mock) } - return this.executor.dependencyRequest(mock.redirect, mock.redirect, [importee]) + const node = await this.moduleRunner.fetchModule(mock.redirect) + return this.moduleRunner.cachedRequest( + mock.redirect, + node, + [importer], + undefined, + true, + ) } - public async requestWithMock(url: string, callstack: string[]): Promise { - const id = this.normalizePath(url) - const mock = this.getDependencyMock(id) - - if (!mock) { - return - } - - const mockPath = this.getMockPath(id) + public async requestWithMockedModule( + url: string, + evaluatedNode: EvaluatedModuleNode, + callstack: string[], + mock: MockedModule, + ): Promise { + const mockId = this.getMockPath(evaluatedNode.id) if (mock.type === 'automock' || mock.type === 'autospy') { - const cache = this.moduleCache.get(mockPath) - if (cache.exports) { - return cache.exports + const cache = this.evaluatedModules.getModuleById(mockId) + if (cache && cache.mockedExports) { + return cache.mockedExports } - const exports = {} - // Assign the empty exports object early to allow for cycles to work. The object will be filled by mockObject() - this.moduleCache.set(mockPath, { exports }) - const mod = await this.executor.directRequest(url, url, callstack) + const Object = this.primitives.Object + // we have to define a separate object that will copy all properties into itself + // and can't just use the same `exports` define automatically by Vite before the evaluator + const exports = Object.create(null) + Object.defineProperty(exports, Symbol.toStringTag, { + value: 'Module', + configurable: true, + writable: true, + }) + const node = this.ensureModule(mockId, this.getMockPath(evaluatedNode.url)) + node.meta = evaluatedNode.meta + node.file = evaluatedNode.file + node.mockedExports = exports + + const mod = await this.moduleRunner.cachedRequest( + url, + node, + callstack, + undefined, + true, + ) this.mockObject(mod, exports, mock.type) return exports } if ( mock.type === 'manual' - && !callstack.includes(mockPath) + && !callstack.includes(mockId) && !callstack.includes(url) ) { try { - callstack.push(mockPath) + callstack.push(mockId) // this will not work if user does Promise.all(import(), import()) // we can also use AsyncLocalStorage to store callstack, but this won't work in the browser // maybe we should improve mock API in the future? this.mockContext.callstack = callstack - return await this.callFunctionMock(mockPath, mock) + return await this.callFunctionMock(mockId, this.getMockPath(url), mock) } finally { this.mockContext.callstack = null - const indexMock = callstack.indexOf(mockPath) + const indexMock = callstack.indexOf(mockId) callstack.splice(indexMock, 1) } } @@ -397,6 +445,16 @@ export class VitestMocker { } } + public async mockedRequest(url: string, evaluatedNode: EvaluatedModuleNode, callstack: string[]): Promise { + const mock = this.getDependencyMock(evaluatedNode.id) + + if (!mock) { + return + } + + return this.requestWithMockedModule(url, evaluatedNode, callstack, mock) + } + public queueMock( id: string, importer: string, @@ -421,6 +479,12 @@ export class VitestMocker { } } +declare module 'vite/module-runner' { + interface EvaluatedModuleNode { + mockedExports?: Record + } +} + function getMockType(factoryOrOptions?: MockFactory | MockOptions): MockedModuleType { if (!factoryOrOptions) { return 'automock' @@ -430,3 +494,51 @@ function getMockType(factoryOrOptions?: MockFactory | MockOptions): MockedModule } 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 new file mode 100644 index 000000000000..cc406f5d5f9c --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts @@ -0,0 +1,151 @@ +import type { MockedModule } from '@vitest/mocker' +import type vm from 'node:vm' +import type { EvaluatedModuleNode, EvaluatedModules, SSRImportMetadata } from 'vite/module-runner' +import type { WorkerGlobalState } from '../../types/worker' +import type { ExternalModulesExecutor } from '../external-executor' +import type { ModuleExecutionInfo } from './moduleDebug' +import type { VitestModuleEvaluator } from './moduleEvaluator' +import type { VitestTransportOptions } from './moduleTransport' +import { ModuleRunner } from 'vite/module-runner' +import { VitestMocker } from './moduleMocker' +import { VitestTransport } from './moduleTransport' + +// @ts-expect-error overriding private method +export class VitestModuleRunner extends ModuleRunner { + public mocker: VitestMocker + public moduleExecutionInfo: ModuleExecutionInfo + + constructor(private options: VitestModuleRunnerOptions) { + const transport = new VitestTransport(options.transport) + const evaluatedModules = options.evaluatedModules + super( + { + transport, + hmr: false, + evaluatedModules, + sourcemapInterceptor: 'prepareStackTrace', + }, + options.evaluator, + ) + this.moduleExecutionInfo = options.getWorkerState().moduleExecutionInfo + this.mocker = options.mocker || new VitestMocker(this, { + context: options.vm?.context, + resolveId: options.transport.resolveId, + get root() { + return options.getWorkerState().config.root + }, + get moduleDirectories() { + return options.getWorkerState().config.deps.moduleDirectories || [] + }, + getCurrentTestFilepath() { + return options.getWorkerState().filepath + }, + }) + + if (options.vm) { + options.vm.context.__vitest_mocker__ = this.mocker + } + else { + Object.defineProperty(globalThis, '__vitest_mocker__', { + configurable: true, + writable: true, + value: this.mocker, + }) + } + } + + public async import(rawId: string): Promise { + const resolved = await this.options.transport.resolveId(rawId) + if (!resolved) { + return super.import(rawId) + } + return super.import(resolved.url) + } + + public async fetchModule(url: string, importer?: string): Promise { + const module = await (this as any).cachedModule(url, importer) + return module + } + + private _cachedRequest( + url: string, + module: EvaluatedModuleNode, + callstack: string[] = [], + metadata?: SSRImportMetadata, + ) { + // @ts-expect-error "cachedRequest" is private + return super.cachedRequest(url, module, callstack, metadata) + } + + /** + * @internal + */ + public async cachedRequest( + url: string, + mod: EvaluatedModuleNode, + callstack: string[] = [], + metadata?: SSRImportMetadata, + ignoreMock = false, + ): Promise { + if (ignoreMock) { + return this._cachedRequest(url, mod, callstack, metadata) + } + + let mocked: any + if (mod.meta && 'mockedModule' in mod.meta) { + mocked = await this.mocker.requestWithMockedModule( + url, + mod, + callstack, + mod.meta.mockedModule as MockedModule, + ) + } + else { + mocked = await this.mocker.mockedRequest(url, mod, callstack) + } + + if (typeof mocked === 'string') { + const node = await this.fetchModule(mocked) + return this._cachedRequest(mocked, node, callstack, metadata) + } + if (mocked != null && typeof mocked === 'object') { + return mocked + } + return this._cachedRequest(url, mod, callstack, metadata) + } + + /** @internal */ + public _invalidateSubTreeById(ids: string[], invalidated = new Set()): void { + for (const id of ids) { + if (invalidated.has(id)) { + continue + } + const node = this.evaluatedModules.getModuleById(id) + if (!node) { + continue + } + invalidated.add(id) + const subIds = Array.from(this.evaluatedModules.idToModuleMap) + .filter(([, mod]) => mod.importers.has(id)) + .map(([key]) => key) + if (subIds.length) { + this._invalidateSubTreeById(subIds, invalidated) + } + this.evaluatedModules.invalidateModule(node) + } + } +} + +export interface VitestModuleRunnerOptions { + transport: VitestTransportOptions + evaluator: VitestModuleEvaluator + evaluatedModules: EvaluatedModules + getWorkerState: () => WorkerGlobalState + mocker?: VitestMocker + vm?: VitestVmOptions +} + +export interface VitestVmOptions { + context: vm.Context + externalModulesExecutor: ExternalModulesExecutor +} diff --git a/packages/vitest/src/runtime/moduleRunner/moduleTransport.ts b/packages/vitest/src/runtime/moduleRunner/moduleTransport.ts new file mode 100644 index 000000000000..8887a1e13b46 --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/moduleTransport.ts @@ -0,0 +1,31 @@ +import type { FetchFunction, ModuleRunnerTransport } from 'vite/module-runner' +import type { ResolveFunctionResult } from '../../types/general' + +export interface VitestTransportOptions { + fetchModule: FetchFunction + resolveId: (id: string, importer?: string) => Promise +} + +export class VitestTransport implements ModuleRunnerTransport { + constructor(private options: VitestTransportOptions) {} + + async invoke(event: any): Promise<{ result: any } | { error: any }> { + if (event.type !== 'custom') { + return { error: new Error(`Vitest Module Runner doesn't support Vite HMR events.`) } + } + if (event.event !== 'vite:invoke') { + return { error: new Error(`Vitest Module Runner doesn't support ${event.event} event.`) } + } + const { name, data } = event.data + if (name !== 'fetchModule') { + return { error: new Error(`Unknown method: ${name}. Expected "fetchModule".`) } + } + try { + const result = await this.options.fetchModule(...data as Parameters) + return { result } + } + catch (error) { + return { error } + } + } +} diff --git a/packages/vitest/src/runtime/moduleRunner/startModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startModuleRunner.ts new file mode 100644 index 000000000000..e842a3696620 --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/startModuleRunner.ts @@ -0,0 +1,179 @@ +import type vm from 'node:vm' +import type { EvaluatedModules } from 'vite/module-runner' +import type { WorkerGlobalState } from '../../types/worker' +import type { ExternalModulesExecutor } from '../external-executor' +import fs from 'node:fs' +import { isBuiltin } from 'node:module' +import { isBareImport } from '@vitest/utils' +import { getCachedVitestImport } from './cachedResolver' +import { listenForErrors } from './errorCatcher' +import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' +import { VitestMocker } from './moduleMocker' +import { VitestModuleRunner } from './moduleRunner' + +const { readFileSync } = fs + +const browserExternalId = '__vite-browser-external' +const browserExternalLength = browserExternalId.length + 1 // 1 is ":" + +export const VITEST_VM_CONTEXT_SYMBOL: string = '__vitest_vm_context__' + +export interface ContextModuleRunnerOptions { + evaluatedModules: EvaluatedModules + mocker?: VitestMocker + evaluator?: VitestModuleEvaluator + context?: vm.Context + externalModulesExecutor?: ExternalModulesExecutor + state: WorkerGlobalState +} + +const cwd = process.cwd() +const isWindows = process.platform === 'win32' + +export async function startVitestModuleRunner(options: ContextModuleRunnerOptions): Promise { + const state = (): WorkerGlobalState => + // @ts-expect-error injected untyped global + globalThis.__vitest_worker__ || 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 + } + + const vm = options.context && options.externalModulesExecutor + ? { + context: options.context, + externalModulesExecutor: options.externalModulesExecutor, + } + : undefined + + const evaluator = options.evaluator || new VitestModuleEvaluator( + vm, + { + get moduleExecutionInfo() { + return state().moduleExecutionInfo + }, + get interopDefault() { + return state().config.deps.interopDefault + }, + getCurrentTestFilepath: () => state().filepath, + }, + ) + + const moduleRunner: VitestModuleRunner = new VitestModuleRunner({ + evaluatedModules: options.evaluatedModules, + evaluator, + mocker: options.mocker, + transport: { + async fetchModule(id, importer, options) { + const resolvingModules = state().resolvingModules + + if (isWindows) { + if (id[1] === ':') { + // The drive letter is different for whatever reason, we need to normalize it to CWD + if (id[0] !== cwd[0] && id[0].toUpperCase() === cwd[0].toUpperCase()) { + const isUpperCase = cwd[0].toUpperCase() === cwd[0] + id = (isUpperCase ? id[0].toUpperCase() : id[0].toLowerCase()) + id.slice(1) + } + // always mark absolute windows paths, otherwise Vite will externalize it + id = `/@id/${id}` + } + } + + const vitest = getCachedVitestImport(id, state) + if (vitest) { + return vitest + } + + const rawId = unwrapId(id) + resolvingModules.add(rawId) + + try { + if (VitestMocker.pendingIds.length) { + await moduleRunner.mocker.resolveMocks() + } + + const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId) + if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') { + return { + code: '', + file: null, + id, + url: id, + invalidate: false, + mockedModule: resolvedMock, + } + } + + if (isBuiltin(rawId) || rawId.startsWith(browserExternalId)) { + return { externalize: toBuiltin(rawId), type: 'builtin' } + } + + const result = await rpc().fetch( + id, + importer, + environment(), + options, + ) + if ('cached' in result) { + const code = readFileSync(result.tmp, 'utf-8') + return { code, ...result } + } + return result + } + catch (cause: any) { + // rethrow vite error if it cannot load the module because it's not resolved + if ( + (typeof cause === 'object' && cause != null && cause.code === 'ERR_LOAD_URL') + || (typeof cause?.message === 'string' && cause.message.includes('Failed to load url')) + || (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}'` : ''}`, + { cause }, + ) as Error & { code: string } + error.code = 'ERR_MODULE_NOT_FOUND' + throw error + } + + throw cause + } + finally { + resolvingModules.delete(rawId) + } + }, + resolveId(id, importer) { + return rpc().resolve( + id, + importer, + environment(), + ) + }, + }, + getWorkerState: state, + vm, + }) + + await moduleRunner.import('/@vite/env') + await moduleRunner.mocker.initializeSpyModule() + + return moduleRunner +} + +export function toBuiltin(id: string): string { + if (id.startsWith(browserExternalId)) { + id = id.slice(browserExternalLength) + } + + if (!id.startsWith('node:')) { + id = `node:${id}` + } + return id +} diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts index 5b93135311b0..3122b807192c 100644 --- a/packages/vitest/src/runtime/runBaseTests.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -1,8 +1,7 @@ import type { FileSpecification } from '@vitest/runner' -import type { ModuleCacheMap } from 'vite-node' import type { ResolvedTestEnvironment } from '../types/environment' import type { SerializedConfig } from './config' -import type { VitestExecutor } from './execute' +import type { VitestModuleRunner } from './moduleRunner/moduleRunner' import { performance } from 'node:perf_hooks' import { collectTests, startTests } from '@vitest/runner' import { setupChaiConfig } from '../integrations/chai/config' @@ -22,7 +21,7 @@ export async function run( files: FileSpecification[], config: SerializedConfig, environment: ResolvedTestEnvironment, - executor: VitestExecutor, + moduleRunner: VitestModuleRunner, ): Promise { const workerState = getWorkerState() @@ -30,14 +29,14 @@ export async function run( const isIsolatedForks = config.pool === 'forks' && (config.poolOptions?.forks?.isolate ?? true) const isolate = isIsolatedThreads || isIsolatedForks - await setupGlobalEnv(config, environment, executor) - await startCoverageInsideWorker(config.coverage, executor, { isolate }) + await setupGlobalEnv(config, environment, moduleRunner) + await startCoverageInsideWorker(config.coverage, moduleRunner, { isolate }) if (config.chaiConfig) { setupChaiConfig(config.chaiConfig) } - const runner = await resolveTestRunner(config, executor) + const runner = await resolveTestRunner(config, moduleRunner) workerState.onCancel.then((reason) => { closeInspector(config) @@ -56,8 +55,8 @@ export async function run( for (const file of files) { if (isolate) { - executor.mocker.reset() - resetModules(workerState.moduleCache as ModuleCacheMap, true) + moduleRunner.mocker.reset() + resetModules(workerState.evaluatedModules, true) } workerState.filepath = file.filepath @@ -75,7 +74,7 @@ export async function run( vi.restoreAllMocks() } - await stopCoverageInsideWorker(config.coverage, executor, { isolate }) + await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate }) }, ) diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index b76273ef15f1..3fe31331ad66 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -1,15 +1,13 @@ import type { FileSpecification } from '@vitest/runner' -import type { ModuleCacheMap } from 'vite-node' import type { SerializedConfig } from './config' -import type { VitestExecutor } from './execute' +import type { VitestModuleRunner } from './moduleRunner/moduleRunner' import { createRequire } from 'node:module' import { performance } from 'node:perf_hooks' import timers from 'node:timers' import timersPromises from 'node:timers/promises' import util from 'node:util' import { collectTests, startTests } from '@vitest/runner' -import { KNOWN_ASSET_TYPES } from 'vite-node/constants' -import { installSourcemapsSupport } from 'vite-node/source-map' +import { KNOWN_ASSET_TYPES } from '@vitest/utils' import { setupChaiConfig } from '../integrations/chai/config' import { startCoverageInsideWorker, @@ -26,7 +24,7 @@ export async function run( method: 'run' | 'collect', files: FileSpecification[], config: SerializedConfig, - executor: VitestExecutor, + moduleRunner: VitestModuleRunner, ): Promise { const workerState = getWorkerState() @@ -37,7 +35,8 @@ export async function run( enumerable: false, }) - if (workerState.environment.transformMode === 'web') { + const viteEnvironment = workerState.environment.viteEnvironment || workerState.environment.name + if (viteEnvironment === 'client') { const _require = createRequire(import.meta.url) // always mock "required" `css` files, because we cannot process them _require.extensions['.css'] = resolveCss @@ -61,19 +60,15 @@ export async function run( timersPromises, } - installSourcemapsSupport({ - getSourceMap: source => (workerState.moduleCache as ModuleCacheMap).getSourceMap(source), - }) - - await startCoverageInsideWorker(config.coverage, executor, { isolate: false }) + await startCoverageInsideWorker(config.coverage, moduleRunner, { isolate: false }) if (config.chaiConfig) { setupChaiConfig(config.chaiConfig) } const [runner, snapshotEnvironment] = await Promise.all([ - resolveTestRunner(config, executor), - resolveSnapshotEnvironment(config, executor), + resolveTestRunner(config, moduleRunner), + resolveSnapshotEnvironment(config, moduleRunner), ]) config.snapshotOptions.snapshotEnvironment = snapshotEnvironment @@ -106,7 +101,7 @@ export async function run( vi.restoreAllMocks() } - await stopCoverageInsideWorker(config.coverage, executor, { isolate: false }) + await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: false }) } function resolveCss(mod: NodeJS.Module) { diff --git a/packages/vitest/src/runtime/runners/benchmark.ts b/packages/vitest/src/runtime/runners/benchmark.ts index 9c875a43abb9..84ce530fc6a2 100644 --- a/packages/vitest/src/runtime/runners/benchmark.ts +++ b/packages/vitest/src/runtime/runners/benchmark.ts @@ -5,6 +5,7 @@ import type { VitestRunner, VitestRunnerImportSource, } from '@vitest/runner' +import type { ModuleRunner } from 'vite/module-runner' import type { SerializedConfig } from '../config' // import type { VitestExecutor } from '../execute' import type { @@ -150,7 +151,7 @@ async function runBenchmarkSuite(suite: Suite, runner: NodeBenchmarkRunner) { } export class NodeBenchmarkRunner implements VitestRunner { - private __vitest_executor!: any + private moduleRunner!: ModuleRunner constructor(public config: SerializedConfig) {} @@ -160,9 +161,12 @@ export class NodeBenchmarkRunner implements VitestRunner { importFile(filepath: string, source: VitestRunnerImportSource): unknown { if (source === 'setup') { - getWorkerState().moduleCache.delete(filepath) + const moduleNode = getWorkerState().evaluatedModules.getModuleById(filepath) + if (moduleNode) { + getWorkerState().evaluatedModules.invalidateModule(moduleNode) + } } - return this.__vitest_executor.executeId(filepath) + return this.moduleRunner.import(filepath) } async runSuite(suite: Suite): Promise { diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index b955fd0263e0..695ad05f8434 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 { SerializedConfig } from '../config' -import type { VitestExecutor } from '../execute' -import { resolve } from 'node:path' +import type { VitestModuleRunner } from '../moduleRunner/moduleRunner' +import { join, resolve } from 'node:path' import { takeCoverageInsideWorker } from '../../integrations/coverage' import { distDir } from '../../paths' import { rpc } from '../rpc' @@ -12,16 +12,17 @@ const runnersFile = resolve(distDir, 'runners.js') async function getTestRunnerConstructor( config: SerializedConfig, - executor: VitestExecutor, + moduleRunner: VitestModuleRunner, ): Promise { if (!config.runner) { - const { VitestTestRunner, NodeBenchmarkRunner } - = await executor.executeFile(runnersFile) + const { VitestTestRunner, NodeBenchmarkRunner } = await moduleRunner.import( + join('/@fs/', runnersFile), + ) return ( config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner ) as VitestRunnerConstructor } - const mod = await executor.executeId(config.runner) + const mod = await moduleRunner.import(config.runner) if (!mod.default && typeof mod.default !== 'function') { throw new Error( `Runner must export a default function, but got ${typeof mod.default} imported from ${ @@ -34,14 +35,14 @@ async function getTestRunnerConstructor( export async function resolveTestRunner( config: SerializedConfig, - executor: VitestExecutor, + moduleRunner: VitestModuleRunner, ): Promise { - const TestRunner = await getTestRunnerConstructor(config, executor) + const TestRunner = await getTestRunnerConstructor(config, moduleRunner) const testRunner = new TestRunner(config) // inject private executor to every runner - Object.defineProperty(testRunner, '__vitest_executor', { - value: executor, + Object.defineProperty(testRunner, 'moduleRunner', { + value: moduleRunner, enumerable: false, configurable: false, }) @@ -55,8 +56,8 @@ export async function resolveTestRunner( } const [diffOptions] = await Promise.all([ - loadDiffConfig(config, executor), - loadSnapshotSerializers(config, executor), + loadDiffConfig(config, moduleRunner), + loadSnapshotSerializers(config, moduleRunner), ]) testRunner.config.diffOptions = diffOptions @@ -100,13 +101,13 @@ export async function resolveTestRunner( const originalOnAfterRun = testRunner.onAfterRunFiles testRunner.onAfterRunFiles = async (files) => { const state = getWorkerState() - const coverage = await takeCoverageInsideWorker(config.coverage, executor) + const coverage = await takeCoverageInsideWorker(config.coverage, moduleRunner) if (coverage) { rpc().onAfterSuiteRun({ coverage, testFiles: files.map(file => file.name).sort(), - transformMode: state.environment.transformMode, + environment: state.environment.viteEnvironment || state.environment.name, projectName: state.ctx.projectName, }) } diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index 587c3ddf4ba1..29c974060d18 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -10,6 +10,7 @@ import type { VitestRunner, VitestRunnerImportSource, } from '@vitest/runner' +import type { ModuleRunner } from 'vite/module-runner' import type { SerializedConfig } from '../config' // import type { VitestExecutor } from '../execute' import { getState, GLOBAL_EXPECT, setState } from '@vitest/expect' @@ -29,7 +30,7 @@ const workerContext = Object.create(null) export class VitestTestRunner implements VitestRunner { private snapshotClient = getSnapshotClient() private workerState = getWorkerState() - private __vitest_executor!: any + private moduleRunner!: ModuleRunner private cancelRun = false private assertionsErrors = new WeakMap, Error>() @@ -40,9 +41,12 @@ export class VitestTestRunner implements VitestRunner { importFile(filepath: string, source: VitestRunnerImportSource): unknown { if (source === 'setup') { - this.workerState.moduleCache.delete(filepath) + const moduleNode = this.workerState.evaluatedModules.getModuleById(filepath) + if (moduleNode) { + this.workerState.evaluatedModules.invalidateModule(moduleNode) + } } - return this.__vitest_executor.executeId(filepath) + return this.moduleRunner.import(filepath) } onCollectStart(file: File): void { diff --git a/packages/vitest/src/runtime/setup-common.ts b/packages/vitest/src/runtime/setup-common.ts index 7af812f99d61..588c16c2f104 100644 --- a/packages/vitest/src/runtime/setup-common.ts +++ b/packages/vitest/src/runtime/setup-common.ts @@ -2,9 +2,10 @@ import type { DiffOptions } from '@vitest/expect' import type { SnapshotSerializer } from '@vitest/snapshot' import type { SerializedDiffOptions } from '@vitest/utils/diff' import type { SerializedConfig } from './config' -import type { VitestExecutor } from './execute' +import type { VitestModuleRunner } from './moduleRunner/moduleRunner' import { addSerializer } from '@vitest/snapshot' import { setSafeTimers } from '@vitest/utils' +import { getWorkerState } from './utils' let globalSetup = false export async function setupCommonEnv(config: SerializedConfig): Promise { @@ -30,21 +31,19 @@ function setupDefines(defines: Record) { } function setupEnv(env: Record) { - if (typeof process === 'undefined') { - return - } + const state = getWorkerState() // same boolean-to-string assignment as VitestPlugin.configResolved const { PROD, DEV, ...restEnvs } = env - process.env.PROD = PROD ? '1' : '' - process.env.DEV = DEV ? '1' : '' + state.metaEnv.PROD = PROD + state.metaEnv.DEV = DEV for (const key in restEnvs) { - process.env[key] = env[key] + state.metaEnv[key] = env[key] } } export async function loadDiffConfig( config: SerializedConfig, - executor: VitestExecutor, + moduleRunner: VitestModuleRunner, ): Promise { if (typeof config.diff === 'object') { return config.diff @@ -53,7 +52,7 @@ export async function loadDiffConfig( return } - const diffModule = await executor.executeId(config.diff) + const diffModule = await moduleRunner.import(config.diff) if ( diffModule @@ -71,13 +70,13 @@ export async function loadDiffConfig( export async function loadSnapshotSerializers( config: SerializedConfig, - executor: VitestExecutor, + moduleRunner: VitestModuleRunner, ): Promise { const files = config.snapshotSerializers const snapshotSerializers = await Promise.all( files.map(async (file) => { - const mo = await executor.executeId(file) + const mo = await moduleRunner.import(file) if (!mo || typeof mo.default !== 'object' || mo.default === null) { throw new Error( `invalid snapshot serializer file ${file}. Must export a default object`, diff --git a/packages/vitest/src/runtime/setup-node.ts b/packages/vitest/src/runtime/setup-node.ts index 066babdf9859..05ad48dda3c9 100644 --- a/packages/vitest/src/runtime/setup-node.ts +++ b/packages/vitest/src/runtime/setup-node.ts @@ -1,14 +1,11 @@ -import type { ModuleCacheMap } from 'vite-node' import type { ResolvedTestEnvironment } from '../types/environment' import type { SerializedConfig } from './config' -import type { VitestExecutor } from './execute' +import type { VitestModuleRunner } from './moduleRunner/moduleRunner' import { createRequire } from 'node:module' import timers from 'node:timers' import timersPromises from 'node:timers/promises' import util from 'node:util' -import { getSafeTimers } from '@vitest/utils' -import { KNOWN_ASSET_TYPES } from 'vite-node/constants' -import { installSourcemapsSupport } from 'vite-node/source-map' +import { getSafeTimers, KNOWN_ASSET_TYPES } from '@vitest/utils' import { expect } from '../integrations/chai' import { resolveSnapshotEnvironment } from '../integrations/snapshot/environments/resolveSnapshotEnvironment' import * as VitestIndex from '../public/index' @@ -20,7 +17,7 @@ let globalSetup = false export async function setupGlobalEnv( config: SerializedConfig, { environment }: ResolvedTestEnvironment, - executor: VitestExecutor, + moduleRunner: VitestModuleRunner, ): Promise { await setupCommonEnv(config) @@ -33,7 +30,7 @@ export async function setupGlobalEnv( if (!state.config.snapshotOptions.snapshotEnvironment) { state.config.snapshotOptions.snapshotEnvironment - = await resolveSnapshotEnvironment(config, executor) + = await resolveSnapshotEnvironment(config, moduleRunner) } if (globalSetup) { @@ -42,7 +39,8 @@ export async function setupGlobalEnv( globalSetup = true - if (environment.transformMode === 'web') { + const viteEnvironment = environment.viteEnvironment || environment.name + if (viteEnvironment === 'client') { const _require = createRequire(import.meta.url) // always mock "required" `css` files, because we cannot process them _require.extensions['.css'] = resolveCss @@ -66,10 +64,6 @@ export async function setupGlobalEnv( timersPromises, } - installSourcemapsSupport({ - getSourceMap: source => (state.moduleCache as ModuleCacheMap).getSourceMap(source), - }) - if (!config.disableConsoleIntercept) { await setupConsoleLogSpy() } diff --git a/packages/vitest/src/runtime/utils.ts b/packages/vitest/src/runtime/utils.ts index ccb5a8e9500c..4d0c9bf9cc65 100644 --- a/packages/vitest/src/runtime/utils.ts +++ b/packages/vitest/src/runtime/utils.ts @@ -1,5 +1,4 @@ -import type { ModuleCacheMap } from 'vite-node/client' - +import type { EvaluatedModules } from 'vite/module-runner' import type { WorkerGlobalState } from '../types/worker' import { getSafeTimers } from '@vitest/utils' @@ -48,11 +47,10 @@ export function setProcessTitle(title: string): void { catch {} } -export function resetModules(modules: ModuleCacheMap, resetMocks = false): void { +export function resetModules(modules: EvaluatedModules, resetMocks = false): void { const skipPaths = [ // Vitest /\/vitest\/dist\//, - /\/vite-node\/dist\//, // yarn's .store folder /vitest-virtual-\w+\/dist/, // cnpm @@ -60,11 +58,15 @@ export function resetModules(modules: ModuleCacheMap, resetMocks = false): void // don't clear mocks ...(!resetMocks ? [/^mock:/] : []), ] - modules.forEach((mod, path) => { + modules.idToModuleMap.forEach((node, path) => { if (skipPaths.some(re => re.test(path))) { return } - modules.invalidateModule(mod) + + node.promise = undefined + node.exports = undefined + node.evaluated = false + node.importers.clear() }) } @@ -77,14 +79,11 @@ export async function waitForImportsToResolve(): Promise { await waitNextTick() const state = getWorkerState() const promises: Promise[] = [] - let resolvingCount = 0 - for (const mod of state.moduleCache.values()) { + const resolvingCount = state.resolvingModules.size + for (const [_, mod] of state.evaluatedModules.idToModuleMap) { if (mod.promise && !mod.evaluated) { promises.push(mod.promise) } - if (mod.resolving) { - resolvingCount++ - } } if (!promises.length && !resolvingCount) { return diff --git a/packages/vitest/src/runtime/vm/commonjs-executor.ts b/packages/vitest/src/runtime/vm/commonjs-executor.ts index 98ea293a1878..f34e3f07d7fe 100644 --- a/packages/vitest/src/runtime/vm/commonjs-executor.ts +++ b/packages/vitest/src/runtime/vm/commonjs-executor.ts @@ -1,9 +1,8 @@ import type { FileMap } from './file-map' import type { ImportModuleDynamically, VMSyntheticModule } from './types' -import { Module as _Module, createRequire } from 'node:module' +import { Module as _Module, createRequire, isBuiltin } from 'node:module' import vm from 'node:vm' import { basename, dirname, extname } from 'pathe' -import { isNodeBuiltin } from 'vite-node/utils' import { interopCommonJsModule, SyntheticModule } from './utils' interface CommonjsExecutorOptions { @@ -206,7 +205,7 @@ export class CommonjsExecutor { const require = ((id: string) => { const resolved = _require.resolve(id) const ext = extname(resolved) - if (ext === '.node' || isNodeBuiltin(resolved)) { + if (ext === '.node' || isBuiltin(resolved)) { return this.requireCoreModule(resolved) } const module = new this.Module(resolved) @@ -358,7 +357,7 @@ export class CommonjsExecutor { public require(identifier: string): any { const ext = extname(identifier) - if (ext === '.node' || isNodeBuiltin(identifier)) { + if (ext === '.node' || isBuiltin(identifier)) { return this.requireCoreModule(identifier) } const module = new this.Module(identifier) diff --git a/packages/vitest/src/runtime/vm/utils.ts b/packages/vitest/src/runtime/vm/utils.ts index 0559aa01ac99..b75666d9d3b0 100644 --- a/packages/vitest/src/runtime/vm/utils.ts +++ b/packages/vitest/src/runtime/vm/utils.ts @@ -1,6 +1,5 @@ import type { VMSourceTextModule, VMSyntheticModule } from './types' import vm from 'node:vm' -import { isPrimitive } from 'vite-node/utils' export function interopCommonJsModule( interopDefault: boolean | undefined, @@ -45,6 +44,11 @@ export function interopCommonJsModule( } } +function isPrimitive(obj: unknown): boolean { + const isObject = obj != null && (typeof obj === 'object' || typeof obj === 'function') + return !isObject +} + export const SyntheticModule: typeof VMSyntheticModule = (vm as any) .SyntheticModule export const SourceTextModule: typeof VMSourceTextModule = (vm as any) diff --git a/packages/vitest/src/runtime/vm/vite-executor.ts b/packages/vitest/src/runtime/vm/vite-executor.ts index a99491a384fa..94aeee5dbc24 100644 --- a/packages/vitest/src/runtime/vm/vite-executor.ts +++ b/packages/vitest/src/runtime/vm/vite-executor.ts @@ -4,9 +4,7 @@ import type { WorkerGlobalState } from '../../types/worker' import type { EsmExecutor } from './esm-executor' import type { VMModule } from './types' import { pathToFileURL } from 'node:url' -import { normalize } from 'pathe' -import { CSS_LANGS_RE, KNOWN_ASSET_RE } from 'vite-node/constants' -import { toArray } from 'vite-node/utils' +import { CSS_LANGS_RE, KNOWN_ASSET_RE, toArray } from '@vitest/utils' import { SyntheticModule } from './utils' interface ViteExecutorOptions { @@ -26,15 +24,9 @@ export class ViteExecutor { this.esm = options.esmExecutor } - public resolve = (identifier: string, parent: string): string | undefined => { + public resolve = (identifier: string): string | undefined => { if (identifier === CLIENT_ID) { - if (this.workerState.environment.transformMode === 'web') { - return identifier - } - const packageName = this.getPackageName(parent) - throw new Error( - `[vitest] Vitest cannot handle ${CLIENT_ID} imported in ${parent} when running in SSR environment. Add "${packageName}" to "ssr.noExternal" if you are using Vite SSR, or to "server.deps.inline" if you are using Vite Node.`, - ) + return identifier } } @@ -42,20 +34,8 @@ export class ViteExecutor { return this.options.context.__vitest_worker__ } - private getPackageName(modulePath: string) { - const path = normalize(modulePath) - let name = path.split('/node_modules/').pop() || '' - if (name?.startsWith('@')) { - name = name.split('/').slice(0, 2).join('/') - } - else { - name = name.split('/')[0] - } - return name - } - public async createViteModule(fileUrl: string): Promise { - if (fileUrl === CLIENT_FILE) { + if (fileUrl === CLIENT_FILE || fileUrl === CLIENT_ID) { return this.createViteClientModule() } const cached = this.esm.resolveCachedModule(fileUrl) @@ -64,7 +44,7 @@ export class ViteExecutor { } return this.esm.createEsModule(fileUrl, async () => { try { - const result = await this.options.transform(fileUrl, 'web') + const result = await this.options.transform(fileUrl) if (result.code) { return result.code } @@ -112,10 +92,6 @@ export class ViteExecutor { } public canResolve = (fileUrl: string): boolean => { - const transformMode = this.workerState.environment.transformMode - if (transformMode !== 'web') { - return false - } if (fileUrl === CLIENT_FILE) { return true } diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index ff86faac1a1a..84be16d96a79 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -1,9 +1,10 @@ +import type { ModuleRunner } from 'vite/module-runner' import type { ContextRPC, WorkerGlobalState } from '../types/worker' import type { VitestWorker } from './workers/types' import { pathToFileURL } from 'node:url' import { createStackString, parseStacktrace } from '@vitest/utils/source-map' import { workerId as poolId } from 'tinypool' -import { ModuleCacheMap } from 'vite-node/client' +import { EvaluatedModules } from 'vite/module-runner' import { loadEnvironment } from '../integrations/env/loader' import { addCleanupListener, cleanup as cleanupWorker } from './cleanup' import { setupInspect } from './inspector' @@ -30,6 +31,8 @@ if (isChildProcess()) { } } +const resolvingModules = new Set() + // this is what every pool executes when running tests async function execute(method: 'run' | 'collect', ctx: ContextRPC) { disposeInternalListeners() @@ -41,6 +44,8 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC) { process.env.VITEST_WORKER_ID = String(ctx.workerId) process.env.VITEST_POOL_ID = String(poolId) + let environmentLoader: ModuleRunner | undefined + try { // worker is a filepath or URL to a file that exposes a default export with "getRpcOptions" and "runTests" methods if (ctx.worker[0] === '.') { @@ -81,15 +86,14 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC) { }) const beforeEnvironmentTime = performance.now() - const environment = await loadEnvironment(ctx, rpc) - if (ctx.environment.transformMode) { - environment.transformMode = ctx.environment.transformMode - } + const { environment, loader } = await loadEnvironment(ctx, rpc) + environmentLoader = loader const state = { ctx, // here we create a new one, workers can reassign this if they need to keep it non-isolated - moduleCache: new ModuleCacheMap(), + evaluatedModules: new EvaluatedModules(), + resolvingModules, moduleExecutionInfo: new Map(), config: ctx.config, onCancel, @@ -104,6 +108,7 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC) { onFilterStackTrace(stack) { return createStackString(parseStacktrace(stack)) }, + metaEnv: createImportMetaEnvProxy(), } satisfies WorkerGlobalState const methodName = method === 'collect' ? 'collectTests' : 'runTests' @@ -120,6 +125,7 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC) { await Promise.all(cleanups.map(fn => fn())) await rpcDone().catch(() => {}) + environmentLoader?.close() } } @@ -134,3 +140,33 @@ export function collect(ctx: ContextRPC): Promise { export async function teardown(): Promise { return cleanupWorker() } + +function createImportMetaEnvProxy(): WorkerGlobalState['metaEnv'] { + // packages/vitest/src/node/plugins/index.ts:146 + const booleanKeys = ['DEV', 'PROD', 'SSR'] + return new Proxy(process.env, { + get(_, key) { + if (typeof key !== 'string') { + return undefined + } + if (booleanKeys.includes(key)) { + return !!process.env[key] + } + return process.env[key] + }, + set(_, key, value) { + if (typeof key !== 'string') { + return true + } + + if (booleanKeys.includes(key)) { + process.env[key] = value ? '1' : '' + } + else { + process.env[key] = value + } + + return true + }, + }) as WorkerGlobalState['metaEnv'] +} diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts index 964cca9ab761..dc40598c4690 100644 --- a/packages/vitest/src/runtime/workers/base.ts +++ b/packages/vitest/src/runtime/workers/base.ts @@ -1,43 +1,52 @@ import type { WorkerGlobalState } from '../../types/worker' -import type { ContextExecutorOptions, VitestExecutor } from '../execute' -import { ModuleCacheMap } from 'vite-node/client' -import { getDefaultRequestStubs, startVitestExecutor } from '../execute' +import type { VitestModuleRunner } from '../moduleRunner/moduleRunner' +import type { ContextModuleRunnerOptions } from '../moduleRunner/startModuleRunner' +import { EvaluatedModules } from 'vite/module-runner' +import { startVitestModuleRunner } from '../moduleRunner/startModuleRunner' import { provideWorkerState } from '../utils' -let _viteNode: VitestExecutor +let _moduleRunner: VitestModuleRunner -const moduleCache = new ModuleCacheMap() +const evaluatedModules = new EvaluatedModules() const moduleExecutionInfo = new Map() -async function startViteNode(options: ContextExecutorOptions) { - if (_viteNode) { - return _viteNode +async function startModuleRunner(options: ContextModuleRunnerOptions) { + if (_moduleRunner) { + return _moduleRunner } - _viteNode = await startVitestExecutor(options) - return _viteNode + _moduleRunner = await startVitestModuleRunner(options) + return _moduleRunner } export async function runBaseTests(method: 'run' | 'collect', state: WorkerGlobalState): Promise { const { ctx } = state // state has new context, but we want to reuse existing ones - state.moduleCache = moduleCache + state.evaluatedModules = evaluatedModules state.moduleExecutionInfo = moduleExecutionInfo provideWorkerState(globalThis, state) if (ctx.invalidates) { - ctx.invalidates.forEach((fsPath) => { - moduleCache.delete(fsPath) - moduleCache.delete(`mock:${fsPath}`) + ctx.invalidates.forEach((filepath) => { + const modules = state.evaluatedModules.fileToModulesMap.get(filepath) || [] + modules.forEach((module) => { + state.evaluatedModules.invalidateModule(module) + }) + // evaluatedModules.delete(fsPath) + // evaluatedModules.delete(`mock:${fsPath}`) }) } - ctx.files.forEach(i => state.moduleCache.delete( - typeof i === 'string' ? i : i.filepath, - )) + ctx.files.forEach((i) => { + const filepath = typeof i === 'string' ? i : i.filepath + const modules = state.evaluatedModules.fileToModulesMap.get(filepath) || [] + modules.forEach((module) => { + state.evaluatedModules.invalidateModule(module) + }) + }) const [executor, { run }] = await Promise.all([ - startViteNode({ state, requestStubs: getDefaultRequestStubs() }), + startModuleRunner({ state, evaluatedModules: state.evaluatedModules }), import('../runBaseTests'), ]) const fileSpecs = ctx.files.map(f => diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts index f4f80997926e..9e86392b9a99 100644 --- a/packages/vitest/src/runtime/workers/vm.ts +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -1,13 +1,13 @@ import type { Context } from 'node:vm' -import type { ModuleCacheMap } from 'vite-node' import type { WorkerGlobalState } from '../../types/worker' import { pathToFileURL } from 'node:url' import { isContext } from 'node:vm' import { resolve } from 'pathe' import { distDir } from '../../paths' import { createCustomConsole } from '../console' -import { getDefaultRequestStubs, startVitestExecutor } from '../execute' import { ExternalModulesExecutor } from '../external-executor' +import { getDefaultRequestStubs } from '../moduleRunner/moduleEvaluator' +import { startVitestModuleRunner, VITEST_VM_CONTEXT_SYMBOL } from '../moduleRunner/startModuleRunner' import { provideWorkerState } from '../utils' import { FileMap } from '../vm/file-map' @@ -75,17 +75,25 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS viteClientModule: stubs['/@vite/client'], }) - const executor = await startVitestExecutor({ + const moduleRunner = await startVitestModuleRunner({ context, - moduleCache: state.moduleCache as ModuleCacheMap, + evaluatedModules: state.evaluatedModules, state, externalModulesExecutor, - requestStubs: stubs, }) - context.__vitest_mocker__ = executor.mocker + Object.defineProperty(context, VITEST_VM_CONTEXT_SYMBOL, { + value: { + context, + externalModulesExecutor, + }, + configurable: true, + enumerable: false, + writable: false, + }) + context.__vitest_mocker__ = moduleRunner.mocker - const { run } = (await executor.importExternalModule( + const { run } = (await moduleRunner.import( entryFile, )) as typeof import('../runVmTests') const fileSpecs = ctx.files.map(f => @@ -99,7 +107,7 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS method, fileSpecs, ctx.config, - executor, + moduleRunner, ) } finally { diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index 8527a02e96e8..cf81e781d43c 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -1,5 +1,5 @@ import type { File, RunMode, Suite, Test } from '@vitest/runner' -import type { RawSourceMap } from 'vite-node' +import type { Rollup } from 'vite' import type { TestProject } from '../node/project' import { calculateSuiteHash, @@ -39,7 +39,7 @@ export interface FileInformation { file: File filepath: string parsed: string - map: RawSourceMap | null + map: Rollup.SourceMap | null definitions: LocalCallDefinition[] } @@ -47,7 +47,7 @@ export async function collectTests( ctx: TestProject, filepath: string, ): Promise { - const request = await ctx.vitenode.transformRequest(filepath, filepath) + const request = await ctx.vite.environments.ssr.transformRequest(filepath) if (!request) { return null } @@ -229,7 +229,7 @@ export async function collectTests( file, parsed: request.code, filepath, - map: request.map as RawSourceMap | null, + map: request.map as Rollup.SourceMap | null, definitions, } } diff --git a/packages/vitest/src/types/environment.ts b/packages/vitest/src/types/environment.ts index 6c6d345973bb..3f2f9e080dfe 100644 --- a/packages/vitest/src/types/environment.ts +++ b/packages/vitest/src/types/environment.ts @@ -11,7 +11,17 @@ export interface VmEnvironmentReturn { export interface Environment { name: string - transformMode: 'web' | 'ssr' + /** + * @deprecated use `viteEnvironment` instead. Uses `name` by default + */ + transformMode?: 'web' | 'ssr' + /** + * Environment initiated by the Vite server. It is usually available + * as `vite.server.environments.${name}`. + * + * By default, fallbacks to `name`. + */ + viteEnvironment?: 'client' | 'ssr' | ({} & string) setupVM?: (options: Record) => Awaitable setup: ( global: any, diff --git a/packages/vitest/src/types/general.ts b/packages/vitest/src/types/general.ts index 62e80527407b..0bb3169b1249 100644 --- a/packages/vitest/src/types/general.ts +++ b/packages/vitest/src/types/general.ts @@ -4,12 +4,10 @@ export type Awaitable = T | PromiseLike export type Arrayable = T | Array export type ArgumentsType = T extends (...args: infer U) => any ? U : never -export type TransformMode = 'web' | 'ssr' - export interface AfterSuiteRunMeta { coverage?: unknown testFiles: string[] - transformMode: TransformMode | 'browser' + environment: string projectName?: string } @@ -31,5 +29,20 @@ export interface ModuleGraphData { export interface ProvidedContext {} +export interface ResolveFunctionResult { + id: string + file: string + url: string +} + +export interface FetchCachedFileSystemResult { + cached: true + tmp: string + id: string + file: string | null + url: string + invalidate: boolean +} + // These need to be compatible with Tinyrainbow's bg-colors, and CSS's background-color export type LabelColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index f6fd773e8d39..b05724ab54a2 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/packages/vitest/src/types/rpc.ts @@ -1,26 +1,12 @@ import type { CancelReason, File, TaskEventPack, TaskResultPack, TestAnnotation } from '@vitest/runner' import type { SnapshotResult } from '@vitest/snapshot' -import type { AfterSuiteRunMeta, TransformMode, UserConsoleLog } from './general' +import type { FetchFunctionOptions, FetchResult } from 'vite/module-runner' +import type { AfterSuiteRunMeta, FetchCachedFileSystemResult, ResolveFunctionResult, UserConsoleLog } from './general' export interface RuntimeRPC { - fetch: ( - id: string, - transformMode: TransformMode - ) => Promise<{ - externalize?: string - id?: string - }> - transform: (id: string, transformMode: TransformMode) => Promise<{ - code?: string - }> - resolveId: ( - id: string, - importer: string | undefined, - transformMode: TransformMode - ) => Promise<{ - external?: boolean | 'absolute' | 'relative' - id: string - } | null> + fetch: (id: string, importer: string | undefined, environment: string, options?: FetchFunctionOptions) => Promise + resolve: (id: string, importer: string | undefined, environment: string) => Promise + transform: (id: string) => Promise<{ code?: string }> onUserConsoleLog: (log: UserConsoleLog) => void onUnhandledError: (err: unknown, type: string) => void diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 6920de9ded50..5114ac110261 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -1,15 +1,14 @@ import type { CancelReason, FileSpecification, Task } from '@vitest/runner' import type { BirpcReturn } from 'birpc' +import type { EvaluatedModules } from 'vite/module-runner' import type { SerializedConfig } from '../runtime/config' import type { Environment } from './environment' -import type { TransformMode } from './general' import type { RunnerRPC, RuntimeRPC } from './rpc' export type WorkerRPC = BirpcReturn export interface ContextTestEnvironment { name: string - transformMode?: TransformMode options: Record | null } @@ -33,10 +32,19 @@ export interface WorkerGlobalState { rpc: WorkerRPC current?: Task filepath?: string + metaEnv: { + [key: string]: any + BASE_URL: string + MODE: string + DEV: boolean + PROD: boolean + SSR: boolean + } environment: Environment environmentTeardownRun?: boolean onCancel: Promise - moduleCache: Map + evaluatedModules: EvaluatedModules + resolvingModules: Set moduleExecutionInfo: Map onCleanup: (listener: () => unknown) => void providedContext: Record diff --git a/packages/vitest/src/utils/coverage.ts b/packages/vitest/src/utils/coverage.ts index 205b5dfa2d3a..b9d471d2b945 100644 --- a/packages/vitest/src/utils/coverage.ts +++ b/packages/vitest/src/utils/coverage.ts @@ -1,10 +1,9 @@ -import type { ModuleExecutionInfo } from 'vite-node/client' import type { SerializedCoverageConfig } from '../runtime/config' export interface RuntimeCoverageModuleLoader { - executeId: (id: string) => Promise<{ default: RuntimeCoverageProviderModule }> + import: (id: string) => Promise<{ default: RuntimeCoverageProviderModule }> isBrowser?: boolean - moduleExecutionInfo?: ModuleExecutionInfo + moduleExecutionInfo?: Map } export interface RuntimeCoverageProviderModule { @@ -21,7 +20,7 @@ export interface RuntimeCoverageProviderModule { /** * Executed on after each run in the worker thread. Possible to return a payload passed to the provider */ - takeCoverage?: (runtimeOptions?: { moduleExecutionInfo?: ModuleExecutionInfo }) => unknown | Promise + takeCoverage?: (runtimeOptions?: { moduleExecutionInfo?: Map }) => unknown | Promise /** * Executed after all tests have been run in the worker thread. @@ -51,7 +50,7 @@ export async function resolveCoverageProviderModule( builtInModule += '/browser' } - const { default: coverageModule } = await loader.executeId(builtInModule) + const { default: coverageModule } = await loader.import(builtInModule) if (!coverageModule) { throw new Error( @@ -65,7 +64,7 @@ export async function resolveCoverageProviderModule( let customProviderModule try { - customProviderModule = await loader.executeId(options.customProviderModule!) + customProviderModule = await loader.import(options.customProviderModule!) } catch (error) { throw new Error( diff --git a/packages/vitest/src/utils/graph.ts b/packages/vitest/src/utils/graph.ts index 3e3cd783524e..875307dc6da6 100644 --- a/packages/vitest/src/utils/graph.ts +++ b/packages/vitest/src/utils/graph.ts @@ -26,11 +26,12 @@ export async function getModuleGraph( } let id = clearId(mod.id) seen.set(mod, id) + // TODO: how to know if it was rewritten(?) - what is rewritten? const rewrote = browser ? mod.file?.includes(project.browser!.vite.config.cacheDir) ? mod.id : false - : await project.vitenode.shouldExternalize(id) + : false if (rewrote) { id = rewrote externalized.add(id) diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index 0d9a711c9f42..8efb74e675d7 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -1,9 +1,8 @@ import type { TestProject } from '../node/project' import type { TestSpecification } from '../node/spec' -import type { EnvironmentOptions, TransformModePatterns, VitestEnvironment } from '../node/types/config' +import type { EnvironmentOptions, VitestEnvironment } from '../node/types/config' import type { ContextTestEnvironment } from '../types/worker' import { promises as fs } from 'node:fs' -import pm from 'picomatch' import { groupBy } from './base' export const envsOrder: string[] = ['node', 'jsdom', 'happy-dom', 'edge-runtime'] @@ -14,19 +13,6 @@ export interface FileByEnv { envOptions: EnvironmentOptions | null } -function getTransformMode( - patterns: TransformModePatterns, - filename: string, -): 'web' | 'ssr' | undefined { - if (patterns.web && pm.isMatch(filename, patterns.web)) { - return 'web' - } - if (patterns.ssr && pm.isMatch(filename, patterns.ssr)) { - return 'ssr' - } - return undefined -} - export async function groupFilesByEnv( files: Array, ): Promise): Promise { + const state = getWorkerState() + const mocker = (globalThis as any).__vitest_mocker__ - prepareContext(context: Record): any { - const ctx = super.prepareContext(context) - // not supported for now, we can't synchronously load modules - return Object.assign(ctx, this.context, { - importScripts, - }) - } + const compiledFunctionArgumentsNames = Object.keys(context) + const compiledFunctionArgumentsValues = Object.values(context) + compiledFunctionArgumentsNames.push('importScripts') + compiledFunctionArgumentsValues.push(importScripts) + + const vm = (globalThis as any)[VITEST_VM_CONTEXT_SYMBOL] + + const evaluator = new VitestModuleEvaluator(vm, { + interopDefault: state.config.deps.interopDefault, + moduleExecutionInfo: state.moduleExecutionInfo, + getCurrentTestFilepath: () => state.filepath, + compiledFunctionArgumentsNames, + compiledFunctionArgumentsValues, + }) + + return startVitestModuleRunner({ + evaluator, + evaluatedModules: state.evaluatedModules, + mocker, + state, + }) } function importScripts() { diff --git a/packages/web-worker/src/shared-worker.ts b/packages/web-worker/src/shared-worker.ts index 7b12bac85049..7dcd5ba55707 100644 --- a/packages/web-worker/src/shared-worker.ts +++ b/packages/web-worker/src/shared-worker.ts @@ -1,11 +1,8 @@ import type { MessagePort as NodeMessagePort } from 'node:worker_threads' import type { Procedure } from './types' -import { - MessageChannel, - -} from 'node:worker_threads' -import { InlineWorkerRunner } from './runner' -import { debug, getFileIdFromUrl, getRunnerOptions } from './utils' +import { MessageChannel } from 'node:worker_threads' +import { startWebWorkerModuleRunner } from './runner' +import { debug, getFileIdFromUrl } from './utils' function convertNodePortToWebPort(port: NodeMessagePort): MessagePort { if (!('addEventListener' in port)) { @@ -53,8 +50,6 @@ function convertNodePortToWebPort(port: NodeMessagePort): MessagePort { } export function createSharedWorkerConstructor(): typeof SharedWorker { - const runnerOptions = getRunnerOptions() - return class SharedWorker extends EventTarget { static __VITEST_WEB_WORKER__ = true @@ -83,11 +78,6 @@ export function createSharedWorkerConstructor(): typeof SharedWorker { onrtctransform: null, onunhandledrejection: null, origin: typeof location !== 'undefined' ? location.origin : 'http://localhost:3000', - importScripts: () => { - throw new Error( - '[vitest] `importScripts` is not supported in Vite workers. Please, consider using `import` instead.', - ) - }, crossOriginIsolated: false, onconnect: null as ((e: MessageEvent) => void) | null, name: name || '', @@ -123,31 +113,28 @@ export function createSharedWorkerConstructor(): typeof SharedWorker { context.onconnect?.(e as MessageEvent) }) - const runner = new InlineWorkerRunner(runnerOptions, context) - - const id = getFileIdFromUrl(url) - - this._vw_name = id - - runner - .resolveUrl(id) - .then(([, fsPath]) => { - this._vw_name = name ?? fsPath - - debug('initialize shared worker %s', this._vw_name) - - return runner.executeFile(fsPath).then(() => { - // worker should be new every time, invalidate its sub dependency - runnerOptions.moduleCache.invalidateSubDepTree([ - fsPath, - runner.mocker.getMockPath(fsPath), - ]) - this._vw_workerTarget.dispatchEvent( - new MessageEvent('connect', { - ports: [this._vw_workerPort], - }), - ) - debug('shared worker %s successfully initialized', this._vw_name) + const fileId = getFileIdFromUrl(url) + + this._vw_name = fileId + + startWebWorkerModuleRunner(context) + .then((runner) => { + return runner.mocker.resolveId(fileId).then(({ url, id: resolvedId }) => { + this._vw_name = name ?? url + debug('initialize shared worker %s', this._vw_name) + + return runner.import(url).then(() => { + runner._invalidateSubTreeById([ + resolvedId, + runner.mocker.getMockPath(resolvedId), + ]) + this._vw_workerTarget.dispatchEvent( + new MessageEvent('connect', { + ports: [this._vw_workerPort], + }), + ) + debug('shared worker %s successfully initialized', this._vw_name) + }) }) }) .catch((e) => { diff --git a/packages/web-worker/src/utils.ts b/packages/web-worker/src/utils.ts index 1ac9f9c23525..e3c1d976a970 100644 --- a/packages/web-worker/src/utils.ts +++ b/packages/web-worker/src/utils.ts @@ -1,12 +1,8 @@ import type { WorkerGlobalState } from 'vitest' import type { CloneOption } from './types' -import { readFileSync as _readFileSync } from 'node:fs' import ponyfillStructuredClone from '@ungap/structured-clone' import createDebug from 'debug' -// keep the reference in case it was mocked -const readFileSync = _readFileSync - export const debug: createDebug.Debugger = createDebug('vitest:web-worker') export function getWorkerState(): WorkerGlobalState { @@ -79,32 +75,6 @@ export function createMessageEvent( } } -export function getRunnerOptions(): any { - const state = getWorkerState() - const { config, rpc, moduleCache, moduleExecutionInfo } = state - - return { - async fetchModule(id: string) { - const result = await rpc.fetch(id, 'web') - if (result.id && !result.externalize) { - const code = readFileSync(result.id, 'utf-8') - return { code } - } - return result - }, - resolveId(id: string, importer?: string) { - return rpc.resolveId(id, importer, 'web') - }, - moduleCache, - moduleExecutionInfo, - interopDefault: config.deps.interopDefault ?? true, - moduleDirectories: config.deps.moduleDirectories, - root: config.root, - base: config.base, - state, - } -} - function stripProtocol(url: string | URL) { return url.toString().replace(/^file:\/+/, '/') } diff --git a/packages/web-worker/src/worker.ts b/packages/web-worker/src/worker.ts index e8862cc20678..8e125629192a 100644 --- a/packages/web-worker/src/worker.ts +++ b/packages/web-worker/src/worker.ts @@ -3,18 +3,16 @@ import type { DefineWorkerOptions, Procedure, } from './types' -import { InlineWorkerRunner } from './runner' +import { startWebWorkerModuleRunner } from './runner' import { createMessageEvent, debug, getFileIdFromUrl, - getRunnerOptions, } from './utils' export function createWorkerConstructor( options?: DefineWorkerOptions, ): typeof Worker { - const runnerOptions = getRunnerOptions() const cloneType = () => (options?.clone ?? process.env.VITEST_WEB_WORKER_CLONE @@ -58,11 +56,6 @@ export function createWorkerConstructor( onrtctransform: null, onunhandledrejection: null, origin: typeof location !== 'undefined' ? location.origin : 'http://localhost:3000', - importScripts: () => { - throw new Error( - '[vitest] `importScripts` is not supported in Vite workers. Please, consider using `import` instead.', - ) - }, crossOriginIsolated: false, name: options?.name || '', close: () => this.terminate(), @@ -119,34 +112,31 @@ export function createWorkerConstructor( this.onmessageerror?.(e) }) - const runner = new InlineWorkerRunner(runnerOptions, context) - - const id = getFileIdFromUrl(url) - - this._vw_name = id - - runner - .resolveUrl(id) - .then(([, fsPath]) => { - this._vw_name = options?.name ?? fsPath - - debug('initialize worker %s', this._vw_name) - - return runner.executeFile(fsPath).then(() => { - // worker should be new every time, invalidate its sub dependency - runnerOptions.moduleCache.invalidateSubDepTree([ - fsPath, - runner.mocker.getMockPath(fsPath), - ]) - const q = this._vw_messageQueue - this._vw_messageQueue = null - if (q) { - q.forEach( - ([data, transfer]) => this.postMessage(data, transfer), - this, - ) - } - debug('worker %s successfully initialized', this._vw_name) + const fileId = getFileIdFromUrl(url) + + this._vw_name = fileId + + startWebWorkerModuleRunner(context) + .then((runner) => { + return runner.mocker.resolveId(fileId).then(({ url, id: resolvedId }) => { + this._vw_name = options?.name ?? url + debug('initialize worker %s', this._vw_name) + + return runner.import(url).then(() => { + runner._invalidateSubTreeById([ + resolvedId, + runner.mocker.getMockPath(resolvedId), + ]) + const q = this._vw_messageQueue + this._vw_messageQueue = null + if (q) { + q.forEach( + ([data, transfer]) => this.postMessage(data, transfer), + this, + ) + } + debug('worker %s successfully initialized', this._vw_name) + }) }) }) .catch((e) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae5510742ec..52573a8b794b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,12 @@ catalogs: '@types/istanbul-lib-coverage': specifier: ^2.0.6 version: 2.0.6 + '@types/istanbul-lib-report': + specifier: ^3.0.3 + version: 3.0.3 + '@types/istanbul-lib-source-maps': + specifier: ^4.0.4 + version: 4.0.4 '@types/istanbul-reports': specifier: ^3.0.4 version: 3.0.4 @@ -63,6 +69,18 @@ catalogs: flatted: specifier: ^3.3.3 version: 3.3.3 + istanbul-lib-coverage: + specifier: ^3.2.2 + version: 3.2.2 + istanbul-lib-report: + specifier: ^3.0.1 + version: 3.0.1 + istanbul-lib-source-maps: + specifier: ^5.0.6 + version: 5.0.6 + istanbul-reports: + specifier: ^3.1.7 + version: 3.1.7 magic-string: specifier: ^0.30.17 version: 0.30.17 @@ -75,6 +93,9 @@ catalogs: pathe: specifier: ^2.0.3 version: 2.0.3 + sirv: + specifier: ^3.0.1 + version: 3.0.1 std-env: specifier: ^3.9.0 version: 3.9.0 @@ -566,6 +587,9 @@ importers: '@bcoe/v8-coverage': specifier: ^1.0.2 version: 1.0.2 + '@vitest/utils': + specifier: workspace:* + version: link:../utils ast-v8-to-istanbul: specifier: ^0.3.3 version: 0.3.3 @@ -615,9 +639,6 @@ importers: pathe: specifier: 'catalog:' version: 2.0.3 - vite-node: - specifier: workspace:* - version: link:../vite-node vitest: specifier: workspace:* version: link:../vitest @@ -946,6 +967,9 @@ importers: debug: specifier: 'catalog:' version: 4.4.1 + es-module-lexer: + specifier: ^1.7.0 + version: 1.7.0 expect-type: specifier: ^1.2.2 version: 1.2.2 @@ -979,9 +1003,6 @@ importers: vite: specifier: ^6.3.5 version: 6.3.5(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.3)(yaml@2.8.0) - vite-node: - specifier: workspace:* - version: link:../vite-node why-is-node-running: specifier: ^2.3.0 version: 2.3.0 @@ -1156,6 +1177,12 @@ importers: test/cli: devDependencies: + '@test/test-dep-error': + specifier: file:./deps/error + version: file:test/cli/deps/error + '@test/test-dep-linked': + specifier: link:./deps/linked + version: link:deps/linked '@types/debug': specifier: 'catalog:' version: 4.1.12 @@ -1168,12 +1195,6 @@ importers: '@vitest/runner': specifier: workspace:^ version: link:../../packages/runner - '@vitest/test-dep-error': - specifier: file:./deps/error - version: file:test/cli/deps/error - '@vitest/test-dep-linked': - specifier: link:./deps/linked - version: link:deps/linked '@vitest/utils': specifier: workspace:* version: link:../../packages/utils @@ -1216,6 +1237,12 @@ importers: test/core: devDependencies: + '@test/vite-environment-external': + specifier: link:./projects/vite-environment-external + version: link:projects/vite-environment-external + '@test/vite-external': + specifier: link:./projects/vite-external + version: link:projects/vite-external '@types/debug': specifier: 'catalog:' version: 4.1.12 @@ -3905,6 +3932,9 @@ packages: '@swc/types@0.1.12': resolution: {integrity: sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==} + '@test/test-dep-error@file:test/cli/deps/error': + resolution: {directory: test/cli/deps/error, type: directory} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -4359,9 +4389,6 @@ packages: '@vitest/test-dep-conditions@file:test/config/deps/test-dep-conditions': resolution: {directory: test/config/deps/test-dep-conditions, type: directory} - '@vitest/test-dep-error@file:test/cli/deps/error': - resolution: {directory: test/cli/deps/error, type: directory} - '@vitest/test-dep1@file:test/core/deps/dep1': resolution: {directory: test/core/deps/dep1, type: directory} @@ -9554,7 +9581,7 @@ snapshots: '@babel/parser': 7.27.5 '@babel/types': 7.27.1 '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.29 jsesc: 3.1.0 '@babel/generator@7.27.5': @@ -9562,7 +9589,7 @@ snapshots: '@babel/parser': 7.27.5 '@babel/types': 7.27.6 '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.29 jsesc: 3.1.0 '@babel/generator@7.28.0': @@ -11601,6 +11628,8 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@test/test-dep-error@file:test/cli/deps/error': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 @@ -12213,8 +12242,6 @@ snapshots: dependencies: '@vitest/test-dep-conditions-indirect': file:test/config/deps/test-dep-conditions-indirect - '@vitest/test-dep-error@file:test/cli/deps/error': {} - '@vitest/test-dep1@file:test/core/deps/dep1': {} '@vitest/test-dep2@file:test/core/deps/dep2': @@ -14752,7 +14779,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.29 debug: 4.4.1 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: diff --git a/test/browser/.env.local b/test/browser/.env.local new file mode 100644 index 000000000000..e47f042f539a --- /dev/null +++ b/test/browser/.env.local @@ -0,0 +1 @@ +VITE_TEST_ENV=local \ No newline at end of file diff --git a/test/browser/src/env.ts b/test/browser/src/env.ts new file mode 100644 index 000000000000..dc81c1f038c2 --- /dev/null +++ b/test/browser/src/env.ts @@ -0,0 +1,3 @@ +export function getAuthToken() { + return import.meta.env.AUTH_TOKEN +} diff --git a/test/browser/test/env.test.ts b/test/browser/test/env.test.ts new file mode 100644 index 000000000000..763ca9b4b29f --- /dev/null +++ b/test/browser/test/env.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'vitest' +import { getAuthToken } from '../src/env' + +test('reads envs from .env file', () => { + expect(import.meta.env.VITE_TEST_ENV).toBe('local') +}) + +test('can reassign env locally', () => { + import.meta.env.VITEST_ENV = 'TEST' + expect(import.meta.env.VITEST_ENV).toBe('TEST') +}) + +test('can reassign env everywhere', () => { + import.meta.env.AUTH_TOKEN = '123' + expect(getAuthToken()).toBe('123') +}) + +test('custom env', () => { + expect(import.meta.env.CUSTOM_ENV).toBe('foo') +}) + +test('ignores import.meta.env in string literals', () => { + expect('import.meta.env').toBe('import' + '.meta.env') +}) + +test('define process and using import.meta.env together', () => { + const process = {} + expect(process).toMatchObject({}) + expect(import.meta.env.MODE).toEqual('test') +}) + +test('PROD, DEV, SSR should be boolean', () => { + expect(import.meta.env.PROD).toBe(false) + expect(import.meta.env.DEV).toBe(true) + + expect(import.meta.env.SSR).toBe(false) + + import.meta.env.SSR = true + expect(import.meta.env.SSR).toEqual(true) +}) diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index 6353140dd444..71ed8df1c2be 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -49,6 +49,9 @@ export default defineConfig({ includeSource: ['src/*.ts'], // having a snapshot environment doesn't affect browser tests snapshotEnvironment: './custom-snapshot-env.ts', + env: { + CUSTOM_ENV: 'foo', + }, browser: { enabled: true, headless: false, @@ -110,9 +113,6 @@ export default defineConfig({ html: './html/index.html', json: './browser.json', }, - env: { - BROWSER: browser, - }, onConsoleLog(log) { if (log.includes('MESSAGE ADDED')) { return false diff --git a/test/cli/custom.ts b/test/cli/custom.ts index 7c569e4e54eb..0eca806aa4b2 100644 --- a/test/cli/custom.ts +++ b/test/cli/custom.ts @@ -7,7 +7,7 @@ const log = debug('test:env') export default { name: 'custom', - transformMode: 'ssr', + viteEnvironment: 'ssr', setupVM({ custom }) { const context = vm.createContext({ testEnvironment: 'custom', diff --git a/test/cli/deps/error/package.json b/test/cli/deps/error/package.json index a254a0692069..16fb1c6a273c 100644 --- a/test/cli/deps/error/package.json +++ b/test/cli/deps/error/package.json @@ -1,5 +1,5 @@ { - "name": "@vitest/test-dep-error", + "name": "@test/test-dep-error", "type": "module", "private": true, "exports": { diff --git a/test/cli/deps/linked/package.json b/test/cli/deps/linked/package.json index 47df26633a08..d8ac48f3b9d8 100644 --- a/test/cli/deps/linked/package.json +++ b/test/cli/deps/linked/package.json @@ -1,5 +1,5 @@ { - "name": "@vitest/test-dep-linked", + "name": "@test/test-dep-linked", "type": "module", "private": true, "exports": { diff --git a/test/cli/fixtures/config-loader/browser/vitest.config.ts b/test/cli/fixtures/config-loader/browser/vitest.config.ts index d5b0934988d7..7a387aaf2ac3 100644 --- a/test/cli/fixtures/config-loader/browser/vitest.config.ts +++ b/test/cli/fixtures/config-loader/browser/vitest.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vitest/config" -import "@vitest/test-dep-linked/ts"; +import "@test/test-dep-linked/ts"; export default defineConfig({ test: { diff --git a/test/cli/fixtures/config-loader/node/vitest.config.ts b/test/cli/fixtures/config-loader/node/vitest.config.ts index 7cd505689a86..ab18a5fc8307 100644 --- a/test/cli/fixtures/config-loader/node/vitest.config.ts +++ b/test/cli/fixtures/config-loader/node/vitest.config.ts @@ -1,4 +1,4 @@ import { defineConfig } from "vitest/config" -import "@vitest/test-dep-linked/ts"; +import "@test/test-dep-linked/ts"; export default defineConfig({}) diff --git a/test/cli/fixtures/config-loader/vitest.config.ts b/test/cli/fixtures/config-loader/vitest.config.ts index 6cf7af8b2725..562336263344 100644 --- a/test/cli/fixtures/config-loader/vitest.config.ts +++ b/test/cli/fixtures/config-loader/vitest.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vitest/config" -import "@vitest/test-dep-linked/ts"; +import "@test/test-dep-linked/ts"; export default defineConfig({ test: { diff --git a/test/cli/fixtures/custom-pool/pool/custom-pool.ts b/test/cli/fixtures/custom-pool/pool/custom-pool.ts index 0e17a93b39bd..33066f5516c0 100644 --- a/test/cli/fixtures/custom-pool/pool/custom-pool.ts +++ b/test/cli/fixtures/custom-pool/pool/custom-pool.ts @@ -1,14 +1,7 @@ -import type { - RunnerTestFile, - RunnerTestCase, - RunnerTestSuite, - RunnerTaskResultPack, - RunnerTaskEventPack, - RunnerTask -} from 'vitest' +import type { RunnerTestCase } from 'vitest' import type { ProcessPool, Vitest } from 'vitest/node' -import { createFileTask, generateFileHash } from '@vitest/runner/utils' -import { normalize, relative } from 'pathe' +import { createFileTask } from '@vitest/runner/utils' +import { normalize } from 'pathe' export default (vitest: Vitest): ProcessPool => { const options = vitest.config.poolOptions?.custom as any diff --git a/test/cli/fixtures/stacktraces/error-in-package.test.js b/test/cli/fixtures/stacktraces/error-in-package.test.js index 91cdf48f8c55..d1a1826000c7 100644 --- a/test/cli/fixtures/stacktraces/error-in-package.test.js +++ b/test/cli/fixtures/stacktraces/error-in-package.test.js @@ -1,8 +1,8 @@ import { test } from 'vitest' -import testStack from "@vitest/test-dep-error" -import testStackTs from "@vitest/test-dep-error/ts.ts" -import testStackTranspiled from "@vitest/test-dep-error/transpiled.js" -import testStackTranspiledInline from "@vitest/test-dep-error/transpiled-inline.js" +import testStack from "@test/test-dep-error" +import testStackTs from "@test/test-dep-error/ts.ts" +import testStackTranspiled from "@test/test-dep-error/transpiled.js" +import testStackTranspiledInline from "@test/test-dep-error/transpiled-inline.js" test('js', () => { testStack() diff --git a/test/cli/package.json b/test/cli/package.json index 0c27b76c13c4..885caadb9650 100644 --- a/test/cli/package.json +++ b/test/cli/package.json @@ -3,15 +3,16 @@ "type": "module", "private": true, "scripts": { - "test": "vitest" + "test": "vitest", + "stacktraces": "vitest --root=./fixtures/stacktraces --watch=false" }, "devDependencies": { + "@test/test-dep-error": "file:./deps/error", + "@test/test-dep-linked": "link:./deps/linked", "@types/debug": "catalog:", "@types/ws": "catalog:", "@vitejs/plugin-basic-ssl": "^2.1.0", "@vitest/runner": "workspace:^", - "@vitest/test-dep-error": "file:./deps/error", - "@vitest/test-dep-linked": "link:./deps/linked", "@vitest/utils": "workspace:*", "debug": "^4.4.1", "unplugin-swc": "^1.5.5", diff --git a/test/cli/test/__snapshots__/stacktraces.test.ts.snap b/test/cli/test/__snapshots__/stacktraces.test.ts.snap index 2260ed52b601..7f35be58f3f4 100644 --- a/test/cli/test/__snapshots__/stacktraces.test.ts.snap +++ b/test/cli/test/__snapshots__/stacktraces.test.ts.snap @@ -28,8 +28,8 @@ exports[`stacktrace in dependency package > external 1`] = ` FAIL error-in-package.test.js > js Error: __TEST_STACK__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/index.js:10:9 - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/index.js:4:3 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/index.js:10:9 + ❯ testStack (NODE_MODULES)/@test/test-dep-error/index.js:4:3 ❯ error-in-package.test.js:8:12 6| 7| test('js', () => { @@ -42,22 +42,22 @@ Error: __TEST_STACK__ FAIL error-in-package.test.js > ts Error: __TEST_STACK_TS__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/ts.ts:22:9 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/ts.ts:22:9 20| */ 21| function innerTestStack() { 22| throw new Error('__TEST_STACK_TS__') | ^ 23| } 24| - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/ts.ts:12:3 + ❯ testStack (NODE_MODULES)/@test/test-dep-error/ts.ts:12:3 ❯ error-in-package.test.js:12:14 ⎯⎯[2/4]⎯ FAIL error-in-package.test.js > transpiled Error: __TEST_STACK_TRANSPILED__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/transpiled.ts:22:9 - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/transpiled.ts:12:3 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/transpiled.js:7:9 + ❯ testStack (NODE_MODULES)/@test/test-dep-error/transpiled.js:3:3 ❯ error-in-package.test.js:16:22 14| 15| test('transpiled', () => { @@ -70,8 +70,8 @@ Error: __TEST_STACK_TRANSPILED__ FAIL error-in-package.test.js > transpiled inline Error: __TEST_STACK_TRANSPILED_INLINE__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/transpiled-inline.ts:22:9 - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/transpiled-inline.ts:12:3 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/transpiled-inline.js:7:9 + ❯ testStack (NODE_MODULES)/@test/test-dep-error/transpiled-inline.js:3:3 ❯ error-in-package.test.js:20:28 18| 19| test('transpiled inline', () => { @@ -91,57 +91,51 @@ exports[`stacktrace in dependency package > inline 1`] = ` FAIL error-in-package.test.js > js Error: __TEST_STACK__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/index.js:10:9 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/index.js:10:9 8| 9| function innerTestStack() { 10| throw new Error('__TEST_STACK__') | ^ 11| } 12| - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/index.js:4:3 + ❯ testStack (NODE_MODULES)/@test/test-dep-error/index.js:4:3 ❯ error-in-package.test.js:8:12 ⎯⎯[1/4]⎯ FAIL error-in-package.test.js > ts Error: __TEST_STACK_TS__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/ts.ts:22:9 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/ts.ts:22:9 20| */ 21| function innerTestStack() { 22| throw new Error('__TEST_STACK_TS__') | ^ 23| } 24| - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/ts.ts:12:3 + ❯ testStack (NODE_MODULES)/@test/test-dep-error/ts.ts:12:3 ❯ error-in-package.test.js:12:14 ⎯⎯[2/4]⎯ FAIL error-in-package.test.js > transpiled Error: __TEST_STACK_TRANSPILED__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/transpiled.ts:22:9 - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/transpiled.ts:12:3 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/transpiled.js:22:9 + 7| throw new Error("__TEST_STACK_TRANSPILED__"); + 8| } + 10| + ❯ testStack (NODE_MODULES)/@test/test-dep-error/transpiled.js:12:3 ❯ error-in-package.test.js:16:22 - 14| - 15| test('transpiled', () => { - 16| testStackTranspiled() - | ^ - 17| }) - 18| ⎯⎯[3/4]⎯ FAIL error-in-package.test.js > transpiled inline Error: __TEST_STACK_TRANSPILED_INLINE__ - ❯ innerTestStack (NODE_MODULES)/@vitest/test-dep-error/transpiled-inline.ts:22:9 - ❯ testStack (NODE_MODULES)/@vitest/test-dep-error/transpiled-inline.ts:12:3 + ❯ innerTestStack (NODE_MODULES)/@test/test-dep-error/transpiled-inline.js:22:9 + 7| throw new Error("__TEST_STACK_TRANSPILED_INLINE__"); + 8| } + 10| + ❯ testStack (NODE_MODULES)/@test/test-dep-error/transpiled-inline.js:12:3 ❯ error-in-package.test.js:20:28 - 18| - 19| test('transpiled inline', () => { - 20| testStackTranspiledInline() - | ^ - 21| }) - 22| ⎯⎯[4/4]⎯ diff --git a/test/cli/test/inspect.test.ts b/test/cli/test/inspect.test.ts index b17da0af9c18..c244ea4b3f12 100644 --- a/test/cli/test/inspect.test.ts +++ b/test/cli/test/inspect.test.ts @@ -51,7 +51,7 @@ test('--inspect-brk stops at test file', async () => { await vitest.waitForStdout('Test Files 1 passed (1)') await waitForClose() -}) +}, 5_000) async function createChannel(url: string) { const ws = new WebSocket(url, { allowSynchronousEvents: false }) diff --git a/test/cli/test/stacktraces.test.ts b/test/cli/test/stacktraces.test.ts index e7822fd69106..2ec746ebdf9f 100644 --- a/test/cli/test/stacktraces.test.ts +++ b/test/cli/test/stacktraces.test.ts @@ -82,7 +82,7 @@ describe('stacktrace in dependency package', () => { root, server: { deps: { - inline: [/@vitest\/test-dep-error/], + inline: [/@test\/test-dep-error/], }, }, }, [testFile]) diff --git a/test/config/test/override.test.ts b/test/config/test/override.test.ts index 9ec222547a4f..029d706edd56 100644 --- a/test/config/test/override.test.ts +++ b/test/config/test/override.test.ts @@ -1,7 +1,6 @@ import type { UserConfig as ViteUserConfig } from 'vite' import type { TestUserConfig } from 'vitest/node' import { describe, expect, it, onTestFinished } from 'vitest' -import { extraInlineDeps } from 'vitest/config' import { createVitest, parseCLI } from 'vitest/node' type VitestOptions = Parameters[3] @@ -129,116 +128,6 @@ describe('correctly defines isolated flags', async () => { }) }) -describe('correctly defines inline and noExternal flags', async () => { - it('both are true if inline is true', async () => { - const v = await vitest({}, { - server: { - deps: { - inline: true, - }, - }, - }) - expect(v.vitenode.options.deps?.inline).toBe(true) - expect(v.vitenode.server.config.ssr.noExternal).toBe(true) - }) - - it('both are true if noExternal is true', async () => { - const v = await vitest({}, {}, { - ssr: { - noExternal: true, - }, - }) - expect(v.vitenode.options.deps?.inline).toBe(true) - expect(v.vitenode.server.config.ssr.noExternal).toBe(true) - }) - - it('inline are added to noExternal', async () => { - const regexp1 = /dep1/ - const regexp2 = /dep2/ - - const v = await vitest({}, { - server: { - deps: { - inline: ['dep1', 'dep2', regexp1, regexp2], - }, - }, - }) - - expect(v.vitenode.options.deps?.inline).toEqual([ - 'dep1', - 'dep2', - regexp1, - regexp2, - ...extraInlineDeps, - ]) - expect(v.server.config.ssr.noExternal).toEqual([ - 'dep1', - 'dep2', - regexp1, - regexp2, - ...extraInlineDeps, - ]) - }) - - it('noExternal are added to inline', async () => { - const regexp1 = /dep1/ - const regexp2 = /dep2/ - - const v = await vitest({}, {}, { - ssr: { - noExternal: ['dep1', 'dep2', regexp1, regexp2], - }, - }) - - expect(v.vitenode.options.deps?.inline).toEqual([ - ...extraInlineDeps, - 'dep1', - 'dep2', - regexp1, - regexp2, - ]) - expect(v.server.config.ssr.noExternal).toEqual([ - 'dep1', - 'dep2', - regexp1, - regexp2, - ]) - }) - - it('noExternal and inline don\'t have duplicates', async () => { - const regexp1 = /dep1/ - const regexp2 = /dep2/ - - const v = await vitest({}, { - server: { - deps: { - inline: ['dep2', regexp1, 'dep3'], - }, - }, - }, { - ssr: { - noExternal: ['dep1', 'dep2', regexp1, regexp2], - }, - }) - - expect(v.vitenode.options.deps?.inline).toEqual([ - 'dep2', - regexp1, - 'dep3', - ...extraInlineDeps, - 'dep1', - regexp2, - ]) - expect(v.server.config.ssr.noExternal).toEqual([ - 'dep1', - 'dep2', - regexp1, - regexp2, - 'dep3', - ]) - }) -}) - describe('correctly defines api flag', () => { it('CLI overrides disabling api', async () => { const c = await vitest({ api: false }, { diff --git a/test/config/test/rollup-error.test.ts b/test/config/test/rollup-error.test.ts index 10b6380f2507..77f83381c352 100644 --- a/test/config/test/rollup-error.test.ts +++ b/test/config/test/rollup-error.test.ts @@ -10,7 +10,7 @@ test('rollup error node', async () => { }) expect(stdout).toContain(`Error: Missing "./no-such-export" specifier in "${rolldownVersion ? 'rolldown-vite' : 'vite'}" package`) expect(stdout).toContain(`Plugin: vite:import-analysis`) - expect(stdout).toContain(`Error: Failed to load url @vitejs/no-such-package`) + expect(stdout).toContain(`Error: Cannot find package '@vitejs/no-such-package'`) }) test('rollup error web', async () => { diff --git a/test/core/package.json b/test/core/package.json index 8686655d894a..5d8950e86697 100644 --- a/test/core/package.json +++ b/test/core/package.json @@ -13,6 +13,8 @@ "collect": "vitest list" }, "devDependencies": { + "@test/vite-environment-external": "link:./projects/vite-environment-external", + "@test/vite-external": "link:./projects/vite-external", "@types/debug": "catalog:", "@vitest/expect": "workspace:*", "@vitest/mocker": "workspace:*", diff --git a/test/core/projects/vite-environment-external/index.d.ts b/test/core/projects/vite-environment-external/index.d.ts new file mode 100644 index 000000000000..b2bbc49c97c9 --- /dev/null +++ b/test/core/projects/vite-environment-external/index.d.ts @@ -0,0 +1 @@ +export declare const external: boolean diff --git a/test/core/projects/vite-environment-external/index.js b/test/core/projects/vite-environment-external/index.js new file mode 100644 index 000000000000..18d9eb65c719 --- /dev/null +++ b/test/core/projects/vite-environment-external/index.js @@ -0,0 +1 @@ +export const external = typeof __vite_ssr_import__ === 'undefined' diff --git a/test/core/projects/vite-environment-external/package.json b/test/core/projects/vite-environment-external/package.json new file mode 100644 index 000000000000..a8f0a1a7c06e --- /dev/null +++ b/test/core/projects/vite-environment-external/package.json @@ -0,0 +1,5 @@ +{ + "name": "@test/vite-environment-external", + "type": "module", + "main": "index.js" +} diff --git a/test/core/projects/vite-external/index.d.ts b/test/core/projects/vite-external/index.d.ts new file mode 100644 index 000000000000..b2bbc49c97c9 --- /dev/null +++ b/test/core/projects/vite-external/index.d.ts @@ -0,0 +1 @@ +export declare const external: boolean diff --git a/test/core/projects/vite-external/index.js b/test/core/projects/vite-external/index.js new file mode 100644 index 000000000000..18d9eb65c719 --- /dev/null +++ b/test/core/projects/vite-external/index.js @@ -0,0 +1 @@ +export const external = typeof __vite_ssr_import__ === 'undefined' diff --git a/test/core/projects/vite-external/package.json b/test/core/projects/vite-external/package.json new file mode 100644 index 000000000000..e0754496eb53 --- /dev/null +++ b/test/core/projects/vite-external/package.json @@ -0,0 +1,5 @@ +{ + "name": "@test/vite-external", + "type": "module", + "main": "index.js" +} diff --git a/test/core/test/exports.test.ts b/test/core/test/exports.test.ts index 1fe7d8e3cd0e..ae146d00187e 100644 --- a/test/core/test/exports.test.ts +++ b/test/core/test/exports.test.ts @@ -16,6 +16,7 @@ it('exports snapshot', async ({ skip, task }) => { .toMatchInlineSnapshot(` { ".": { + "EvaluatedModules": "function", "afterAll": "function", "afterEach": "function", "assert": "function", @@ -46,7 +47,6 @@ it('exports snapshot', async ({ skip, task }) => { "defaultInclude": "object", "defineConfig": "function", "defineProject": "function", - "extraInlineDeps": "object", "mergeConfig": "function", }, "./coverage": { @@ -56,9 +56,6 @@ it('exports snapshot', async ({ skip, task }) => { "builtinEnvironments": "object", "populateGlobal": "function", }, - "./execute": { - "VitestExecutor": "function", - }, "./internal/browser": { "SpyModule": "object", "TraceMap": "function", @@ -77,6 +74,13 @@ it('exports snapshot', async ({ skip, task }) => { "stringify": "function", "takeCoverageInsideWorker": "function", }, + "./internal/module-runner": { + "VITEST_VM_CONTEXT_SYMBOL": "string", + "VitestModuleEvaluator": "function", + "VitestModuleRunner": "function", + "getWorkerState": "function", + "startVitestModuleRunner": "function", + }, "./mocker": { "AutomockedModule": "function", "AutospiedModule": "function", @@ -169,6 +173,7 @@ it('exports snapshot', async ({ skip, task }) => { .toMatchInlineSnapshot(` { ".": { + "EvaluatedModules": "function", "afterAll": "function", "afterEach": "function", "assert": "function", @@ -199,7 +204,6 @@ it('exports snapshot', async ({ skip, task }) => { "defaultInclude": "object", "defineConfig": "function", "defineProject": "function", - "extraInlineDeps": "object", "mergeConfig": "function", }, "./coverage": { @@ -209,9 +213,6 @@ it('exports snapshot', async ({ skip, task }) => { "builtinEnvironments": "object", "populateGlobal": "function", }, - "./execute": { - "VitestExecutor": "function", - }, "./internal/browser": { "SpyModule": "object", "TraceMap": "function", @@ -230,6 +231,13 @@ it('exports snapshot', async ({ skip, task }) => { "stringify": "function", "takeCoverageInsideWorker": "function", }, + "./internal/module-runner": { + "VITEST_VM_CONTEXT_SYMBOL": "string", + "VitestModuleEvaluator": "function", + "VitestModuleRunner": "function", + "getWorkerState": "function", + "startVitestModuleRunner": "function", + }, "./mocker": { "AutomockedModule": "function", "AutospiedModule": "function", diff --git a/test/core/test/import-client.test.ts b/test/core/test/import-client.test.ts new file mode 100644 index 000000000000..fe8488290090 --- /dev/null +++ b/test/core/test/import-client.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from 'vitest' +// @ts-expect-error not typed import +import * as client from '/@vite/client' + +test('client is imported', () => { + expect(client).toHaveProperty('createHotContext') +}) diff --git a/test/core/test/imports.test.ts b/test/core/test/imports.test.ts index 8cb5caec61d1..3832aec9606a 100644 --- a/test/core/test/imports.test.ts +++ b/test/core/test/imports.test.ts @@ -82,9 +82,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 '\.\/some-unknown-path' imported/) + 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/) + 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/module.test.ts b/test/core/test/module.test.ts index 7d3b76d3a642..f01fe8dd588e 100644 --- a/test/core/test/module.test.ts +++ b/test/core/test/module.test.ts @@ -1,3 +1,6 @@ +import { external as viteEnvironmentExternal } from '@test/vite-environment-external' +import { external as viteExternal } from '@test/vite-external' + import { describe, expect, it } from 'vitest' // @ts-expect-error is not typed with imports @@ -37,10 +40,15 @@ import * as moduleDefaultCjs from '../src/external/default-cjs' // @ts-expect-error is not typed with imports import * as nestedDefaultExternalCjs from '../src/external/nested-default-cjs' - import c, { d } from '../src/module-esm' + import * as timeout from '../src/timeout' +it('extect vite.noExternal to be respected', () => { + expect(viteExternal).toBe(false) + expect(viteEnvironmentExternal).toBe(false) +}) + it('doesn\'t when extending module', () => { expect(() => Object.assign(globalThis, timeout)).not.toThrow() }) diff --git a/test/core/test/require.test.ts b/test/core/test/require.test.ts index 17df9ace9714..6df89b2ce34f 100644 --- a/test/core/test/require.test.ts +++ b/test/core/test/require.test.ts @@ -1,6 +1,5 @@ // @vitest-environment jsdom -// import { KNOWN_ASSET_RE } from 'vite-node/constants' import { describe, expect, it } from 'vitest' const _require = require diff --git a/test/core/test/utils.spec.ts b/test/core/test/utils.spec.ts index 7984fdf1756e..c9bc570279b9 100644 --- a/test/core/test/utils.spec.ts +++ b/test/core/test/utils.spec.ts @@ -1,8 +1,7 @@ -import type { EncodedSourceMap } from '../../../packages/vite-node/src/types' import { assertTypes, deepClone, deepMerge, isNegativeNaN, objDisplay, objectAttr, toArray } from '@vitest/utils' +import { EvaluatedModules } from 'vite/module-runner' import { beforeAll, describe, expect, test } from 'vitest' import { deepMergeSnapshot } from '../../../packages/snapshot/src/port/utils' -import { ModuleCacheMap } from '../../../packages/vite-node/src/client' import { resetModules } from '../../../packages/vitest/src/runtime/utils' describe('assertTypes', () => { @@ -206,9 +205,7 @@ describe('deepClone', () => { }) describe('resetModules doesn\'t resets only user modules', () => { - const mod = () => ({ evaluated: true, promise: Promise.resolve({}), resolving: false, exports: {}, map: {} as EncodedSourceMap }) - - const moduleCache = new ModuleCacheMap() + const moduleCache = new EvaluatedModules() const modules = [ ['/some-module.ts', true], ['/@fs/some-path.ts', true], @@ -222,23 +219,31 @@ describe('resetModules doesn\'t resets only user modules', () => { beforeAll(() => { modules.forEach(([path]) => { - moduleCache.set(path, mod()) + const exports = {} + moduleCache.idToModuleMap.set(path, { + id: path, + url: path, + file: path, + importers: new Set(), + imports: new Set(), + evaluated: true, + meta: undefined, + exports, + promise: Promise.resolve(exports), + map: undefined, + }) }) resetModules(moduleCache) }) test.each(modules)('Cache for %s is reset (%s)', (path, reset) => { - const cached = moduleCache.get(path) + const cached = moduleCache.idToModuleMap.get(path) if (reset) { - expect(cached).not.toHaveProperty('evaluated') - expect(cached).not.toHaveProperty('resolving') - expect(cached).not.toHaveProperty('exports') - expect(cached).not.toHaveProperty('promise') + expect(cached).toHaveProperty('exports', undefined) + expect(cached).toHaveProperty('promise', undefined) } else { - expect(cached).toHaveProperty('evaluated') - expect(cached).toHaveProperty('resolving') expect(cached).toHaveProperty('exports') expect(cached).toHaveProperty('promise') } diff --git a/test/core/vite.config.ts b/test/core/vite.config.ts index 34c5ae3f4796..a8c5cdc4f110 100644 --- a/test/core/vite.config.ts +++ b/test/core/vite.config.ts @@ -44,10 +44,18 @@ export default defineConfig({ }, resolve: { alias: [ - { find: '#', replacement: resolve(import.meta.dirname, 'src') }, + { find: /^#/, replacement: resolve(import.meta.dirname, 'src') }, { find: /^custom-lib$/, replacement: resolve(import.meta.dirname, 'projects', 'custom-lib') }, { find: /^inline-lib$/, replacement: resolve(import.meta.dirname, 'projects', 'inline-lib') }, ], + noExternal: [/projects\/vite-external/], + }, + environments: { + ssr: { + resolve: { + noExternal: [/projects\/vite-environment-external/], + }, + }, }, server: { port: 3022, @@ -72,6 +80,19 @@ export default defineConfig({ setupFiles: [ './test/setup.ts', ], + server: { + deps: { + external: [ + 'tinyspy', + /src\/external/, + /esm\/esm/, + /packages\/web-worker/, + /\.wasm$/, + /\/wasm-bindgen-no-cyclic\/index_bg.js/, + ], + inline: ['inline-lib'], + }, + }, includeTaskLocation: true, reporters: process.env.GITHUB_ACTIONS ? ['default', 'github-actions'] @@ -114,19 +135,6 @@ export default defineConfig({ deps: { moduleDirectories: ['node_modules', 'projects', 'packages'], }, - server: { - deps: { - external: [ - 'tinyspy', - /src\/external/, - /esm\/esm/, - /packages\/web-worker/, - /\.wasm$/, - /\/wasm-bindgen-no-cyclic\/index_bg.js/, - ], - inline: ['inline-lib'], - }, - }, alias: [ { find: 'test-alias', diff --git a/test/core/vitest-environment-custom/index.ts b/test/core/vitest-environment-custom/index.ts index a475ae847226..1c414253a36b 100644 --- a/test/core/vitest-environment-custom/index.ts +++ b/test/core/vitest-environment-custom/index.ts @@ -7,7 +7,7 @@ const log = debug('test:env') export default { name: 'custom', - transformMode: 'ssr', + viteEnvironment: 'ssr', setupVM({ custom }) { const context = vm.createContext({ testEnvironment: 'custom', diff --git a/test/core/vitest-environment-custom/package.json b/test/core/vitest-environment-custom/package.json index 122b09347d04..3af25f85cdd9 100644 --- a/test/core/vitest-environment-custom/package.json +++ b/test/core/vitest-environment-custom/package.json @@ -1,5 +1,6 @@ { "name": "vitest-environment-custom", + "type": "module", "private": true, "exports": { ".": "./index.ts" diff --git a/test/coverage-test/test/custom-provider.custom.test.ts b/test/coverage-test/test/custom-provider.custom.test.ts index 7674e06b823e..ee0033ef460e 100644 --- a/test/coverage-test/test/custom-provider.custom.test.ts +++ b/test/coverage-test/test/custom-provider.custom.test.ts @@ -19,8 +19,8 @@ test('custom provider', async () => { "reportCoverage with {\\"allTestsRun\\":true}" ], "coverageReports": [ - "{\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"},\\"testFiles\\":[\\"fixtures/test/even.test.ts\\"],\\"transformMode\\":\\"ssr\\",\\"projectName\\":\\"\\"}", - "{\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"},\\"testFiles\\":[\\"fixtures/test/math.test.ts\\"],\\"transformMode\\":\\"ssr\\",\\"projectName\\":\\"\\"}" + "{\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"},\\"testFiles\\":[\\"fixtures/test/even.test.ts\\"],\\"environment\\":\\"ssr\\",\\"projectName\\":\\"\\"}", + "{\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"},\\"testFiles\\":[\\"fixtures/test/math.test.ts\\"],\\"environment\\":\\"ssr\\",\\"projectName\\":\\"\\"}" ], "transformedFiles": [ "/fixtures/src/even.ts", diff --git a/test/optimize-deps/vitest.config.ts b/test/optimize-deps/vitest.config.ts index 98485f6ea6e1..5df38db04690 100644 --- a/test/optimize-deps/vitest.config.ts +++ b/test/optimize-deps/vitest.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ }, deps: { optimizer: { - web: { + client: { enabled: true, }, ssr: { diff --git a/test/reporters/tests/utils.test.ts b/test/reporters/tests/utils.test.ts index e78fc4fe7401..275b5c170c96 100644 --- a/test/reporters/tests/utils.test.ts +++ b/test/reporters/tests/utils.test.ts @@ -1,4 +1,4 @@ -import type { ViteNodeRunner } from 'vite-node/client' +import type { ModuleRunner } from 'vite/module-runner' import type { Vitest } from 'vitest/node' /** * @format @@ -11,8 +11,8 @@ import TestReporter from '../src/custom-reporter' const customReporterPath = resolve(import.meta.dirname, '../src/custom-reporter.js') const fetchModule = { - executeId: (id: string) => import(id), -} as ViteNodeRunner + import: (id: string) => import(id), +} as ModuleRunner const ctx = { runner: fetchModule, } as Vitest diff --git a/tsconfig.base.json b/tsconfig.base.json index cd21f6657808..da5ecf972430 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -23,6 +23,7 @@ "@vitest/browser/client": ["./packages/browser/src/client/client.ts"], "~/*": ["./packages/ui/client/*"], "vitest": ["./packages/vitest/src/public/index.ts"], + "vitest/internal/module-runner": ["./packages/vitest/src/public/module-runner.ts"], "vitest/globals": ["./packages/vitest/globals.d.ts"], "vitest/*": ["./packages/vitest/src/public/*"], "vite-node": ["./packages/vite-node/src/index.ts"],