[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

perf: reuse compiled code across vm pool contexts and prewarm the module graph (#10744)

authored by

Vladimir and committed by
GitHub
(Jul 23, 2026, 3:10 PM +0200) d8b040c6 e0fadbaf

+477 -54
+1
packages/vitest/src/node/core.ts
··· 255 255 this._resolver, 256 256 resolved, 257 257 this._fsCache, 258 + this.state, 258 259 this._traces, 259 260 this._tmpDir, 260 261 )
+34 -14
packages/vitest/src/node/environments/fetchModule.ts
··· 18 18 const saveCachePromises = new Map<string, Promise<VitestFetchResult>>() 19 19 const readFilePromises = new Map<string, Promise<string | null>>() 20 20 21 + /** 22 + * Tracks the wall time during which at least one transform is running. 23 + * Durations of individual fetches cannot be summed instead: concurrent 24 + * fetches (parallel workers, the vm pool graph prewarm) all wait on the same 25 + * deduplicated in-flight transforms, so per-caller wall times overcount the 26 + * actual work by orders of magnitude. 27 + */ 28 + export interface TransformClock { 29 + transformStarted: () => void 30 + transformFinished: () => void 31 + } 32 + 21 33 class ModuleFetcher { 22 34 private tmpDirectories = new Set<string>() 23 35 private fsCacheEnabled: boolean ··· 30 42 private resolver: VitestResolver, 31 43 private config: ResolvedConfig, 32 44 private fsCache: FileSystemModuleCache, 45 + private clock: TransformClock, 33 46 private tmpProjectDir: string, 34 47 ) { 35 48 this.fsCacheEnabled = config.fsModuleCache === true ··· 312 325 moduleGraphModule: EnvironmentModuleNode, 313 326 options?: FetchFunctionOptions, 314 327 ): Promise<VitestFetchResult> { 315 - const moduleRunnerModule = await fetchModule( 316 - environment, 317 - url, 318 - importer, 319 - { 320 - ...options, 321 - inlineSourceMap: false, 322 - }, 323 - ).catch(handleRollupError) 328 + this.clock.transformStarted() 329 + try { 330 + const moduleRunnerModule = await fetchModule( 331 + environment, 332 + url, 333 + importer, 334 + { 335 + ...options, 336 + inlineSourceMap: false, 337 + }, 338 + ).catch(handleRollupError) 324 339 325 - const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule) 326 - if ('code' in result) { 327 - result.moduleType = await this.cachedModuleType(result.file, result.code, moduleGraphModule.transformResult) 340 + const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule) 341 + if ('code' in result) { 342 + result.moduleType = await this.cachedModuleType(result.file, result.code, moduleGraphModule.transformResult) 343 + } 344 + return result 328 345 } 329 - return result 346 + finally { 347 + this.clock.transformFinished() 348 + } 330 349 } 331 350 332 351 private sourceLoader(file: string | null): (() => Promise<string | null>) | undefined { ··· 415 434 resolver: VitestResolver, 416 435 config: ResolvedConfig, 417 436 fsCache: FileSystemModuleCache, 437 + clock: TransformClock, 418 438 traces: Traces, 419 439 tmpProjectDir: string, 420 440 ): VitestFetchFunction { 421 - const fetcher = new ModuleFetcher(resolver, config, fsCache, tmpProjectDir) 441 + const fetcher = new ModuleFetcher(resolver, config, fsCache, clock, tmpProjectDir) 422 442 return async (url, importer, environment, cacheFs, options, otelCarrier) => { 423 443 await traces.waitInit() 424 444 const context = otelCarrier
+118 -31
packages/vitest/src/node/pools/rpc.ts
··· 1 1 import type { DevEnvironment, EnvironmentModuleNode, FetchResult } from 'vite' 2 + import type { FetchFunctionOptions } from 'vite/module-runner' 2 3 import type { FetchCachedFileSystemResult } from '../../types/general' 3 4 import type { RuntimeRPC } from '../../types/rpc' 5 + import type { OTELCarrier } from '../../utils/traces' 4 6 import type { TestProject } from '../project' 5 7 import type { ResolveSnapshotPathHandlerContext } from '../types/config' 6 8 import { existsSync, mkdirSync } from 'node:fs' ··· 41 43 mkdirSync(project.config.dumpDir, { recursive: true }) 42 44 } 43 45 project.vitest.state.metadata[project.name].dumpDir = project.config.dumpDir 46 + 47 + function getEnvironment(environmentName: string): DevEnvironment { 48 + const environment = project.vite.environments[environmentName] 49 + if (!environment) { 50 + throw new Error(`The environment ${environmentName} was not defined in the Vite config.`) 51 + } 52 + return environment 53 + } 54 + 55 + async function fetchModule( 56 + url: string, 57 + importer: string | undefined, 58 + environment: DevEnvironment, 59 + options?: FetchFunctionOptions, 60 + otelCarrier?: OTELCarrier, 61 + // per-module durations are only recorded for direct worker fetches: the 62 + // graph prewarm fetches whole levels concurrently, so its per-module wall 63 + // times measure the queue position, not the module's own transform cost 64 + accountModuleDuration = true, 65 + ): Promise<FetchResult | FetchCachedFileSystemResult> { 66 + const state = project.vitest.state 67 + const start = performance.now() 68 + 69 + return await project._fetcher(url, importer, environment, cacheFs, options, otelCarrier).then((result) => { 70 + const metadata = state.metadata[project.name] 71 + if ('externalize' in result) { 72 + metadata.externalized[url] = result.externalize 73 + // builtins and network urls are already resolved inside the worker 74 + // without a round-trip, only module externalizations are worth sharing 75 + if (result.type === 'module' && url[0] === '/') { 76 + let externals = warmExternals.get(environment) 77 + if (!externals) { 78 + externals = Object.create(null) as Record<string, FetchResult> 79 + warmExternals.set(environment, externals) 80 + } 81 + externals[url] = result 82 + } 83 + } 84 + if ('tmp' in result) { 85 + metadata.tmps[url] = result.tmp 86 + } 87 + if (accountModuleDuration) { 88 + const duration = performance.now() - start 89 + metadata.duration[url] ??= [] 90 + metadata.duration[url].push(duration) 91 + } 92 + return result 93 + }) 94 + } 95 + 44 96 return { 45 97 async fetch( 46 98 url, ··· 49 101 options, 50 102 otelCarrier, 51 103 ) { 52 - const environment = project.vite.environments[environmentName] 53 - if (!environment) { 54 - throw new Error(`The environment ${environmentName} was not defined in the Vite config.`) 55 - } 56 - 57 - const start = performance.now() 58 - 59 - return await project._fetcher(url, importer, environment, cacheFs, options, otelCarrier).then((result) => { 60 - const duration = performance.now() - start 61 - project.vitest.state.transformTime += duration 62 - const metadata = project.vitest.state.metadata[project.name] 63 - if ('externalize' in result) { 64 - metadata.externalized[url] = result.externalize 65 - // builtins and network urls are already resolved inside the worker 66 - // without a round-trip, only module externalizations are worth sharing 67 - if (result.type === 'module' && url[0] === '/') { 68 - let externals = warmExternals.get(environment) 69 - if (!externals) { 70 - externals = Object.create(null) as Record<string, FetchResult> 71 - warmExternals.set(environment, externals) 72 - } 73 - externals[url] = result 74 - } 75 - } 76 - if ('tmp' in result) { 77 - metadata.tmps[url] = result.tmp 78 - } 79 - metadata.duration[url] ??= [] 80 - metadata.duration[url].push(duration) 81 - return result 82 - }) 104 + return fetchModule(url, importer, getEnvironment(environmentName), options, otelCarrier) 83 105 }, 84 106 async fetchWarmModules(environmentName, files) { 85 107 const environment = project.vite.environments[environmentName] ··· 149 171 } 150 172 151 173 return warm 174 + }, 175 + async prewarmModuleGraph(environmentName, files) { 176 + const environment = getEnvironment(environmentName) 177 + const moduleGraph = environment.moduleGraph 178 + const seen = new Set<string>() 179 + 180 + async function walkNode(node: EnvironmentModuleNode): Promise<void> { 181 + const children: Promise<void>[] = [] 182 + for (const child of node.importedModules) { 183 + if (child.url == null || seen.has(child.url)) { 184 + continue 185 + } 186 + if (child.transformResult) { 187 + seen.add(child.url) 188 + children.push(walkNode(child)) 189 + } 190 + else { 191 + children.push(fetchNode(child.url, node.id ?? undefined)) 192 + } 193 + } 194 + if (children.length) { 195 + await Promise.all(children) 196 + } 197 + } 198 + 199 + async function fetchNode(url: string, importer: string | undefined): Promise<void> { 200 + if (seen.has(url)) { 201 + return 202 + } 203 + seen.add(url) 204 + try { 205 + await fetchModule(url, importer, environment, undefined, undefined, false) 206 + } 207 + catch { 208 + // the worker's own fetch will surface the error with the proper 209 + // import context 210 + return 211 + } 212 + let node: EnvironmentModuleNode | undefined 213 + try { 214 + node = await moduleGraph.getModuleByUrl(url) ?? moduleGraph.getModuleById(url) ?? undefined 215 + } 216 + catch { 217 + node = moduleGraph.getModuleById(url) ?? undefined 218 + } 219 + if (node) { 220 + await walkNode(node) 221 + } 222 + } 223 + 224 + await Promise.all([...files, ...project.config.setupFiles].map(async (file) => { 225 + const nodes = moduleGraph.getModulesByFile(file) 226 + if (nodes && nodes.size) { 227 + await Promise.all(Array.from(nodes, (node) => { 228 + if (node.transformResult) { 229 + seen.add(node.url) 230 + return walkNode(node) 231 + } 232 + return fetchNode(node.url, undefined) 233 + })) 234 + } 235 + else { 236 + await fetchNode(file, undefined) 237 + } 238 + })) 152 239 }, 153 240 async resolve(id, importer, environmentName) { 154 241 const environment = project.vite.environments[environmentName]
+1
packages/vitest/src/node/project.ts
··· 101 101 this._resolver, 102 102 this.config, 103 103 this.vitest._fsCache, 104 + this.vitest.state, 104 105 this.vitest._traces, 105 106 this.tmpDir, 106 107 )
+24 -1
packages/vitest/src/node/state.ts
··· 1 1 import type { File, FileSpecification, Task, TaskResultPack } from '../runtime/runner/types' 2 2 import type { AsyncLeak, UserConsoleLog } from '../types/general' 3 + import type { TransformClock } from './environments/fetchModule' 3 4 import type { TestProject } from './project' 4 5 import type { MergedBlobs } from './reporters/blob' 5 6 import type { OnUnhandledErrorCallback } from './types/config' ··· 15 16 return err instanceof Error && 'errors' in err 16 17 } 17 18 18 - export class StateManager { 19 + export class StateManager implements TransformClock { 19 20 filesMap: Map<string, File[]> = new Map() 20 21 pathsSet: Set<string> = new Set() 21 22 idMap: Map<string, Task> = new Map() ··· 24 25 leakSet: Set<AsyncLeak> = new Set() 25 26 reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap() 26 27 blobs?: MergedBlobs 28 + /** 29 + * Wall time during which the server's module transform pipeline was busy, 30 + * measured as the union of in-flight fetch intervals. Individual fetch 31 + * durations cannot be summed instead: concurrent fetches (parallel workers, 32 + * the vm pool graph prewarm) all wait on the same deduplicated in-flight 33 + * transforms, so per-caller wall times overcount the actual work by orders 34 + * of magnitude. 35 + */ 27 36 transformTime = 0 37 + private _transformsInflight = 0 38 + private _transformsBusyStart = 0 39 + 40 + transformStarted(): void { 41 + if (this._transformsInflight++ === 0) { 42 + this._transformsBusyStart = performance.now() 43 + } 44 + } 45 + 46 + transformFinished(): void { 47 + if (--this._transformsInflight === 0) { 48 + this.transformTime += performance.now() - this._transformsBusyStart 49 + } 50 + } 28 51 29 52 metadata: Record<string, { 30 53 externalized: Record<string, string>
+56 -7
packages/vitest/src/runtime/external-executor.ts
··· 1 1 import type vm from 'node:vm' 2 2 import type { RuntimeRPC } from '../types/rpc' 3 + import type { CodeCache } from './vm/code-cache' 3 4 import type { FileMap } from './vm/file-map' 4 5 import type { VMModule } from './vm/types' 5 6 import fs from 'node:fs' ··· 16 17 17 18 // always defined when we use vm pool 18 19 const nativeResolve = import.meta.resolve! 20 + 21 + // a relative ESM specifier resolves by plain URL join — Node's resolver adds 22 + // no information for these (it does not check existence and relative 23 + // specifiers never consult package.json), but it re-derives the package 24 + // scope on every uncached call, re-parsing large `exports` maps. Restricted 25 + // to a conservative charset so anything URL-special falls back to Node. 26 + const SIMPLE_RELATIVE_SPECIFIER_RE = /^\.{1,2}\/[\w\-./]+$/ 19 27 20 28 export interface ExternalModulesExecutorOptions { 21 29 context: vm.Context 22 30 fileMap: FileMap 31 + codeCache?: CodeCache 32 + resolveCache?: Map<string, string> 33 + moduleInfoCache?: Map<string, ModuleInformation> 23 34 packageCache: Map<string, any> 24 35 transform: RuntimeRPC['transform'] 25 36 interopDefault?: boolean 26 37 viteClientModule: Record<string, unknown> 27 38 } 28 39 29 - interface ModuleInformation { 40 + export interface ModuleInformation { 30 41 type: 31 42 | 'data' 32 43 | 'builtin' ··· 37 48 | 'network' 38 49 url: string 39 50 path: string 51 + exists?: boolean 40 52 } 41 53 42 54 // TODO: improve Node.js strict mode support in #2854 ··· 46 58 private vite: ViteExecutor 47 59 private context: vm.Context 48 60 private fs: FileMap 61 + public readonly codeCache: CodeCache | undefined 49 62 private resolvers: ((id: string, parent: string) => string | undefined)[] 50 63 = [] 51 64 ··· 55 68 this.context = options.context 56 69 57 70 this.fs = options.fileMap 71 + this.codeCache = options.codeCache 58 72 this.esm = new EsmExecutor(this, { 59 73 context: this.context, 60 74 }) ··· 62 76 context: this.context, 63 77 importModuleDynamically: this.importModuleDynamically, 64 78 fileMap: options.fileMap, 79 + codeCache: options.codeCache, 65 80 interopDefault: options.interopDefault, 66 81 }) 67 82 this.vite = new ViteExecutor({ ··· 116 131 } 117 132 } 118 133 134 + if ( 135 + SIMPLE_RELATIVE_SPECIFIER_RE.test(specifier) 136 + && parent.startsWith('file://') 137 + ) { 138 + return new URL(specifier, parent).href 139 + } 140 + 141 + // resolution of externalized modules is stable for the lifetime of the 142 + // worker (like fileMap/packageCache), while fresh vm contexts re-resolve 143 + // every import edge 144 + const cache = this.options.resolveCache 145 + const key = cache ? `${parent}\n${specifier}` : undefined 146 + if (cache) { 147 + const cached = cache.get(key!) 148 + if (cached !== undefined) { 149 + return cached 150 + } 151 + } 152 + 119 153 // import.meta.resolve can be asynchronous in older +18 Node versions 120 - return nativeResolve(specifier, parent) 154 + const resolved = nativeResolve(specifier, parent) 155 + if (cache && typeof resolved === 'string') { 156 + cache.set(key!, resolved) 157 + } 158 + return resolved 121 159 } 122 160 123 161 private getModuleInformation(identifier: string): ModuleInformation { 162 + const cached = this.options.moduleInfoCache?.get(identifier) 163 + if (cached) { 164 + return cached 165 + } 166 + const info = this.resolveModuleInformation(identifier) 167 + this.options.moduleInfoCache?.set(identifier, info) 168 + return info 169 + } 170 + 171 + private resolveModuleInformation(identifier: string): ModuleInformation { 124 172 if (identifier.startsWith('data:')) { 125 173 return { type: 'data', url: identifier, path: identifier } 126 174 } ··· 165 213 } 166 214 167 215 private createModule(identifier: string): VMModule | Promise<VMModule> { 168 - const { type, url, path } = this.getModuleInformation(identifier) 216 + const information = this.getModuleInformation(identifier) 217 + const { type, url, path } = information 169 218 170 219 // create ERR_MODULE_NOT_FOUND on our own since latest NodeJS's import.meta.resolve doesn't throw on non-existing namespace or path 171 220 // https://github.com/nodejs/node/pull/49038 172 - if ( 173 - (type === 'module' || type === 'commonjs' || type === 'wasm') 174 - && !existsSync(path) 175 - ) { 221 + if (type === 'module' || type === 'commonjs' || type === 'wasm') { 222 + information.exists ??= existsSync(path) 223 + } 224 + if (information.exists === false) { 176 225 const error = new Error(`Cannot find ${isBareImport(path) ? 'package' : 'module'} '${path}'`); 177 226 (error as any).code = 'ERR_MODULE_NOT_FOUND' 178 227 throw error
+22 -1
packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts
··· 25 25 26 26 const isWindows = process.platform === 'win32' 27 27 28 + // Compiled scripts of inlined modules, shared across vm contexts: vm pools 29 + // evaluate every module again in each fresh context, but the compiled script 30 + // holds no per-context state (Vite rewrites dynamic imports to 31 + // `__vite_ssr_dynamic_import__`, so no per-context import callback is baked 32 + // in) — only its evaluation has to happen per context. Keyed by module id 33 + // (`mock:` ids stay distinct from their originals). 34 + const vmInlineScriptCache = new Map<string, vm.Script>() 35 + 36 + function getVmInlineScript( 37 + id: string, 38 + wrappedCode: string, 39 + options: vm.ScriptOptions, 40 + ): vm.Script { 41 + let script = vmInlineScriptCache.get(id) 42 + if (!script) { 43 + script = new vm.Script(wrappedCode, options) 44 + vmInlineScriptCache.set(id, script) 45 + } 46 + return script 47 + } 48 + 28 49 export interface VitestModuleEvaluatorOptions { 29 50 evaluatedModules?: VitestEvaluatedModules 30 51 metaEnv?: ModuleRunnerImportMeta['env'] ··· 391 412 392 413 try { 393 414 const initModule = this.vm 394 - ? vm.runInContext(wrappedCode, this.vm.context, options) 415 + ? getVmInlineScript(module.id, wrappedCode, options).runInContext(this.vm.context) 395 416 : vm.runInThisContext(wrappedCode, options) 396 417 397 418 await initModule(...argumentsValues)
+52
packages/vitest/src/runtime/vm/code-cache.ts
··· 1 + interface CodeCacheEntry { 2 + source: string 3 + data: Buffer | undefined 4 + } 5 + 6 + /** 7 + * Worker-wide cache of V8 code cache buffers for externalized modules. 8 + * 9 + * vm pools create a fresh executor per test file, so every externalized 10 + * module is compiled and evaluated again in each fresh context. The compiled 11 + * code has no per-context state — reusing its V8 code cache skips the 12 + * re-parse/re-compile while the evaluation still happens per context. 13 + * 14 + * Entries are keyed by the module identifier and guarded by the exact source 15 + * text, so an invalidated module that produces different code simply replaces 16 + * its entry. 17 + */ 18 + export class CodeCache { 19 + private entries = new Map<string, CodeCacheEntry>() 20 + 21 + get(identifier: string, source: string): Buffer | undefined { 22 + const entry = this.entries.get(identifier) 23 + if (entry && entry.source === source) { 24 + return entry.data 25 + } 26 + return undefined 27 + } 28 + 29 + /** 30 + * Stores the code cache produced by `produce` unless an entry for the same 31 + * source already exists. A `produce` failure is recorded as an empty entry, 32 + * so it is not retried on every fresh context. 33 + */ 34 + store(identifier: string, source: string, produce: () => Buffer): void { 35 + const entry = this.entries.get(identifier) 36 + if (entry && entry.source === source) { 37 + return 38 + } 39 + let data: Buffer | undefined 40 + try { 41 + data = produce() 42 + } 43 + catch { 44 + data = undefined 45 + } 46 + this.entries.set(identifier, { source, data }) 47 + } 48 + 49 + delete(identifier: string): void { 50 + this.entries.delete(identifier) 51 + } 52 + }
+13
packages/vitest/src/runtime/vm/commonjs-executor.ts
··· 1 + import type { CodeCache } from './code-cache' 1 2 import type { FileMap } from './file-map' 2 3 import type { ImportModuleDynamically, VMSyntheticModule } from './types' 3 4 import { Module as _Module, createRequire, isBuiltin } from 'node:module' ··· 7 8 8 9 interface CommonjsExecutorOptions { 9 10 fileMap: FileMap 11 + codeCache?: CodeCache 10 12 interopDefault?: boolean 11 13 context: vm.Context 12 14 importModuleDynamically: ImportModuleDynamically ··· 33 35 > = Object.create(null) 34 36 35 37 private fs: FileMap 38 + private codeCache: CodeCache | undefined 36 39 private Module: typeof _Module 37 40 private interopDefault: boolean | undefined 38 41 39 42 constructor(options: CommonjsExecutorOptions) { 40 43 this.context = options.context 41 44 this.fs = options.fileMap 45 + this.codeCache = options.codeCache 42 46 this.interopDefault = options.interopDefault 43 47 44 48 const primitives = vm.runInContext( ··· 109 113 110 114 _compile(code: string, filename: string) { 111 115 const cjsModule = Module.wrap(code) 116 + const codeCache = executor.codeCache 117 + const cachedData = codeCache?.get(filename, cjsModule) 112 118 const script = new vm.Script(cjsModule, { 113 119 filename, 120 + cachedData, 114 121 importModuleDynamically: options.importModuleDynamically, 115 122 } as any) 123 + if (cachedData && script.cachedDataRejected) { 124 + codeCache!.delete(filename) 125 + } 116 126 // @ts-expect-error mark script with current identifier 117 127 script.identifier = filename 118 128 const fn = script.runInContext(executor.context) ··· 124 134 } 125 135 finally { 126 136 this.loaded = true 137 + // store after execution so the code cache carries the compiled 138 + // module body, not only the lazily-parsed wrapper 139 + codeCache?.store(filename, cjsModule, () => script.createCachedData()) 127 140 } 128 141 } 129 142
+7
packages/vitest/src/runtime/vm/esm-executor.ts
··· 73 73 this.moduleCache.set(fileURL, m) 74 74 return m 75 75 } 76 + const codeCache = this.executor.codeCache 77 + const cachedData = codeCache?.get(fileURL, code) 76 78 const m = new SourceTextModule(code, { 77 79 identifier: fileURL, 78 80 context: this.context, 81 + cachedData, 79 82 importModuleDynamically: this.executor.importModuleDynamically, 80 83 initializeImportMeta: (meta, mod) => { 81 84 meta.url = mod.identifier ··· 92 95 } 93 96 }, 94 97 }) 98 + // the code cache of a SourceTextModule must be created before evaluation 99 + if (!cachedData) { 100 + codeCache?.store(fileURL, code, () => m.createCachedData()) 101 + } 95 102 this.moduleCache.set(fileURL, m) 96 103 return m 97 104 }
+6
packages/vitest/src/runtime/vm/types.ts
··· 87 87 * @param code JavaScript Module code to parse 88 88 */ 89 89 constructor(code: string, options?: SourceTextModuleOptions) 90 + /** 91 + * Creates a code cache that can be used with the `SourceTextModule` 92 + * constructor's `cachedData` option. Must be called before the module 93 + * has been evaluated. 94 + */ 95 + createCachedData(): Buffer 90 96 }
+19
packages/vitest/src/runtime/workers/vm.ts
··· 1 1 import type { Context } from 'node:vm' 2 2 import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker' 3 3 import type { Traces } from '../../utils/traces' 4 + import type { ModuleInformation } from '../external-executor' 4 5 import { pathToFileURL } from 'node:url' 5 6 import { isContext, runInContext } from 'node:vm' 6 7 import { resolve } from 'pathe' ··· 15 16 import { startVitestModuleRunner, VITEST_VM_CONTEXT_SYMBOL } from '../moduleRunner/startVitestModuleRunner' 16 17 import { setupEnv } from '../setup-common' 17 18 import { provideWorkerState } from '../utils' 19 + import { CodeCache } from '../vm/code-cache' 18 20 import { FileMap } from '../vm/file-map' 19 21 20 22 const entryFile = pathToFileURL(resolve(distDir, 'workers/runVmTests.js')).href 21 23 22 24 const fileMap = new FileMap() 23 25 const packageCache = new Map<string, string>() 26 + const codeCache = new CodeCache() 27 + const resolveCache = new Map<string, string>() 28 + const moduleInfoCache = new Map<string, ModuleInformation>() 24 29 25 30 export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalState, traces: Traces): Promise<void> { 26 31 const { ctx, rpc } = state ··· 28 33 const beforeEnvironmentTime = performance.now() 29 34 const { environment } = await loadEnvironment(ctx.environment.name, ctx.config.root, rpc, traces, true) 30 35 state.environment = environment 36 + 37 + // let the server transform this file's import graph while this worker is 38 + // busy importing the environment package (jsdom takes ~0.5s per worker) — 39 + // the server is otherwise idle during that window on a cold start. The 40 + // transforms also land in the `fetchWarmModules` snapshot, so the worker's 41 + // own fetches short-circuit to disk reads. Failures are ignored: the 42 + // worker's own fetch reports them with the proper import context. 43 + rpc.prewarmModuleGraph( 44 + environment.viteEnvironment || environment.name, 45 + ctx.files.map(file => file.filepath), 46 + ).catch(() => {}) 31 47 32 48 if (!environment.setupVM) { 33 49 const envName = ctx.environment.name ··· 87 103 const externalModulesExecutor = new ExternalModulesExecutor({ 88 104 context, 89 105 fileMap, 106 + codeCache, 107 + resolveCache, 108 + moduleInfoCache, 90 109 packageCache, 91 110 transform: rpc.transform, 92 111 viteClientModule: stubs['/@vite/client'],
+7
packages/vitest/src/types/rpc.ts
··· 19 19 * paying a `fetch` round-trip per module. 20 20 */ 21 21 fetchWarmModules: (environment: string, files: string[]) => Promise<Record<string, FetchResult | FetchCachedFileSystemResult>> 22 + /** 23 + * Transforms the import graphs of the given test files ahead of the 24 + * worker's own fetches. Fired by vm pool workers before their environment 25 + * setup, so the server transforms modules while the worker is busy 26 + * importing its environment package. 27 + */ 28 + prewarmModuleGraph: (environment: string, files: string[]) => Promise<void> 22 29 transform: (id: string) => Promise<{ code?: string }> 23 30 24 31 onUserConsoleLog: (log: UserConsoleLog) => void
+54
test/e2e/test/vm-threads.test.ts
··· 16 16 expect(exitCode).toBe(0) 17 17 }) 18 18 19 + // compiled scripts of inlined modules are shared between vm contexts within 20 + // a worker — module state must still be re-evaluated per test file. With 4 21 + // files on 2 workers, at least one worker runs several files, so a leak of 22 + // evaluated state through the shared script would fail the second file. 23 + test.for(['vmThreads', 'vmForks'] as const)( 24 + '%s re-evaluates inlined modules in every context', 25 + async (pool) => { 26 + const testFile = ` 27 + import { expect, test } from 'vitest' 28 + import { increment } from './counter.js' 29 + 30 + test('module state is fresh for this file', () => { 31 + expect(increment()).toBe(1) 32 + }) 33 + ` 34 + const { stderr, exitCode } = await runInlineTests({ 35 + 'counter.js': ` 36 + let count = 0 37 + export function increment() { 38 + return ++count 39 + } 40 + `, 41 + 'a.test.js': testFile, 42 + 'b.test.js': testFile, 43 + 'c.test.js': testFile, 44 + 'd.test.js': testFile, 45 + }, { 46 + pool, 47 + maxWorkers: 2, 48 + }) 49 + 50 + expect(stderr).toBe('') 51 + expect(exitCode).toBe(0) 52 + }, 53 + ) 54 + 19 55 // vm pools resolve `isolate` to false (isolation comes from a fresh VM 20 56 // context per run request), which used to trigger the "single non-isolated 21 57 // worker receives all files at once" batching with `maxWorkers: 1` — all ··· 45 81 expect(exitCode).toBe(0) 46 82 }, 47 83 ) 84 + 85 + // the graph prewarm triggered by vm workers swallows its own transform 86 + // errors — the worker's fetch must still report them with the import context 87 + test('vm pools report errors from modules covered by the graph prewarm', async () => { 88 + const { stderr, exitCode } = await runInlineTests({ 89 + 'a.test.js': ` 90 + import './does-not-exist.js' 91 + import { test } from 'vitest' 92 + 93 + test('never runs', () => {}) 94 + `, 95 + }, { 96 + pool: 'vmThreads', 97 + }) 98 + 99 + expect(exitCode).toBe(1) 100 + expect(stderr).toContain('does-not-exist.js') 101 + }) 48 102 49 103 // The module-sync condition was added in Node 22.12/20.19 when require(esm) 50 104 // was unflagged. The fix uses the _resolveFilename conditions option which
+63
test/unit/test/vm-code-cache.test.ts
··· 1 + import { expect, test, vi } from 'vitest' 2 + import { CodeCache } from 'vitest/src/runtime/vm/code-cache.js' 3 + 4 + test('returns the stored data only for the exact same source', () => { 5 + const cache = new CodeCache() 6 + const data = Buffer.from('cached') 7 + 8 + expect(cache.get('/mod.js', 'source')).toBeUndefined() 9 + 10 + cache.store('/mod.js', 'source', () => data) 11 + 12 + expect(cache.get('/mod.js', 'source')).toBe(data) 13 + expect(cache.get('/mod.js', 'changed source')).toBeUndefined() 14 + expect(cache.get('/other.js', 'source')).toBeUndefined() 15 + }) 16 + 17 + test('does not produce again for the same source', () => { 18 + const cache = new CodeCache() 19 + const produce = vi.fn(() => Buffer.from('cached')) 20 + 21 + cache.store('/mod.js', 'source', produce) 22 + cache.store('/mod.js', 'source', produce) 23 + 24 + expect(produce).toHaveBeenCalledTimes(1) 25 + }) 26 + 27 + test('replaces the entry when the source changes', () => { 28 + const cache = new CodeCache() 29 + const first = Buffer.from('first') 30 + const second = Buffer.from('second') 31 + 32 + cache.store('/mod.js', 'source', () => first) 33 + cache.store('/mod.js', 'changed source', () => second) 34 + 35 + expect(cache.get('/mod.js', 'source')).toBeUndefined() 36 + expect(cache.get('/mod.js', 'changed source')).toBe(second) 37 + }) 38 + 39 + test('records a failed produce and does not retry it', () => { 40 + const cache = new CodeCache() 41 + const produce = vi.fn<() => Buffer>(() => { 42 + throw new Error('cannot create cached data') 43 + }) 44 + 45 + cache.store('/mod.js', 'source', produce) 46 + cache.store('/mod.js', 'source', produce) 47 + 48 + expect(produce).toHaveBeenCalledTimes(1) 49 + expect(cache.get('/mod.js', 'source')).toBeUndefined() 50 + }) 51 + 52 + test('delete removes the entry', () => { 53 + const cache = new CodeCache() 54 + const produce = vi.fn(() => Buffer.from('cached')) 55 + 56 + cache.store('/mod.js', 'source', produce) 57 + cache.delete('/mod.js') 58 + 59 + expect(cache.get('/mod.js', 'source')).toBeUndefined() 60 + 61 + cache.store('/mod.js', 'source', produce) 62 + expect(produce).toHaveBeenCalledTimes(2) 63 + })