From 47f758c6a5e734499d1b36e87f6fd1b5fd223ecb Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Nov 2025 21:19:54 +0100 Subject: [PATCH 01/53] perf(experimental): add file system cache --- packages/vitest/src/node/cache/fs.ts | 132 ++++++++++++++++++ packages/vitest/src/node/core.ts | 5 +- .../src/node/environments/fetchModule.ts | 131 +++++------------ packages/vitest/src/node/project.ts | 1 + 4 files changed, 174 insertions(+), 95 deletions(-) create mode 100644 packages/vitest/src/node/cache/fs.ts diff --git a/packages/vitest/src/node/cache/fs.ts b/packages/vitest/src/node/cache/fs.ts new file mode 100644 index 000000000000..7acf711a1512 --- /dev/null +++ b/packages/vitest/src/node/cache/fs.ts @@ -0,0 +1,132 @@ +import type { DevEnvironment, FetchResult } from 'vite' +import type { FetchCachedFileSystemResult } from '../../types/general' +import crypto from 'node:crypto' +import { existsSync, mkdirSync } from 'node:fs' +import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'pathe' +import { version as viteVersion } from 'vite' + +/** + * @experimental + */ +export class FileSystemModuleCache { + private fsCacheRoot: string + + constructor(fsCacheKey: string) { + this.fsCacheRoot = join(tmpdir(), fsCacheKey) + + if (!existsSync(this.fsCacheRoot)) { + mkdirSync(this.fsCacheRoot) + } + } + + async getCachedModule( + environment: DevEnvironment, + id: string, + ): Promise { + const cachedFilePath = this.getCachePath(environment, id) + + if (!existsSync(cachedFilePath)) { + return + } + + const content = await readFile(cachedFilePath, 'utf-8') + const matchIndex = content.lastIndexOf('\n//') + if (matchIndex === -1) { + return + } + + const meta = JSON.parse(content.slice(matchIndex + 4)) + if (meta.externalize) { + return { externalize: meta.externalize, type: meta.type } + } + + return { + id: meta.id, + url: meta.url, + file: meta.file, + // TODO: if fsCache is false, return with `code` + tmp: cachedFilePath, + cached: true, + invalidate: false, + } + } + + async saveCachedModule( + environment: DevEnvironment, + id: string, + fetchResult: T, + ): Promise { + const cachedFilePath = this.getCachePath(environment, id) + if ('externalize' in fetchResult) { + await atomicWriteFile(cachedFilePath, `\n// ${JSON.stringify(fetchResult)}`) + } + else if ('code' in fetchResult) { + const result = { + file: fetchResult.file, + id: fetchResult.id, + url: fetchResult.url, + invalidate: false, + } satisfies Omit + await atomicWriteFile(cachedFilePath, `${fetchResult.code}\n// ${JSON.stringify(result)}`) + } + } + + getCachePath(environment: DevEnvironment, id: string): string { + const config = environment.config + // TODO: more dynamic options + const viteConfig = JSON.stringify( + { + root: config.root, + resolve: config.resolve, + plugins: config.plugins.map(p => p.name), + }, + (_, value) => { + if (typeof value === 'function' || value instanceof RegExp) { + return value.toString() + } + return value + }, + ) + const cacheKey = crypto.createHash('sha1') + .update(id) + .update(process.env.NODE_ENV ?? '') + .update(viteVersion) + .update(viteConfig) + .digest('hex') + return join(this.fsCacheRoot, cacheKey) + } +} + +/** + * 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/core.ts b/packages/vitest/src/node/core.ts index 0f49459e06da..3ac82276ca32 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -15,7 +15,7 @@ import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage import type { Reporter } from './types/reporter' import type { TestRunResult } from './types/tests' import os, { tmpdir } from 'node:os' -import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils' +import { generateHash, getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils' import { SnapshotManager } from '@vitest/snapshot/manager' import { deepClone, deepMerge, nanoid, noop, toArray } from '@vitest/utils/helpers' import { join, normalize, relative } from 'pathe' @@ -223,6 +223,9 @@ export class Vitest { dumpFolder: this.config.dumpDir, readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, }, + generateHash( + this._config!.root + this._config!.name, + ), ) const environment = server.environments.__vitest__ this.runner = new ServerModuleRunner( diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 91633afc9335..ee920f2db384 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -2,15 +2,12 @@ import type { DevEnvironment, FetchResult, Rollup, TransformResult } from 'vite' import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' import type { VitestResolver } from '../resolver' -import { existsSync, mkdirSync } from 'node:fs' -import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isExternalUrl, nanoid, unwrapId } from '@vitest/utils/helpers' -import { dirname, join, resolve } from 'pathe' +import { join } from 'pathe' import { fetchModule } from 'vite' -import { hash } from '../hash' +import { FileSystemModuleCache } from '../cache/fs' -const created = new Set() const promises = new Map>() interface DumpOptions { @@ -30,9 +27,12 @@ export interface VitestFetchFunction { export function createFetchModuleFunction( resolver: VitestResolver, - tmpDir: string = join(tmpdir(), nanoid()), - dump?: DumpOptions, + _tmpDir: string = join(tmpdir(), nanoid()), + dump: DumpOptions, + fsCacheKey: string, ): VitestFetchFunction { + // TODO: doesn't work with watch mode + const fsCache = new FileSystemModuleCache(fsCacheKey) return async ( url, importer, @@ -70,57 +70,40 @@ export function createFetchModuleFunction( return { cache: true } } + const cachedModule = await fsCache.getCachedModule(environment, moduleGraphModule.id!) + + if (cachedModule) { + return cachedModule + } + if (moduleGraphModule.id) { const externalize = await resolver.shouldExternalize(moduleGraphModule.id) if (externalize) { + await fsCache.saveCachedModule( + environment, + moduleGraphModule.id, + { externalize, type: 'module' }, + ) return { externalize, type: 'module' } } } - let moduleRunnerModule: FetchResult | undefined - - if (dump?.dumpFolder && dump.readFromDump) { - const path = resolve(dump?.dumpFolder, url.replace(/[^\w+]/g, '-')) - if (existsSync(path)) { - const code = await readFile(path, 'utf-8') - const matchIndex = code.lastIndexOf('\n//') - if (matchIndex !== -1) { - const { id, file } = JSON.parse(code.slice(matchIndex + 4)) - moduleRunnerModule = { - code, - id, - url, - file, - invalidate: false, - } - } - } - } - - if (!moduleRunnerModule) { - moduleRunnerModule = await fetchModule( - environment, - url, - importer, - { - ...options, - inlineSourceMap: false, - }, - ).catch(handleRollupError) - } + const moduleRunnerModule = await fetchModule( + environment, + url, + importer, + { + ...options, + inlineSourceMap: false, + }, + ).catch(handleRollupError) const result = processResultSource(environment, moduleRunnerModule) - if (dump?.dumpFolder && 'code' in result) { - const path = resolve(dump?.dumpFolder, result.url.replace(/[^\w+]/g, '-')) - await writeFile(path, `${result.code}\n// ${JSON.stringify({ id: result.id, file: result.file })}`, 'utf-8') - } - if (!cacheFs || !('code' in result)) { return result } - const code = result.code const transformResult = result.transformResult! if (!transformResult) { throw new Error(`"transformResult" in not defined. This is a bug in Vitest.`) @@ -130,30 +113,22 @@ export function createFetchModuleFunction( if ('_vitestTmp' in transformResult) { return getCachedResult(result, Reflect.get(transformResult as any, '_vitestTmp')) } - 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) - return getCachedResult(result, tmp) + const cachePath = fsCache.getCachePath(environment, result.id) + if (promises.has(cachePath)) { + await promises.get(cachePath) + return getCachedResult(result, cachePath) } promises.set( - tmp, + cachePath, - atomicWriteFile(tmp, code) - // Fallback to non-atomic write for windows case where file already exists: - .catch(() => writeFile(tmp, code, 'utf-8')) + fsCache.saveCachedModule(environment, result.id, result) .finally(() => { - Reflect.set(transformResult, '_vitestTmp', tmp) - promises.delete(tmp) + Reflect.set(transformResult, '_vitestTmp', cachePath) + promises.delete(cachePath) }), ) - await promises.get(tmp) - return getCachedResult(result, tmp) + await promises.get(cachePath) + return getCachedResult(result, cachePath) } } @@ -265,35 +240,3 @@ export function handleRollupError(e: unknown): never { } 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/project.ts b/packages/vitest/src/node/project.ts index ee7fbeb45e70..9550dd663a80 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -557,6 +557,7 @@ export class TestProject { dumpFolder: this.config.dumpDir, readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, }, + this._hash!, ) const environment = server.environments.__vitest__ From 5748d96e28f5ce4ac11c81e57f0df13289542b71 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 11:26:37 +0100 Subject: [PATCH 02/53] chore: fix cache key --- packages/vitest/src/node/cache/fs.ts | 57 ++++++++++---- packages/vitest/src/node/cli/cli-config.ts | 3 + .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/node/core.ts | 1 + .../src/node/environments/fetchModule.ts | 78 ++++++++++++++++--- packages/vitest/src/node/project.ts | 1 + packages/vitest/src/node/resolver.ts | 2 +- packages/vitest/src/node/types/config.ts | 5 ++ packages/vitest/src/runtime/config.ts | 1 + test/test-utils/index.ts | 6 +- 10 files changed, 129 insertions(+), 26 deletions(-) diff --git a/packages/vitest/src/node/cache/fs.ts b/packages/vitest/src/node/cache/fs.ts index 7acf711a1512..5d0bb52753d8 100644 --- a/packages/vitest/src/node/cache/fs.ts +++ b/packages/vitest/src/node/cache/fs.ts @@ -1,33 +1,38 @@ import type { DevEnvironment, FetchResult } from 'vite' import type { FetchCachedFileSystemResult } from '../../types/general' +import type { VitestResolver } from '../resolver' +import type { ResolvedConfig } from '../types/config' import crypto from 'node:crypto' import { existsSync, mkdirSync } from 'node:fs' import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'pathe' import { version as viteVersion } from 'vite' +import { Vitest } from '../core' /** * @experimental */ export class FileSystemModuleCache { private fsCacheRoot: string + private version = '1.0.0' - constructor(fsCacheKey: string) { - this.fsCacheRoot = join(tmpdir(), fsCacheKey) + constructor(private _enabled: boolean) { + this.fsCacheRoot = join(tmpdir(), 'vitest') - if (!existsSync(this.fsCacheRoot)) { + if (_enabled && !existsSync(this.fsCacheRoot)) { mkdirSync(this.fsCacheRoot) } } + public isEnabled(): boolean { + return this._enabled + } + async getCachedModule( - environment: DevEnvironment, - id: string, + cachedFilePath: string, ): Promise { - const cachedFilePath = this.getCachePath(environment, id) - - if (!existsSync(cachedFilePath)) { + if (!this.isEnabled() || !existsSync(cachedFilePath)) { return } @@ -46,7 +51,7 @@ export class FileSystemModuleCache { id: meta.id, url: meta.url, file: meta.file, - // TODO: if fsCache is false, return with `code` + // TODO: if cacheFs is false, return with `code` tmp: cachedFilePath, cached: true, invalidate: false, @@ -54,11 +59,15 @@ export class FileSystemModuleCache { } async saveCachedModule( - environment: DevEnvironment, - id: string, + cachedFilePath: string, fetchResult: T, ): Promise { - const cachedFilePath = this.getCachePath(environment, id) + if (!this.isEnabled()) { + return + } + + // TODO: also keep dependencies, so they can populate the module graph on the next run + if ('externalize' in fetchResult) { await atomicWriteFile(cachedFilePath, `\n// ${JSON.stringify(fetchResult)}`) } @@ -73,14 +82,29 @@ export class FileSystemModuleCache { } } - getCachePath(environment: DevEnvironment, id: string): string { + getCachePath( + vitestConfig: ResolvedConfig, + environment: DevEnvironment, + resolver: VitestResolver, + id: string, + fileContent: string, + ): string { + if (!this.isEnabled()) { + return id + } + const config = environment.config - // TODO: more dynamic options const viteConfig = JSON.stringify( { root: config.root, + base: config.base, + mode: config.mode, + consumer: config.consumer, resolve: config.resolve, plugins: config.plugins.map(p => p.name), + environment: environment.name, + css: vitestConfig.css, + resolver: resolver.options, }, (_, value) => { if (typeof value === 'function' || value instanceof RegExp) { @@ -91,9 +115,12 @@ export class FileSystemModuleCache { ) const cacheKey = crypto.createHash('sha1') .update(id) + .update(fileContent) .update(process.env.NODE_ENV ?? '') - .update(viteVersion) + .update(this.version) .update(viteConfig) + .update(viteVersion) + .update(Vitest.version) .digest('hex') return join(this.fsCacheRoot, cacheKey) } diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 7a34b23cfb62..13465ede3a3f 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -774,6 +774,9 @@ export const cliOptionsConfig: VitestCLIOptions = { return value }, }, + clearCache: { + description: 'Clear all caches.', + }, // disable CLI options cliExclude: null, diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 04baa15963d5..aa960381c27b 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -130,5 +130,6 @@ export function serializeConfig(project: TestProject): SerializedConfig { serializedDefines: config.browser.enabled ? '' : project._serializedDefines || '', + cache: config.cache !== false, } } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 3ac82276ca32..9fc302aadbf8 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -218,6 +218,7 @@ export class Vitest { this._resolver = new VitestResolver(server.config.cacheDir, resolved) this._fetcher = createFetchModuleFunction( this._resolver, + this._config, this._tmpDir, { dumpFolder: this.config.dumpDir, diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index ee920f2db384..d1c2f17fea69 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -2,6 +2,8 @@ import type { DevEnvironment, FetchResult, Rollup, TransformResult } from 'vite' import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' import type { VitestResolver } from '../resolver' +import type { ResolvedConfig } from '../types/config' +import { readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isExternalUrl, nanoid, unwrapId } from '@vitest/utils/helpers' import { join } from 'pathe' @@ -27,12 +29,13 @@ export interface VitestFetchFunction { export function createFetchModuleFunction( resolver: VitestResolver, + config: ResolvedConfig, _tmpDir: string = join(tmpdir(), nanoid()), - dump: DumpOptions, - fsCacheKey: string, + _dump: DumpOptions, + _fsCacheKey: string, ): VitestFetchFunction { // TODO: doesn't work with watch mode - const fsCache = new FileSystemModuleCache(fsCacheKey) + const fsCache = new FileSystemModuleCache(config.cache !== false) return async ( url, importer, @@ -70,9 +73,47 @@ export function createFetchModuleFunction( return { cache: true } } - const cachedModule = await fsCache.getCachedModule(environment, moduleGraphModule.id!) + let fileContent = '' + if (!fsCache.isEnabled()) { + if (moduleGraphModule.file) { + fileContent = await readFile(moduleGraphModule.file, 'utf-8') + } + else { + const loadResult = await environment.pluginContainer.load(moduleGraphModule.id!) + if (typeof loadResult === 'string') { + fileContent = loadResult + } + else if (loadResult != null) { + fileContent = loadResult.code + } + } + } + + const cachePath = fsCache.getCachePath( + config, + environment, + resolver, + moduleGraphModule.id!, + fileContent, + ) + + const cachedModule = await fsCache.getCachedModule(cachePath) if (cachedModule) { + if ('tmp' in cachedModule) { + // keep the module graph in sync + if (!moduleGraphModule.transformResult) { + const code = await readFile(cachedModule.tmp, 'utf-8') + const map = extractSourceMap(code) + if (map && cachedModule.file) { + map.file = cachedModule.file + } + moduleGraphModule.transformResult = { + code, + map, + } + } + } return cachedModule } @@ -80,8 +121,7 @@ export function createFetchModuleFunction( const externalize = await resolver.shouldExternalize(moduleGraphModule.id) if (externalize) { await fsCache.saveCachedModule( - environment, - moduleGraphModule.id, + cachePath, { externalize, type: 'module' }, ) return { externalize, type: 'module' } @@ -100,7 +140,8 @@ export function createFetchModuleFunction( const result = processResultSource(environment, moduleRunnerModule) - if (!cacheFs || !('code' in result)) { + // TODO: still save the tmp file for `forks` pool _somehow_ + if (!fsCache.isEnabled() || !cacheFs || !('code' in result)) { return result } @@ -113,7 +154,6 @@ export function createFetchModuleFunction( if ('_vitestTmp' in transformResult) { return getCachedResult(result, Reflect.get(transformResult as any, '_vitestTmp')) } - const cachePath = fsCache.getCachePath(environment, result.id) if (promises.has(cachePath)) { await promises.get(cachePath) return getCachedResult(result, cachePath) @@ -121,7 +161,7 @@ export function createFetchModuleFunction( promises.set( cachePath, - fsCache.saveCachedModule(environment, result.id, result) + fsCache.saveCachedModule(cachePath, result) .finally(() => { Reflect.set(transformResult, '_vitestTmp', cachePath) promises.delete(cachePath) @@ -240,3 +280,23 @@ export function handleRollupError(e: unknown): never { } throw e } + +const MODULE_RUNNER_SOURCEMAPPING_REGEXP = new RegExp( + `//# ${SOURCEMAPPING_URL}=data:application/json;base64,(.+)`, +) + +function extractSourceMap(code: string): null | Rollup.SourceMap { + const pattern = `//# ${SOURCEMAPPING_URL}=data:application/json;base64,` + const lastIndex = code.lastIndexOf(pattern) + if (lastIndex === -1) { + return null + } + + const mapString = MODULE_RUNNER_SOURCEMAPPING_REGEXP.exec( + code.slice(lastIndex), + )?.[1] + if (!mapString) { + return null + } + return JSON.parse(Buffer.from(mapString, 'base64').toString('utf-8')) +} diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 9550dd663a80..ba4f17abe085 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -552,6 +552,7 @@ export class TestProject { this._serializedDefines = createDefinesScript(server.config.define) this._fetcher = createFetchModuleFunction( this._resolver, + this._config, this.tmpDir, { dumpFolder: this.config.dumpDir, diff --git a/packages/vitest/src/node/resolver.ts b/packages/vitest/src/node/resolver.ts index 63c4877b9552..fd6a3b71cdd4 100644 --- a/packages/vitest/src/node/resolver.ts +++ b/packages/vitest/src/node/resolver.ts @@ -9,7 +9,7 @@ import { dirname, extname, join, resolve } from 'pathe' import { isWindows } from '../utils/env' export class VitestResolver { - private options: ExternalizeOptions + public readonly options: ExternalizeOptions private externalizeCache = new Map>() constructor(cacheDir: string, config: ResolvedConfig) { diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index dd7446179c74..263e58c2f71c 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -954,6 +954,11 @@ export interface UserConfig extends InlineConfig { * @default '.vitest-reports' */ mergeReports?: string + + /** + * Clear all caches + */ + clearCache?: boolean } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 3fc4cf1fc9f2..afec6c0ecc7e 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -117,6 +117,7 @@ export interface SerializedConfig { includeSamples: boolean } | undefined serializedDefines: string + cache: boolean } export interface SerializedCoverageConfig { diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 2eca071ef630..9c93a3ed37b3 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -1,6 +1,6 @@ import type { Options } from 'tinyexec' import type { UserConfig as ViteUserConfig } from 'vite' -import type { WorkerGlobalState } from 'vitest' +import type { SerializedConfig, WorkerGlobalState } from 'vitest' import type { TestProjectConfiguration } from 'vitest/config' import type { TestCollection, TestModule, TestSpecification, TestUserConfig, Vitest, VitestRunMode } from 'vitest/node' import { webcrypto as crypto } from 'node:crypto' @@ -70,6 +70,8 @@ export async function runVitest( stdin.isTTY = true stdin.setRawMode = () => stdin const cli = new Cli({ stdin, stdout, stderr, preserveAnsi: runnerOptions.preserveAnsi }) + // @ts-expect-error not typed global + const currentConfig: SerializedConfig = __vitest_worker__.ctx.config let ctx: Vitest | undefined let thrown = false @@ -88,6 +90,8 @@ export async function runVitest( NO_COLOR: 'true', ...rest.env, }, + // override cache config with the one that was used to run `vitest` formt the CLI + ...(currentConfig.cache === false ? { cache: false } : {}), }, { ...viteOverrides, server: { From d026fbe1451e4f133fb6dd2751d746127900e79f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 12:41:28 +0100 Subject: [PATCH 03/53] refactor: use a class for easier management --- .../src/node/environments/fetchModule.ts | 281 ++++++++++-------- packages/vitest/src/node/resolver.ts | 15 +- 2 files changed, 170 insertions(+), 126 deletions(-) diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index d1c2f17fea69..55600d290f38 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -1,4 +1,4 @@ -import type { DevEnvironment, FetchResult, Rollup, TransformResult } from 'vite' +import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, TransformResult } from 'vite' import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' import type { VitestResolver } from '../resolver' @@ -10,48 +10,31 @@ import { join } from 'pathe' import { fetchModule } from 'vite' import { FileSystemModuleCache } from '../cache/fs' -const promises = new Map>() +const saveCachePromises = new Map>() +const readFilePromises = new Map>() -interface DumpOptions { - dumpFolder?: string - readFromDump?: boolean -} +class ModuleFetcher { + private fsCache: FileSystemModuleCache -export interface VitestFetchFunction { - ( + constructor( + private resolver: VitestResolver, + private config: ResolvedConfig, + ) { + this.fsCache = new FileSystemModuleCache(config.cache !== false) + } + + async fetch( url: string, importer: string | undefined, environment: DevEnvironment, cacheFs: boolean, - options?: FetchFunctionOptions - ): Promise -} - -export function createFetchModuleFunction( - resolver: VitestResolver, - config: ResolvedConfig, - _tmpDir: string = join(tmpdir(), nanoid()), - _dump: DumpOptions, - _fsCacheKey: string, -): VitestFetchFunction { - // TODO: doesn't work with watch mode - const fsCache = new FileSystemModuleCache(config.cache !== false) - return async ( - url, - importer, - environment, - cacheFs, - 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) + options?: FetchFunctionOptions, + ): Promise { 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' } } @@ -61,73 +44,88 @@ export function createFetchModuleFunction( 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 } } - let fileContent = '' - if (!fsCache.isEnabled()) { - if (moduleGraphModule.file) { - fileContent = await readFile(moduleGraphModule.file, 'utf-8') - } - else { - const loadResult = await environment.pluginContainer.load(moduleGraphModule.id!) - if (typeof loadResult === 'string') { - fileContent = loadResult - } - else if (loadResult != null) { - fileContent = loadResult.code - } - } + // caching can be disabled on a project-per-project or file-per-file basis + if (!cacheFs || !this.fsCache.isEnabled()) { + return this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) } - const cachePath = fsCache.getCachePath( - config, + const fileContent = await this.readFileContentToCache(environment, moduleGraphModule) + const cachePath = this.fsCache.getCachePath( + this.config, environment, - resolver, + this.resolver, moduleGraphModule.id!, fileContent, ) - const cachedModule = await fsCache.getCachedModule(cachePath) - + const cachedModule = await this.getCachedModule(cachePath, moduleGraphModule) if (cachedModule) { - if ('tmp' in cachedModule) { - // keep the module graph in sync - if (!moduleGraphModule.transformResult) { - const code = await readFile(cachedModule.tmp, 'utf-8') - const map = extractSourceMap(code) - if (map && cachedModule.file) { - map.file = cachedModule.file - } - moduleGraphModule.transformResult = { - code, - map, - } - } - } return cachedModule } - if (moduleGraphModule.id) { - const externalize = await resolver.shouldExternalize(moduleGraphModule.id) - if (externalize) { - await fsCache.saveCachedModule( - cachePath, - { externalize, type: 'module' }, - ) - return { externalize, type: 'module' } + const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) + + return this.cacheResult(result, cachePath) + } + + private async readFileContentToCache( + environment: DevEnvironment, + moduleGraphModule: EnvironmentModuleNode, + ): Promise { + if (moduleGraphModule.file) { + return this.readFileConcurrently(moduleGraphModule.file) + } + + const loadResult = await environment.pluginContainer.load(moduleGraphModule.id!) + if (typeof loadResult === 'string') { + return loadResult + } + if (loadResult != null) { + return loadResult.code + } + return '' + } + + private async getCachedModule( + cachePath: string, + moduleGraphModule: EnvironmentModuleNode, + ): Promise { + const cachedModule = await this.fsCache.getCachedModule(cachePath) + + if (cachedModule && 'tmp' in cachedModule) { + // keep the module graph in sync + if (!moduleGraphModule.transformResult) { + const code = await readFile(cachedModule.tmp, 'utf-8') + const map = extractSourceMap(code) + if (map && cachedModule.file) { + map.file = cachedModule.file + } + moduleGraphModule.transformResult = { code, map } } } + return cachedModule + } + + private async fetchAndProcess( + environment: DevEnvironment, + url: string, + importer: string | undefined, + moduleGraphModule: EnvironmentModuleNode, + options?: FetchFunctionOptions, + ): Promise { + const externalize = await this.resolver.shouldExternalize(moduleGraphModule.id!) + if (externalize) { + return { externalize, type: 'module' } + } + const moduleRunnerModule = await fetchModule( environment, url, @@ -138,40 +136,78 @@ export function createFetchModuleFunction( }, ).catch(handleRollupError) - const result = processResultSource(environment, moduleRunnerModule) + return processResultSource(environment, moduleRunnerModule) + } - // TODO: still save the tmp file for `forks` pool _somehow_ - if (!fsCache.isEnabled() || !cacheFs || !('code' in result)) { + private async cacheResult( + result: FetchResult, + cachePath: string, + ): Promise { + if (!this.fsCache.isEnabled()) { return result } - const transformResult = result.transformResult! - if (!transformResult) { - throw new Error(`"transformResult" in not defined. This is a bug in Vitest.`) - } - // to avoid serialising large chunks of code, - // we store them in a tmp file and read in the test thread - if ('_vitestTmp' in transformResult) { - return getCachedResult(result, Reflect.get(transformResult as any, '_vitestTmp')) - } - if (promises.has(cachePath)) { - await promises.get(cachePath) - return getCachedResult(result, cachePath) + const returnResult = 'code' in result + ? getCachedResult(result, cachePath) + : result + + if (saveCachePromises.has(cachePath)) { + await saveCachePromises.get(cachePath) + return returnResult } - promises.set( - cachePath, - fsCache.saveCachedModule(cachePath, result) - .finally(() => { - Reflect.set(transformResult, '_vitestTmp', cachePath) - promises.delete(cachePath) + const savePromise = this.fsCache + .saveCachedModule(cachePath, result) + .finally(() => { + saveCachePromises.delete(cachePath) + }) + + saveCachePromises.set(cachePath, savePromise) + await savePromise + + return returnResult + } + + private readFileConcurrently(file: string): Promise { + if (!readFilePromises.has(file)) { + readFilePromises.set( + file, + readFile(file, 'utf-8').finally(() => { + readFilePromises.delete(file) }), - ) - await promises.get(cachePath) - return getCachedResult(result, cachePath) + ) + } + return readFilePromises.get(file)! } } +interface DumpOptions { + dumpFolder?: string + readFromDump?: boolean +} + +export interface VitestFetchFunction { + ( + url: string, + importer: string | undefined, + environment: DevEnvironment, + cacheFs: boolean, + options?: FetchFunctionOptions + ): Promise +} + +export function createFetchModuleFunction( + resolver: VitestResolver, + config: ResolvedConfig, + _tmpDir: string = join(tmpdir(), nanoid()), + _dump: DumpOptions, + _fsCacheKey: string, +): VitestFetchFunction { + const fetcher = new ModuleFetcher(resolver, config) + return (url, importer, environment, cacheFs, options) => + fetcher.fetch(url, importer, environment, cacheFs, options) +} + let SOURCEMAPPING_URL = 'sourceMa' SOURCEMAPPING_URL += 'ppingURL' @@ -194,7 +230,6 @@ function processResultSource(environment: DevEnvironment, result: FetchResult): return { ...result, code: node?.transformResult?.code || result.code, - transformResult: node?.transformResult, } } @@ -258,6 +293,26 @@ function getCachedResult(result: Extract, tmp: st } } +const MODULE_RUNNER_SOURCEMAPPING_REGEXP = new RegExp( + `//# ${SOURCEMAPPING_URL}=data:application/json;base64,(.+)`, +) + +function extractSourceMap(code: string): null | Rollup.SourceMap { + const pattern = `//# ${SOURCEMAPPING_URL}=data:application/json;base64,` + const lastIndex = code.lastIndexOf(pattern) + if (lastIndex === -1) { + return null + } + + const mapString = MODULE_RUNNER_SOURCEMAPPING_REGEXP.exec( + code.slice(lastIndex), + )?.[1] + if (!mapString) { + return null + } + return JSON.parse(Buffer.from(mapString, 'base64').toString('utf-8')) +} + // serialize rollup error on server to preserve details as a test error export function handleRollupError(e: unknown): never { if ( @@ -280,23 +335,3 @@ export function handleRollupError(e: unknown): never { } throw e } - -const MODULE_RUNNER_SOURCEMAPPING_REGEXP = new RegExp( - `//# ${SOURCEMAPPING_URL}=data:application/json;base64,(.+)`, -) - -function extractSourceMap(code: string): null | Rollup.SourceMap { - const pattern = `//# ${SOURCEMAPPING_URL}=data:application/json;base64,` - const lastIndex = code.lastIndexOf(pattern) - if (lastIndex === -1) { - return null - } - - const mapString = MODULE_RUNNER_SOURCEMAPPING_REGEXP.exec( - code.slice(lastIndex), - )?.[1] - if (!mapString) { - return null - } - return JSON.parse(Buffer.from(mapString, 'base64').toString('utf-8')) -} diff --git a/packages/vitest/src/node/resolver.ts b/packages/vitest/src/node/resolver.ts index fd6a3b71cdd4..7c4813a996f1 100644 --- a/packages/vitest/src/node/resolver.ts +++ b/packages/vitest/src/node/resolver.ts @@ -13,8 +13,17 @@ export class VitestResolver { private externalizeCache = new Map>() constructor(cacheDir: string, config: ResolvedConfig) { + // sorting to make cache consistent + const inline = config.server.deps?.inline + if (Array.isArray(inline)) { + inline.sort() + } + const external = config.server.deps?.external + if (Array.isArray(external)) { + external.sort() + } this.options = { - moduleDirectories: config.deps.moduleDirectories, + moduleDirectories: config.deps.moduleDirectories?.sort(), inlineFiles: config.setupFiles.flatMap((file) => { if (file.startsWith('file://')) { return file @@ -23,8 +32,8 @@ export class VitestResolver { return [resolvedId, pathToFileURL(resolvedId).href] }), cacheDir, - inline: config.server.deps?.inline, - external: config.server.deps?.external, + inline, + external, } } From e556995e2dbecbfc802330225cf7b94245843e22 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 12:43:04 +0100 Subject: [PATCH 04/53] chore: early cache check --- packages/vitest/src/node/environments/fetchModule.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 55600d290f38..d164fb5f99bc 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -10,7 +10,7 @@ import { join } from 'pathe' import { fetchModule } from 'vite' import { FileSystemModuleCache } from '../cache/fs' -const saveCachePromises = new Map>() +const saveCachePromises = new Map>() const readFilePromises = new Map>() class ModuleFetcher { @@ -65,6 +65,10 @@ class ModuleFetcher { fileContent, ) + if (saveCachePromises.has(cachePath)) { + return saveCachePromises.get(cachePath)! + } + const cachedModule = await this.getCachedModule(cachePath, moduleGraphModule) if (cachedModule) { return cachedModule @@ -158,6 +162,7 @@ class ModuleFetcher { const savePromise = this.fsCache .saveCachedModule(cachePath, result) + .then(() => result) .finally(() => { saveCachePromises.delete(cachePath) }) From 87cf02092e3a58f7128fbf1c15b65e3b2ab3d2cd Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 13:10:15 +0100 Subject: [PATCH 05/53] chore: move fs to project --- packages/vitest/src/node/core.ts | 33 ++++++++++++------- .../src/node/environments/fetchModule.ts | 27 ++++++--------- packages/vitest/src/node/project.ts | 17 ++++++---- 3 files changed, 42 insertions(+), 35 deletions(-) diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 9fc302aadbf8..b3b69dc7cc25 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -15,7 +15,7 @@ import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage import type { Reporter } from './types/reporter' import type { TestRunResult } from './types/tests' import os, { tmpdir } from 'node:os' -import { generateHash, getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils' +import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils' import { SnapshotManager } from '@vitest/snapshot/manager' import { deepClone, deepMerge, nanoid, noop, toArray } from '@vitest/utils/helpers' import { join, normalize, relative } from 'pathe' @@ -27,6 +27,7 @@ import { convertTasksToEvents } from '../utils/tasks' import { astCollectTests, createFailedFileTask } from './ast-collect' import { BrowserSessions } from './browser/sessions' import { VitestCache } from './cache' +import { FileSystemModuleCache } from './cache/fs' import { resolveConfig } from './config/resolveConfig' import { getCoverageProvider } from './coverage' import { createFetchModuleFunction } from './environments/fetchModule' @@ -108,6 +109,7 @@ export class Vitest { /** @internal */ _testRun: TestRun = undefined! /** @internal */ _resolver!: VitestResolver /** @internal */ _fetcher!: VitestFetchFunction + /** @internal */ _fsCache!: FileSystemModuleCache /** @internal */ _tmpDir = join(tmpdir(), nanoid()) private isFirstRun = true @@ -216,17 +218,19 @@ export class Vitest { } this._resolver = new VitestResolver(server.config.cacheDir, resolved) + this._fsCache = new FileSystemModuleCache(this._config.cache !== false) this._fetcher = createFetchModuleFunction( this._resolver, this._config, - this._tmpDir, - { - dumpFolder: this.config.dumpDir, - readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, - }, - generateHash( - this._config!.root + this._config!.name, - ), + this._fsCache, + // this._tmpDir, + // { + // dumpFolder: this.config.dumpDir, + // readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, + // }, + // generateHash( + // this._config!.root + this._config!.name, + // ), ) const environment = server.environments.__vitest__ this.runner = new ServerModuleRunner( @@ -1108,15 +1112,20 @@ export class Vitest { * Invalidate a file in all projects. */ public invalidateFile(filepath: string): void { - this.projects.forEach(({ vite, browser }) => { + this.projects.forEach(({ vite, browser, _fsCache }) => { const environments = [ ...Object.values(vite.environments), ...Object.values(browser?.vite.environments || {}), ] - environments.forEach(({ moduleGraph }) => { + environments.forEach((environment) => { + const { moduleGraph } = environment const modules = moduleGraph.getModulesByFile(filepath) - modules?.forEach(module => moduleGraph.invalidateModule(module)) + if (!modules) { + return + } + + modules.forEach(module => moduleGraph.invalidateModule(module)) }) }) } diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index d164fb5f99bc..556339a924d3 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -1,27 +1,22 @@ import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, TransformResult } from 'vite' import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' +import type { FileSystemModuleCache } from '../cache/fs' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' import { readFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { isExternalUrl, nanoid, unwrapId } from '@vitest/utils/helpers' -import { join } from 'pathe' +import { isExternalUrl, unwrapId } from '@vitest/utils/helpers' import { fetchModule } from 'vite' -import { FileSystemModuleCache } from '../cache/fs' const saveCachePromises = new Map>() const readFilePromises = new Map>() class ModuleFetcher { - private fsCache: FileSystemModuleCache - constructor( private resolver: VitestResolver, private config: ResolvedConfig, - ) { - this.fsCache = new FileSystemModuleCache(config.cache !== false) - } + private fsCache: FileSystemModuleCache, + ) {} async fetch( url: string, @@ -186,10 +181,10 @@ class ModuleFetcher { } } -interface DumpOptions { - dumpFolder?: string - readFromDump?: boolean -} +// interface DumpOptions { +// dumpFolder?: string +// readFromDump?: boolean +// } export interface VitestFetchFunction { ( @@ -204,11 +199,9 @@ export interface VitestFetchFunction { export function createFetchModuleFunction( resolver: VitestResolver, config: ResolvedConfig, - _tmpDir: string = join(tmpdir(), nanoid()), - _dump: DumpOptions, - _fsCacheKey: string, + fsCache: FileSystemModuleCache, ): VitestFetchFunction { - const fetcher = new ModuleFetcher(resolver, config) + const fetcher = new ModuleFetcher(resolver, config, fsCache) return (url, importer, environment, cacheFs, options) => fetcher.fetch(url, importer, environment, cacheFs, options) } diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index ba4f17abe085..85273591ae49 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -24,6 +24,7 @@ import pm from 'picomatch' import { glob } from 'tinyglobby' import { setup } from '../api/setup' import { createDefinesScript } from '../utils/config-helpers' +import { FileSystemModuleCache } from './cache/fs' import { isBrowserEnabled, resolveConfig } from './config/resolveConfig' import { serializeConfig } from './config/serializeConfig' import { createFetchModuleFunction } from './environments/fetchModule' @@ -65,6 +66,7 @@ export class TestProject { /** @internal */ _hash?: string /** @internal */ _resolver!: VitestResolver /** @internal */ _fetcher!: VitestFetchFunction + /** @internal */ _fsCache!: FileSystemModuleCache /** @internal */ _serializedDefines?: string /** @inetrnal */ testFilesList: string[] | null = null @@ -550,15 +552,17 @@ export class TestProject { this._resolver = new VitestResolver(server.config.cacheDir, this._config) this._vite = server this._serializedDefines = createDefinesScript(server.config.define) + this._fsCache = new FileSystemModuleCache(this._config.cache !== false) this._fetcher = createFetchModuleFunction( this._resolver, this._config, - this.tmpDir, - { - dumpFolder: this.config.dumpDir, - readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, - }, - this._hash!, + this._fsCache, + // this.tmpDir, + // { + // dumpFolder: this.config.dumpDir, + // readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, + // }, + // this._hash!, ) const environment = server.environments.__vitest__ @@ -623,6 +627,7 @@ export class TestProject { project._config = vitest.config project._resolver = vitest._resolver project._fetcher = vitest._fetcher + project._fsCache = vitest._fsCache project._serializedDefines = createDefinesScript(vitest.vite.config.define) project._setHash() project._provideObject(vitest.config.provide) From eb8696490fac03bf0d42acab73f8bf9e1ec85470 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 14:09:37 +0100 Subject: [PATCH 06/53] chore: rename file --- packages/vitest/src/node/cache/{fs.ts => fsCache.ts} | 0 packages/vitest/src/node/core.ts | 2 +- packages/vitest/src/node/environments/fetchModule.ts | 2 +- packages/vitest/src/node/project.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/vitest/src/node/cache/{fs.ts => fsCache.ts} (100%) diff --git a/packages/vitest/src/node/cache/fs.ts b/packages/vitest/src/node/cache/fsCache.ts similarity index 100% rename from packages/vitest/src/node/cache/fs.ts rename to packages/vitest/src/node/cache/fsCache.ts diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index b3b69dc7cc25..6d245c3a9def 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -27,7 +27,7 @@ import { convertTasksToEvents } from '../utils/tasks' import { astCollectTests, createFailedFileTask } from './ast-collect' import { BrowserSessions } from './browser/sessions' import { VitestCache } from './cache' -import { FileSystemModuleCache } from './cache/fs' +import { FileSystemModuleCache } from './cache/fsCache' import { resolveConfig } from './config/resolveConfig' import { getCoverageProvider } from './coverage' import { createFetchModuleFunction } from './environments/fetchModule' diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 556339a924d3..c6b76bd576ce 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -1,7 +1,7 @@ import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, TransformResult } from 'vite' import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' -import type { FileSystemModuleCache } from '../cache/fs' +import type { FileSystemModuleCache } from '../cache/fsCache' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' import { readFile } from 'node:fs/promises' diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 85273591ae49..dc90f71cefbf 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -24,7 +24,7 @@ import pm from 'picomatch' import { glob } from 'tinyglobby' import { setup } from '../api/setup' import { createDefinesScript } from '../utils/config-helpers' -import { FileSystemModuleCache } from './cache/fs' +import { FileSystemModuleCache } from './cache/fsCache' import { isBrowserEnabled, resolveConfig } from './config/resolveConfig' import { serializeConfig } from './config/serializeConfig' import { createFetchModuleFunction } from './environments/fetchModule' From bcd91937b62190bd9d845f1f46486deb34551184 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 14:42:33 +0100 Subject: [PATCH 07/53] perf: don't read multiple times, respect project-level cacheFs --- packages/vitest/src/node/cache/fsCache.ts | 28 ++------ packages/vitest/src/node/core.ts | 4 +- .../src/node/environments/fetchModule.ts | 66 ++++++++++++++----- packages/vitest/src/node/plugins/workspace.ts | 15 +++-- packages/vitest/src/node/project.ts | 4 +- 5 files changed, 70 insertions(+), 47 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 5d0bb52753d8..df40366e339b 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -17,32 +17,28 @@ export class FileSystemModuleCache { private fsCacheRoot: string private version = '1.0.0' - constructor(private _enabled: boolean) { + constructor() { this.fsCacheRoot = join(tmpdir(), 'vitest') - if (_enabled && !existsSync(this.fsCacheRoot)) { + if (!existsSync(this.fsCacheRoot)) { mkdirSync(this.fsCacheRoot) } } - public isEnabled(): boolean { - return this._enabled - } - async getCachedModule( cachedFilePath: string, ): Promise { - if (!this.isEnabled() || !existsSync(cachedFilePath)) { + if (!existsSync(cachedFilePath)) { return } - const content = await readFile(cachedFilePath, 'utf-8') - const matchIndex = content.lastIndexOf('\n//') + const code = await readFile(cachedFilePath, 'utf-8') + const matchIndex = code.lastIndexOf('\n//') if (matchIndex === -1) { return } - const meta = JSON.parse(content.slice(matchIndex + 4)) + const meta = JSON.parse(code.slice(matchIndex + 4)) if (meta.externalize) { return { externalize: meta.externalize, type: meta.type } } @@ -51,9 +47,7 @@ export class FileSystemModuleCache { id: meta.id, url: meta.url, file: meta.file, - // TODO: if cacheFs is false, return with `code` - tmp: cachedFilePath, - cached: true, + code, invalidate: false, } } @@ -62,10 +56,6 @@ export class FileSystemModuleCache { cachedFilePath: string, fetchResult: T, ): Promise { - if (!this.isEnabled()) { - return - } - // TODO: also keep dependencies, so they can populate the module graph on the next run if ('externalize' in fetchResult) { @@ -89,10 +79,6 @@ export class FileSystemModuleCache { id: string, fileContent: string, ): string { - if (!this.isEnabled()) { - return id - } - const config = environment.config const viteConfig = JSON.stringify( { diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 6d245c3a9def..4b77074e2aa5 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -218,12 +218,12 @@ export class Vitest { } this._resolver = new VitestResolver(server.config.cacheDir, resolved) - this._fsCache = new FileSystemModuleCache(this._config.cache !== false) + this._fsCache = new FileSystemModuleCache() this._fetcher = createFetchModuleFunction( this._resolver, this._config, this._fsCache, - // this._tmpDir, + this._tmpDir, // { // dumpFolder: this.config.dumpDir, // readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index c6b76bd576ce..3e235fcc84b3 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -4,25 +4,34 @@ import type { FetchCachedFileSystemResult } from '../../types/general' import type { FileSystemModuleCache } from '../cache/fsCache' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' +import { existsSync, mkdirSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { isExternalUrl, unwrapId } from '@vitest/utils/helpers' +import { join } from 'pathe' import { fetchModule } from 'vite' +import { hash } from '../hash' const saveCachePromises = new Map>() const readFilePromises = new Map>() class ModuleFetcher { + private tmpDirectories = new Set() + private fsCacheEnabled: boolean + constructor( private resolver: VitestResolver, private config: ResolvedConfig, private fsCache: FileSystemModuleCache, - ) {} + private tmpProjectDir: string, + ) { + this.fsCacheEnabled = config.cache !== false + } async fetch( url: string, importer: string | undefined, environment: DevEnvironment, - cacheFs: boolean, + makeTmpCopies?: boolean, options?: FetchFunctionOptions, ): Promise { if (url.startsWith('data:')) { @@ -46,9 +55,35 @@ class ModuleFetcher { return { cache: true } } - // caching can be disabled on a project-per-project or file-per-file basis - if (!cacheFs || !this.fsCache.isEnabled()) { - return this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) + // full fs caching is disabled, but we still want to keep tmp files if makeTmpCopies is enabled + // this is primarily used by the forks pool to avoid using process.send(bigBuffer) + if (!this.fsCacheEnabled) { + const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) + if (!makeTmpCopies || !('code' in result)) { + return result + } + + const transformResult = moduleGraphModule.transformResult + const tmpPath = transformResult && Reflect.get(transformResult, '_vitest_tmp') + if (typeof tmpPath === 'string') { + return getCachedResult(result, tmpPath) + } + + const tmpDir = join(this.tmpProjectDir, environment.name) + if (!this.tmpDirectories.has(tmpDir)) { + if (!existsSync(tmpDir)) { + mkdirSync(tmpDir, { recursive: true }) + } + this.tmpDirectories.add(tmpDir) + } + + const tmpFile = join(tmpDir, hash('sha1', result.id, 'hex')) + return this.cacheResult(result, tmpFile).then((result) => { + if (transformResult) { + Reflect.set(transformResult, '_vitest_tmp', tmpFile) + } + return result + }) } const fileContent = await this.readFileContentToCache(environment, moduleGraphModule) @@ -98,16 +133,16 @@ class ModuleFetcher { ): Promise { const cachedModule = await this.fsCache.getCachedModule(cachePath) - if (cachedModule && 'tmp' in cachedModule) { + if (cachedModule && 'code' in cachedModule) { // keep the module graph in sync if (!moduleGraphModule.transformResult) { - const code = await readFile(cachedModule.tmp, 'utf-8') - const map = extractSourceMap(code) + const map = extractSourceMap(cachedModule.code) if (map && cachedModule.file) { map.file = cachedModule.file } - moduleGraphModule.transformResult = { code, map } + moduleGraphModule.transformResult = { code: cachedModule.code, map } } + return getCachedResult(cachedModule, cachePath) } return cachedModule @@ -142,10 +177,6 @@ class ModuleFetcher { result: FetchResult, cachePath: string, ): Promise { - if (!this.fsCache.isEnabled()) { - return result - } - const returnResult = 'code' in result ? getCachedResult(result, cachePath) : result @@ -191,7 +222,7 @@ export interface VitestFetchFunction { url: string, importer: string | undefined, environment: DevEnvironment, - cacheFs: boolean, + cacheFs?: boolean, options?: FetchFunctionOptions ): Promise } @@ -200,8 +231,9 @@ export function createFetchModuleFunction( resolver: VitestResolver, config: ResolvedConfig, fsCache: FileSystemModuleCache, + tmpProjectDir: string, ): VitestFetchFunction { - const fetcher = new ModuleFetcher(resolver, config, fsCache) + const fetcher = new ModuleFetcher(resolver, config, fsCache, tmpProjectDir) return (url, importer, environment, cacheFs, options) => fetcher.fetch(url, importer, environment, cacheFs, options) } @@ -211,9 +243,7 @@ SOURCEMAPPING_URL += 'ppingURL' const MODULE_RUNNER_SOURCEMAPPING_SOURCE = '//# sourceMappingSource=vite-generated' -function processResultSource(environment: DevEnvironment, result: FetchResult): FetchResult & { - transformResult?: TransformResult | null -} { +function processResultSource(environment: DevEnvironment, result: FetchResult): FetchResult { if (!('code' in result)) { return result } diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts index 8d467d0e6290..708b107a3c72 100644 --- a/packages/vitest/src/node/plugins/workspace.ts +++ b/packages/vitest/src/node/plugins/workspace.ts @@ -1,6 +1,6 @@ import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite' import type { TestProject } from '../project' -import type { BrowserConfigOptions, ResolvedConfig, TestProjectInlineConfiguration } from '../types/config' +import type { BrowserConfigOptions, ResolvedConfig, TestProjectInlineConfiguration, UserConfig } from '../types/config' import { existsSync, readFileSync } from 'node:fs' import { deepMerge } from '@vitest/utils/helpers' import { basename, dirname, relative, resolve } from 'pathe' @@ -93,6 +93,15 @@ export function WorkspaceVitestPlugin( } } + const vitestConfig: UserConfig = { + name: { label: name, color }, + } + + // always inherit the global `cache` value + if (testConfig.cache == null && project.vitest.config.cache === false) { + vitestConfig.cache = false + } + return { base: '/', environments: { @@ -100,9 +109,7 @@ export function WorkspaceVitestPlugin( dev: {}, }, }, - test: { - name: { label: name, color }, - }, + test: vitestConfig, } }, }, diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index dc90f71cefbf..0f710d1b16ea 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -552,12 +552,12 @@ export class TestProject { this._resolver = new VitestResolver(server.config.cacheDir, this._config) this._vite = server this._serializedDefines = createDefinesScript(server.config.define) - this._fsCache = new FileSystemModuleCache(this._config.cache !== false) + this._fsCache = new FileSystemModuleCache() this._fetcher = createFetchModuleFunction( this._resolver, this._config, this._fsCache, - // this.tmpDir, + this.tmpDir, // { // dumpFolder: this.config.dumpDir, // readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, From 2d845ea05f64f68c65a456f3e661e78b4b880c14 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 14:49:46 +0100 Subject: [PATCH 08/53] chore: enable caching via the experimental flag --- packages/vitest/src/node/cli/cli-config.ts | 12 +++++++++--- .../vitest/src/node/environments/fetchModule.ts | 2 +- packages/vitest/src/node/types/config.ts | 17 ++++++++++++----- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 13465ede3a3f..5b1b13513059 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -774,10 +774,16 @@ export const cliOptionsConfig: VitestCLIOptions = { return value }, }, - clearCache: { - description: 'Clear all caches.', - }, + experimental: { + description: 'Experimental features.', + argument: '', + subcommands: { + fsModuleCache: { + description: 'Enable caching of modules on the file system between reruns.', + }, + }, + }, // disable CLI options cliExclude: null, server: null, diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 3e235fcc84b3..b44b6e1f1a60 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -24,7 +24,7 @@ class ModuleFetcher { private fsCache: FileSystemModuleCache, private tmpProjectDir: string, ) { - this.fsCacheEnabled = config.cache !== false + this.fsCacheEnabled = config.experimental?.fsModuleCache === true } async fetch( diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 263e58c2f71c..2d05645e48cf 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -825,6 +825,18 @@ export interface InlineConfig { * @default '.vitest-attachments' */ attachmentsDir?: string + + /** + * Experimental features + * + * @experimental + */ + experimental?: { + /** + * Enable caching of modules on the file system between reruns. + */ + fsModuleCache?: boolean + } } export interface TypecheckConfig { @@ -954,11 +966,6 @@ export interface UserConfig extends InlineConfig { * @default '.vitest-reports' */ mergeReports?: string - - /** - * Clear all caches - */ - clearCache?: boolean } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void From f9fb30d8fc912e282ef211baff27268a7a24e4aa Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 15:13:47 +0100 Subject: [PATCH 09/53] chore: inherit fsModuleCache --- packages/vitest/src/node/config/resolveConfig.ts | 2 ++ packages/vitest/src/node/plugins/workspace.ts | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 81780c83e5b1..391116b46dda 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -797,6 +797,8 @@ export function resolveConfig( resolved.testTimeout ??= resolved.browser.enabled ? 30_000 : 5_000 resolved.hookTimeout ??= resolved.browser.enabled ? 30_000 : 10_000 + resolved.experimental ??= {} + return resolved } diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts index 708b107a3c72..4210f37175d4 100644 --- a/packages/vitest/src/node/plugins/workspace.ts +++ b/packages/vitest/src/node/plugins/workspace.ts @@ -97,9 +97,10 @@ export function WorkspaceVitestPlugin( name: { label: name, color }, } - // always inherit the global `cache` value - if (testConfig.cache == null && project.vitest.config.cache === false) { - vitestConfig.cache = false + // always inherit the global `fsModuleCache` value even without `extends: true` + if (testConfig.experimental?.fsModuleCache == null && project.vitest.config.experimental?.fsModuleCache !== null) { + vitestConfig.experimental ??= {} + vitestConfig.experimental.fsModuleCache = project.vitest.config.experimental.fsModuleCache } return { From 6060ce94f94d108a441a82e86ebaba36d96ab680 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 15:23:52 +0100 Subject: [PATCH 10/53] chore: cleanup --- docs/config/server.md | 22 ------------------- .../vitest/src/node/config/serializeConfig.ts | 4 +++- packages/vitest/src/node/core.ts | 7 ------ packages/vitest/src/node/project.ts | 5 ----- packages/vitest/src/runtime/config.ts | 4 +++- test/test-utils/index.ts | 5 ++++- 6 files changed, 10 insertions(+), 37 deletions(-) diff --git a/docs/config/server.md b/docs/config/server.md index 40ad74e986b3..a999b7cfc632 100644 --- a/docs/config/server.md +++ b/docs/config/server.md @@ -71,25 +71,3 @@ If a `RegExp` is provided, it is matched against the full file path. When a dependency is a valid ESM package, try to guess the cjs version based on the path. This might be helpful, if a dependency has the wrong ESM file. This might potentially cause some misalignment if a package has different logic in ESM and CJS mode. - -## debug - -### dump - -- **Type:** `string | boolean` -- **Default:** `false` - -The folder where Vitest stores the contents of inlined test files that can be inspected manually. - -If set to `true`, Vitest dumps the files inside the `.vitest-dump` folder relative to the root of the project. - -You can also use `VITEST_DEBUG_DUMP` env variable to enable this conditionally. - -### load - -- **Type:** `boolean` -- **Default:** `false` - -Read files from the dump instead of transforming them. If dump is disabled, this does nothing. - -You can also use `VITEST_DEBUG_LOAD_DUMP` env variable to enable this conditionally. diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index aa960381c27b..672b64532555 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -130,6 +130,8 @@ export function serializeConfig(project: TestProject): SerializedConfig { serializedDefines: config.browser.enabled ? '' : project._serializedDefines || '', - cache: config.cache !== false, + experimental: { + fsModuleCache: config.experimental.fsModuleCache ?? false, + }, } } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 4b77074e2aa5..5b718c0b94f4 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -224,13 +224,6 @@ export class Vitest { this._config, this._fsCache, this._tmpDir, - // { - // dumpFolder: this.config.dumpDir, - // readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, - // }, - // generateHash( - // this._config!.root + this._config!.name, - // ), ) const environment = server.environments.__vitest__ this.runner = new ServerModuleRunner( diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 0f710d1b16ea..10e92557fdb0 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -558,11 +558,6 @@ export class TestProject { this._config, this._fsCache, this.tmpDir, - // { - // dumpFolder: this.config.dumpDir, - // readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, - // }, - // this._hash!, ) const environment = server.environments.__vitest__ diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index afec6c0ecc7e..a71095a6e382 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -117,7 +117,9 @@ export interface SerializedConfig { includeSamples: boolean } | undefined serializedDefines: string - cache: boolean + experimental: { + fsModuleCache: boolean + } } export interface SerializedCoverageConfig { diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 9c93a3ed37b3..8e9f00152bef 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -91,7 +91,10 @@ export async function runVitest( ...rest.env, }, // override cache config with the one that was used to run `vitest` formt the CLI - ...(currentConfig.cache === false ? { cache: false } : {}), + experimental: { + fsModuleCache: currentConfig.experimental.fsModuleCache, + ...rest.experimental, + }, }, { ...viteOverrides, server: { From d44708c33d7eb38985b3fa80de9709214593d9f2 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 15:50:12 +0100 Subject: [PATCH 11/53] feat: add --clearCache --- docs/config/experimental.md | 15 +++++++++++++++ packages/vitest/src/node/cache/fsCache.ts | 12 ++++++++++-- packages/vitest/src/node/cache/index.ts | 5 +++-- packages/vitest/src/node/cache/results.ts | 16 +++++++++++++--- packages/vitest/src/node/cli/cac.ts | 4 ++++ packages/vitest/src/node/cli/cli-api.ts | 5 ++++- packages/vitest/src/node/cli/cli-config.ts | 3 +++ packages/vitest/src/node/core.ts | 15 +++++++++++++-- packages/vitest/src/node/project.ts | 2 +- packages/vitest/src/node/types/config.ts | 5 +++++ 10 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 docs/config/experimental.md diff --git a/docs/config/experimental.md b/docs/config/experimental.md new file mode 100644 index 000000000000..f014b6501b4a --- /dev/null +++ b/docs/config/experimental.md @@ -0,0 +1,15 @@ +--- +title: experimental | Config +outline: deep +--- + +# experimental + +## fsModuleCache 4.0.10 + +- **Type:** `boolean` +- **Default:** `false` + +Enabling this option allows Vitest to keep cached modules on the file system, making tests run faster between reruns. + +You can delete the old cache by running `vitest --clearCache`. diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index df40366e339b..00c0fd5d3448 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -1,10 +1,11 @@ import type { DevEnvironment, FetchResult } from 'vite' import type { FetchCachedFileSystemResult } from '../../types/general' +import type { Logger } from '../logger' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' import crypto from 'node:crypto' import { existsSync, mkdirSync } from 'node:fs' -import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' +import { readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'pathe' import { version as viteVersion } from 'vite' @@ -17,7 +18,9 @@ export class FileSystemModuleCache { private fsCacheRoot: string private version = '1.0.0' - constructor() { + // TODO: keep track of stale cache somehow? maybe in a meta file? + + constructor(private logger: Logger) { this.fsCacheRoot = join(tmpdir(), 'vitest') if (!existsSync(this.fsCacheRoot)) { @@ -25,6 +28,11 @@ export class FileSystemModuleCache { } } + async clearCache(): Promise { + await rm(this.fsCacheRoot, { force: true, recursive: true }) + this.logger.log('[cache] cleared fs module cache at', this.fsCacheRoot) + } + async getCachedModule( cachedFilePath: string, ): Promise { diff --git a/packages/vitest/src/node/cache/index.ts b/packages/vitest/src/node/cache/index.ts index 6abbd65f253d..296a0d0d1853 100644 --- a/packages/vitest/src/node/cache/index.ts +++ b/packages/vitest/src/node/cache/index.ts @@ -1,3 +1,4 @@ +import type { Logger } from '../logger' import type { SuiteResultCache } from './results' import { slash } from '@vitest/utils/helpers' import { resolve } from 'pathe' @@ -9,8 +10,8 @@ export class VitestCache { results: ResultsCache stats: FilesStatsCache = new FilesStatsCache() - constructor(version: string) { - this.results = new ResultsCache(version) + constructor(logger: Logger) { + this.results = new ResultsCache(logger) } getFileTestResults(key: string): SuiteResultCache | undefined { diff --git a/packages/vitest/src/node/cache/results.ts b/packages/vitest/src/node/cache/results.ts index 5b987c8ca285..44227237488f 100644 --- a/packages/vitest/src/node/cache/results.ts +++ b/packages/vitest/src/node/cache/results.ts @@ -1,7 +1,10 @@ import type { File } from '@vitest/runner' +import type { Logger } from '../logger' import type { ResolvedConfig } from '../types/config' -import fs from 'node:fs' +import fs, { existsSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { dirname, relative, resolve } from 'pathe' +import { Vitest } from '../core' export interface SuiteResultCache { failed: boolean @@ -15,8 +18,8 @@ export class ResultsCache { private version: string private root = '/' - constructor(version: string) { - this.version = version + constructor(private logger: Logger) { + this.version = Vitest.version } public getCachePath(): string | null { @@ -34,6 +37,13 @@ export class ResultsCache { return this.cache.get(key) } + async clearCache(): Promise { + if (this.cachePath && existsSync(this.cachePath)) { + await rm(this.cachePath, { force: true, recursive: true }) + this.logger.log('[cache] cleared results cache at', this.cachePath) + } + } + async readFromCache(): Promise { if (!this.cachePath) { return diff --git a/packages/vitest/src/node/cli/cac.ts b/packages/vitest/src/node/cli/cac.ts index 826b15a45d3d..3cd29fcbb15e 100644 --- a/packages/vitest/src/node/cli/cac.ts +++ b/packages/vitest/src/node/cli/cac.ts @@ -288,6 +288,10 @@ function normalizeCliOptions(cliFilters: string[], argv: CliOptions): CliOptions if (typeof argv.typecheck?.only === 'boolean') { argv.typecheck.enabled ??= true } + if (argv.clearCache) { + argv.watch = false + argv.run = true + } return argv } diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index 56170f0b8abd..9c1d0e1179b7 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -91,7 +91,10 @@ export async function startVitest( }) try { - if (ctx.config.mergeReports) { + if (ctx.config.clearCache) { + await ctx.clearCache() + } + else if (ctx.config.mergeReports) { await ctx.mergeReports() } else if (ctx.config.standalone) { diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 5b1b13513059..d77ecd54d931 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -774,6 +774,9 @@ export const cliOptionsConfig: VitestCLIOptions = { return value }, }, + clearCache: { + description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', + }, experimental: { description: 'Experimental features.', diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 5b718c0b94f4..abc78d3f3d44 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -209,7 +209,7 @@ export class Vitest { this._state = new StateManager({ onUnhandledError: resolved.onUnhandledError, }) - this._cache = new VitestCache(this.version) + this._cache = new VitestCache(this.logger) this._snapshot = new SnapshotManager({ ...resolved.snapshotOptions }) this._testRun = new TestRun(this) @@ -218,7 +218,7 @@ export class Vitest { } this._resolver = new VitestResolver(server.config.cacheDir, resolved) - this._fsCache = new FileSystemModuleCache() + this._fsCache = new FileSystemModuleCache(this.logger) this._fetcher = createFetchModuleFunction( this._resolver, this._config, @@ -482,6 +482,17 @@ export class Vitest { return this._coverageProvider } + public async clearCache(): Promise { + await this.cache.results.clearCache() + const projects = [...this.projects] + if (this.coreWorkspaceProject && !projects.includes(this.coreWorkspaceProject)) { + projects.push(this.coreWorkspaceProject) + } + await Promise.all( + projects.map(p => p._fsCache.clearCache()), + ) + } + /** * Merge reports from multiple runs located in the specified directory (value from `--merge-reports` if not specified). */ diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 10e92557fdb0..e94176f28475 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -552,7 +552,7 @@ export class TestProject { this._resolver = new VitestResolver(server.config.cacheDir, this._config) this._vite = server this._serializedDefines = createDefinesScript(server.config.define) - this._fsCache = new FileSystemModuleCache() + this._fsCache = new FileSystemModuleCache(this.vitest.logger) this._fetcher = createFetchModuleFunction( this._resolver, this._config, diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 2d05645e48cf..372f4e7413de 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -966,6 +966,11 @@ export interface UserConfig extends InlineConfig { * @default '.vitest-reports' */ mergeReports?: string + + /** + * Delete all Vitest caches, including `experimental.fsModuleCache`. + */ + clearCache?: boolean } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void From bd0ef3598f861b3bc12b61eca36382ec08d3538f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 15:52:41 +0100 Subject: [PATCH 12/53] chore: cleanup --- docs/.vitepress/scripts/cli-generator.ts | 1 + packages/vitest/src/node/types/config.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/.vitepress/scripts/cli-generator.ts b/docs/.vitepress/scripts/cli-generator.ts index b0c4dcd6f38e..1e82a8bd5d2e 100644 --- a/docs/.vitepress/scripts/cli-generator.ts +++ b/docs/.vitepress/scripts/cli-generator.ts @@ -41,6 +41,7 @@ const skipConfig = new Set([ 'ui', 'browser.name', 'browser.fileParallelism', + 'clearCache', ]) function resolveOptions(options: CLIOptions, parentName?: string) { diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 372f4e7413de..15338a74d0f6 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -969,6 +969,7 @@ export interface UserConfig extends InlineConfig { /** * Delete all Vitest caches, including `experimental.fsModuleCache`. + * @experimental */ clearCache?: boolean } From 45f16c8e851f85a3fc10605f767e7ae49217467b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Nov 2025 16:00:42 +0100 Subject: [PATCH 13/53] docs: add section about the public method --- docs/api/advanced/vitest.md | 8 ++++++++ packages/vitest/src/node/cli/cli-api.ts | 2 +- packages/vitest/src/node/core.ts | 6 +++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/api/advanced/vitest.md b/docs/api/advanced/vitest.md index b49fc3505ade..37eb743abc7b 100644 --- a/docs/api/advanced/vitest.md +++ b/docs/api/advanced/vitest.md @@ -603,3 +603,11 @@ function experimental_parseSpecifications( ``` This method will [collect tests](#parsespecification) from an array of specifications. By default, Vitest will run only `os.availableParallelism()` number of specifications at a time to reduce the potential performance degradation. You can specify a different number in a second argument. + +## experimental_clearCache 4.0.10 experimental {#clearcache} + +```ts +function experimental_clearCache(): Promise +``` + +Deletes all Vitest caches, including [`experimental.fsModuleCache`](/config/experimental#fsmodulecache). diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index 9c1d0e1179b7..48b524e2233c 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -92,7 +92,7 @@ export async function startVitest( try { if (ctx.config.clearCache) { - await ctx.clearCache() + await ctx.experimental_clearCache() } else if (ctx.config.mergeReports) { await ctx.mergeReports() diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index abc78d3f3d44..67c8accaaf01 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -482,7 +482,11 @@ export class Vitest { return this._coverageProvider } - public async clearCache(): Promise { + /** + * Deletes all Vitest caches, including `experimental.fsModuleCache`. + * @experimental + */ + public async experimental_clearCache(): Promise { await this.cache.results.clearCache() const projects = [...this.projects] if (this.coreWorkspaceProject && !projects.includes(this.coreWorkspaceProject)) { From 96e2003640e25ff78870d78843ffae2a7fd92462 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 16:35:03 +0100 Subject: [PATCH 14/53] fix: return traces to fetchModule --- packages/vitest/src/node/core.ts | 1 + .../src/node/environments/fetchModule.ts | 69 ++++++++++++++++--- packages/vitest/src/node/project.ts | 1 + 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index c4c842d3c09d..a136e268fb18 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -232,6 +232,7 @@ export class Vitest { this._resolver, this._config, this._fsCache, + this._traces, this._tmpDir, ) const environment = server.environments.__vitest__ diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index b44b6e1f1a60..311c11041e1f 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -1,6 +1,8 @@ +import type { Span } from '@opentelemetry/api' import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, TransformResult } from 'vite' import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' +import type { OTELCarrier, Traces } from '../../utils/traces' import type { FileSystemModuleCache } from '../cache/fsCache' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' @@ -22,12 +24,14 @@ class ModuleFetcher { private resolver: VitestResolver, private config: ResolvedConfig, private fsCache: FileSystemModuleCache, + private traces: Traces, private tmpProjectDir: string, ) { this.fsCacheEnabled = config.experimental?.fsModuleCache === true } async fetch( + trace: Span, url: string, importer: string | undefined, environment: DevEnvironment, @@ -35,22 +39,29 @@ class ModuleFetcher { options?: FetchFunctionOptions, ): Promise { if (url.startsWith('data:')) { + trace.setAttribute('vitest.module.external', url) return { externalize: url, type: 'builtin' } } if (url === '/@vite/client' || url === '@vite/client') { + trace.setAttribute('vitest.module.external', url) return { externalize: '/@vite/client', type: 'module' } } const isFileUrl = url.startsWith('file://') if (isExternalUrl(url) && !isFileUrl) { + trace.setAttribute('vitest.module.external', url) return { externalize: url, type: 'network' } } const moduleGraphModule = await environment.moduleGraph.ensureEntryFromUrl(unwrapId(url)) const cached = !!moduleGraphModule.transformResult + if (moduleGraphModule.file) { + trace.setAttribute('code.file.path', moduleGraphModule.file) + } + if (options?.cached && cached) { return { cache: true } } @@ -59,6 +70,9 @@ class ModuleFetcher { // this is primarily used by the forks pool to avoid using process.send(bigBuffer) if (!this.fsCacheEnabled) { const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) + + this.recordResult(trace, result) + if (!makeTmpCopies || !('code' in result)) { return result } @@ -96,11 +110,15 @@ class ModuleFetcher { ) if (saveCachePromises.has(cachePath)) { - return saveCachePromises.get(cachePath)! + return saveCachePromises.get(cachePath)!.then((result) => { + this.recordResult(trace, result) + return result + }) } const cachedModule = await this.getCachedModule(cachePath, moduleGraphModule) if (cachedModule) { + this.recordResult(trace, cachedModule) return cachedModule } @@ -109,6 +127,29 @@ class ModuleFetcher { return this.cacheResult(result, cachePath) } + private recordResult(trace: Span, result: FetchResult | FetchCachedFileSystemResult): void { + if ('externalize' in result) { + trace.setAttributes({ + 'vitest.module.external': result.externalize, + 'vitest.fetched_module.type': result.type, + }) + } + if ('id' in result) { + trace.setAttributes({ + 'vitest.fetched_module.invalidate': result.invalidate, + 'vitest.fetched_module.id': result.id, + 'vitest.fetched_module.url': result.url, + 'vitest.fetched_module.cache': false, + }) + if (result.file) { + trace.setAttribute('code.file.path', result.file) + } + } + if ('code' in result) { + trace.setAttribute('vitest.fetched_module.code_length', result.code.length) + } + } + private async readFileContentToCache( environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode, @@ -212,18 +253,14 @@ class ModuleFetcher { } } -// interface DumpOptions { -// dumpFolder?: string -// readFromDump?: boolean -// } - export interface VitestFetchFunction { ( url: string, importer: string | undefined, environment: DevEnvironment, cacheFs?: boolean, - options?: FetchFunctionOptions + options?: FetchFunctionOptions, + otelCarrier?: OTELCarrier ): Promise } @@ -231,11 +268,23 @@ export function createFetchModuleFunction( resolver: VitestResolver, config: ResolvedConfig, fsCache: FileSystemModuleCache, + traces: Traces, tmpProjectDir: string, ): VitestFetchFunction { - const fetcher = new ModuleFetcher(resolver, config, fsCache, tmpProjectDir) - return (url, importer, environment, cacheFs, options) => - fetcher.fetch(url, importer, environment, cacheFs, options) + const fetcher = new ModuleFetcher(resolver, config, fsCache, traces, tmpProjectDir) + return async (url, importer, environment, cacheFs, options, otelCarrier) => { + await traces.waitInit() + const context = otelCarrier + ? traces.getContextFromCarrier(otelCarrier) + : undefined + return traces.$( + 'vitest.module.transform', + context + ? { context } + : {}, + span => fetcher.fetch(span, url, importer, environment, cacheFs, options), + ) + } } let SOURCEMAPPING_URL = 'sourceMa' diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index b75295d26551..0b20448b8416 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -566,6 +566,7 @@ export class TestProject { this._resolver, this._config, this._fsCache, + this.vitest._traces, this.tmpDir, ) From cea30f84c370f28e595422fd989deb5d22e6b903 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 17:16:19 +0100 Subject: [PATCH 15/53] perf: use hash function instead --- packages/vitest/src/node/cache/fsCache.ts | 36 +++++++++++++---------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 00c0fd5d3448..5876c6466ba0 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -1,15 +1,14 @@ import type { DevEnvironment, FetchResult } from 'vite' -import type { FetchCachedFileSystemResult } from '../../types/general' import type { Logger } from '../logger' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' -import crypto from 'node:crypto' import { existsSync, mkdirSync } from 'node:fs' import { readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'pathe' import { version as viteVersion } from 'vite' import { Vitest } from '../core' +import { hash } from '../hash' /** * @experimental @@ -33,9 +32,7 @@ export class FileSystemModuleCache { this.logger.log('[cache] cleared fs module cache at', this.fsCacheRoot) } - async getCachedModule( - cachedFilePath: string, - ): Promise { + async getCachedModule(cachedFilePath: string): Promise { if (!existsSync(cachedFilePath)) { return } @@ -88,16 +85,21 @@ export class FileSystemModuleCache { fileContent: string, ): string { const config = environment.config - const viteConfig = JSON.stringify( + const cacheConfig = JSON.stringify( { root: config.root, - base: config.base, + // at the moment, Vitest always forces base to be / + // base: config.base, mode: config.mode, consumer: config.consumer, resolve: config.resolve, + // plugins can have different options, so this is not the best key, + // but we canot access the options because there is no standard API for it plugins: config.plugins.map(p => p.name), environment: environment.name, + // this affects Vitest CSS plugin css: vitestConfig.css, + // this affect externalization resolver: resolver.options, }, (_, value) => { @@ -107,15 +109,17 @@ export class FileSystemModuleCache { return value }, ) - const cacheKey = crypto.createHash('sha1') - .update(id) - .update(fileContent) - .update(process.env.NODE_ENV ?? '') - .update(this.version) - .update(viteConfig) - .update(viteVersion) - .update(Vitest.version) - .digest('hex') + const cacheKey = hash( + 'sha1', + id + + fileContent + + (process.env.NODE_ENV ?? '') + + this.version + + cacheConfig + + viteVersion + + Vitest.version, + 'hex', + ) return join(this.fsCacheRoot, cacheKey) } } From 7e58c0adf73eeca9850aa96685883f692181e685 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 17:16:27 +0100 Subject: [PATCH 16/53] docs: fix links --- docs/config/experimental.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config/experimental.md b/docs/config/experimental.md index 4de934541cc6..ea2cb1abcc08 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -5,7 +5,7 @@ outline: deep # experimental -## fsModuleCache 4.0.10 +## fsModuleCache 4.0.10 {#fsmodulecache} - **Type:** `boolean` - **Default:** `false` @@ -14,7 +14,7 @@ Enabling this option allows Vitest to keep cached modules on the file system, ma You can delete the old cache by running `vitest --clearCache`. -## openTelemetry 4.0.10 +## openTelemetry 4.0.10 {#opentelemetry} - **Type:** From 9af8f8c8f5b13ce9b44c4cab5ebd3948e590f05f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 17:24:26 +0100 Subject: [PATCH 17/53] test: validate the option is inherited --- test/config/test/cli-config.test.ts | 40 ++++++++++++++++++++++++++++- test/config/test/flags.test.ts | 38 --------------------------- test/config/test/override.test.ts | 37 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 39 deletions(-) delete mode 100644 test/config/test/flags.test.ts diff --git a/test/config/test/cli-config.test.ts b/test/config/test/cli-config.test.ts index 1f6de119bff1..e719da85d2a0 100644 --- a/test/config/test/cli-config.test.ts +++ b/test/config/test/cli-config.test.ts @@ -1,7 +1,9 @@ import { resolve } from 'pathe' -import { expect, test } from 'vitest' +import { expect, it, test } from 'vitest' import { createVitest } from 'vitest/node' +import { runVitest } from '../../test-utils' + test('can pass down the config as a module', async () => { const vitest = await createVitest('test', { config: '@test/test-dep-config', @@ -11,3 +13,39 @@ test('can pass down the config as a module', async () => { resolve(import.meta.dirname, '../deps/test-dep-config/index.js'), ) }) + +it('correctly inherit from the cli', async () => { + const { ctx } = await runVitest({ + root: 'fixtures/workspace-flags', + logHeapUsage: true, + allowOnly: true, + sequence: { + seed: 123, + }, + testTimeout: 5321, + pool: 'forks', + globals: true, + expandSnapshotDiff: true, + retry: 6, + testNamePattern: 'math', + passWithNoTests: true, + bail: 100, + }) + const project = ctx!.projects[0] + const config = project.config + expect(config).toMatchObject({ + logHeapUsage: true, + allowOnly: true, + sequence: expect.objectContaining({ + seed: 123, + }), + testTimeout: 5321, + pool: 'forks', + globals: true, + expandSnapshotDiff: true, + retry: 6, + passWithNoTests: true, + bail: 100, + }) + expect(config.testNamePattern?.test('math')).toBe(true) +}) diff --git a/test/config/test/flags.test.ts b/test/config/test/flags.test.ts deleted file mode 100644 index b490142beb6c..000000000000 --- a/test/config/test/flags.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, it } from 'vitest' -import { runVitest } from '../../test-utils' - -it('correctly inherit from the cli', async () => { - const { ctx } = await runVitest({ - root: 'fixtures/workspace-flags', - logHeapUsage: true, - allowOnly: true, - sequence: { - seed: 123, - }, - testTimeout: 5321, - pool: 'forks', - globals: true, - expandSnapshotDiff: true, - retry: 6, - testNamePattern: 'math', - passWithNoTests: true, - bail: 100, - }) - const project = ctx!.projects[0] - const config = project.config - expect(config).toMatchObject({ - logHeapUsage: true, - allowOnly: true, - sequence: expect.objectContaining({ - seed: 123, - }), - testTimeout: 5321, - pool: 'forks', - globals: true, - expandSnapshotDiff: true, - retry: 6, - passWithNoTests: true, - bail: 100, - }) - expect(config.testNamePattern?.test('math')).toBe(true) -}) diff --git a/test/config/test/override.test.ts b/test/config/test/override.test.ts index 67aa75b732dc..3b5ff159df00 100644 --- a/test/config/test/override.test.ts +++ b/test/config/test/override.test.ts @@ -89,3 +89,40 @@ describe.each([ }).rejects.toThrowError(`Inspector host cannot be a URL. Use "host:port" instead of "${url}"`) }) }) + +it('experimental fsModuleCache is inherited in a project', async () => { + const v = await vitest({}, { + experimental: { + fsModuleCache: true, + }, + projects: [ + { + test: { + name: 'project', + }, + }, + ], + }) + expect(v.config.experimental.fsModuleCache).toBe(true) + expect(v.projects[0].config.experimental.fsModuleCache).toBe(true) +}) + +it('project overrides experimental fsModuleCache', async () => { + const v = await vitest({}, { + experimental: { + fsModuleCache: true, + }, + projects: [ + { + test: { + name: 'project', + experimental: { + fsModuleCache: false, + }, + }, + }, + ], + }) + expect(v.config.experimental.fsModuleCache).toBe(true) + expect(v.projects[0].config.experimental.fsModuleCache).toBe(false) +}) From 3b61a1328fa799faee0b6071c3f5d9e26608f718 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 18:00:06 +0100 Subject: [PATCH 18/53] fix: don't read virtual file, read tmp in server runner --- .../src/node/environments/fetchModule.ts | 19 ++++++++++++++----- .../src/node/environments/serverRunner.ts | 5 +++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 311c11041e1f..c53de84a7332 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -14,7 +14,7 @@ import { fetchModule } from 'vite' import { hash } from '../hash' const saveCachePromises = new Map>() -const readFilePromises = new Map>() +const readFilePromises = new Map>() class ModuleFetcher { private tmpDirectories = new Set() @@ -154,8 +154,16 @@ class ModuleFetcher { environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode, ): Promise { - if (moduleGraphModule.file) { - return this.readFileConcurrently(moduleGraphModule.file) + if ( + moduleGraphModule.file + // \x00 is a virtual file convention + && !moduleGraphModule.file.startsWith('\x00') + && !moduleGraphModule.file.startsWith('virtual:') + ) { + const result = await this.readFileConcurrently(moduleGraphModule.file) + if (result != null) { + return result + } } const loadResult = await environment.pluginContainer.load(moduleGraphModule.id!) @@ -240,11 +248,12 @@ class ModuleFetcher { return returnResult } - private readFileConcurrently(file: string): Promise { + private readFileConcurrently(file: string): Promise { if (!readFilePromises.has(file)) { readFilePromises.set( file, - readFile(file, 'utf-8').finally(() => { + // virtual file can have a "file" property + readFile(file, 'utf-8').catch(() => null).finally(() => { readFilePromises.delete(file) }), ) diff --git a/packages/vitest/src/node/environments/serverRunner.ts b/packages/vitest/src/node/environments/serverRunner.ts index 780def006cd7..0530f28793e9 100644 --- a/packages/vitest/src/node/environments/serverRunner.ts +++ b/packages/vitest/src/node/environments/serverRunner.ts @@ -1,6 +1,7 @@ import type { DevEnvironment } from 'vite' import type { ResolvedConfig } from '../types/config' import type { VitestFetchFunction } from './fetchModule' +import { readFile } from 'node:fs/promises' import { VitestModuleEvaluator } from '#module-evaluator' import { ModuleRunner } from 'vite/module-runner' import { normalizeResolvedIdToUrl } from './normalizeUrl' @@ -28,6 +29,10 @@ export class ServerModuleRunner extends ModuleRunner { } try { const result = await fetcher(data[0], data[1], environment, false, data[2]) + if ('tmp' in result) { + const code = await readFile(result.tmp) + return { result: { ...result, code } } + } return { result } } catch (error) { From 6b69f28fd29c753e362e4d21a3936b3e5b0b4ee4 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 18:05:16 +0100 Subject: [PATCH 19/53] cI: add a step to ci --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b378204f6cb..db38bf0168eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,40 @@ jobs: path: test/ui/test-results/ retention-days: 30 + test-cached: + needs: changed + name: 'Cache&Test: node-${{ matrix.node_version }}, ${{ matrix.os }}' + if: needs.changed.outputs.should_skip != 'true' + runs-on: ${{ matrix.os }} + + timeout-minutes: 30 + + strategy: + matrix: + os: [macos-latest] + node_version: [24] + fail-fast: false + + steps: + - uses: actions/checkout@v5 + + - uses: ./.github/actions/setup-and-cache + with: + node-version: ${{ matrix.node_version }} + + - uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0 + + - name: Install + run: pnpm i + + - uses: ./.github/actions/setup-playwright + + - name: Build + run: pnpm run build + + - name: Test + run: pnpm run test:ci + test-browser: needs: changed name: 'Browsers: node-${{ matrix.node_version }}, ${{ matrix.os }}' From 5a38b081b478b5eb734b20a93ef28db868161ffb Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 18:25:31 +0100 Subject: [PATCH 20/53] feat: add fsModuleCachePath --- docs/config/experimental.md | 7 ++++ packages/vitest/src/node/cache/fsCache.ts | 39 +++++++++++-------- .../vitest/src/node/config/resolveConfig.ts | 6 +++ packages/vitest/src/node/core.ts | 12 ++---- packages/vitest/src/node/project.ts | 6 +-- packages/vitest/src/node/types/config.ts | 5 +++ 6 files changed, 44 insertions(+), 31 deletions(-) diff --git a/docs/config/experimental.md b/docs/config/experimental.md index ea2cb1abcc08..b881ceec0594 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -14,6 +14,13 @@ Enabling this option allows Vitest to keep cached modules on the file system, ma You can delete the old cache by running `vitest --clearCache`. +## fsModuleCachePath 4.0.10 {#fsmodulecachepath} + +- **Type:** `string` +- **Default:** `join(tmpdir(), 'vitest')` + +Directory where the file system cache is located. + ## openTelemetry 4.0.10 {#opentelemetry} - **Type:** diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 5876c6466ba0..ea9d60acc678 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -1,5 +1,4 @@ import type { DevEnvironment, FetchResult } from 'vite' -import type { Logger } from '../logger' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' import { existsSync, mkdirSync } from 'node:fs' @@ -10,26 +9,25 @@ import { version as viteVersion } from 'vite' import { Vitest } from '../core' import { hash } from '../hash' +// TODO: keep track of stale cache somehow? maybe in a meta file? + /** * @experimental */ export class FileSystemModuleCache { - private fsCacheRoot: string private version = '1.0.0' + private fsCacheRoots = new WeakMap() - // TODO: keep track of stale cache somehow? maybe in a meta file? - - constructor(private logger: Logger) { - this.fsCacheRoot = join(tmpdir(), 'vitest') - - if (!existsSync(this.fsCacheRoot)) { - mkdirSync(this.fsCacheRoot) - } - } - - async clearCache(): Promise { - await rm(this.fsCacheRoot, { force: true, recursive: true }) - this.logger.log('[cache] cleared fs module cache at', this.fsCacheRoot) + async clearCache(vitest: Vitest): Promise { + const defaultFsCache = join(tmpdir(), 'vitest') + const fsCachePaths = vitest.projects.map((r) => { + return r.config.experimental.fsModuleCachePath || defaultFsCache + }) + const uniquePaths = Array.from(new Set(fsCachePaths)) + await Promise.all( + uniquePaths.map(directory => rm(directory, { force: true, recursive: true })), + ) + vitest.logger.log('[cache] cleared fs module cache at', uniquePaths.join(', ')) } async getCachedModule(cachedFilePath: string): Promise { @@ -89,7 +87,7 @@ export class FileSystemModuleCache { { root: config.root, // at the moment, Vitest always forces base to be / - // base: config.base, + base: config.base, mode: config.mode, consumer: config.consumer, resolve: config.resolve, @@ -120,7 +118,14 @@ export class FileSystemModuleCache { + Vitest.version, 'hex', ) - return join(this.fsCacheRoot, cacheKey) + let cacheRoot = this.fsCacheRoots.get(vitestConfig) + if (cacheRoot == null) { + cacheRoot = vitestConfig.experimental.fsModuleCachePath || join(tmpdir(), 'vitest') + if (!existsSync(cacheRoot)) { + mkdirSync(cacheRoot, { recursive: true }) + } + } + return join(cacheRoot, cacheKey) } } diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index f0819dadb06f..14ac4340aa5c 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -806,6 +806,12 @@ export function resolveConfig( ) resolved.experimental.openTelemetry.sdkPath = pathToFileURL(sdkPath).toString() } + if (resolved.experimental.fsModuleCachePath) { + resolved.experimental.fsModuleCachePath = resolve( + resolved.root, + resolved.experimental.fsModuleCachePath, + ) + } return resolved } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index a136e268fb18..15397f1f6ee5 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -227,7 +227,7 @@ export class Vitest { } this._resolver = new VitestResolver(server.config.cacheDir, resolved) - this._fsCache = new FileSystemModuleCache(this.logger) + this._fsCache = new FileSystemModuleCache() this._fetcher = createFetchModuleFunction( this._resolver, this._config, @@ -501,13 +501,7 @@ export class Vitest { */ public async experimental_clearCache(): Promise { await this.cache.results.clearCache() - const projects = [...this.projects] - if (this.coreWorkspaceProject && !projects.includes(this.coreWorkspaceProject)) { - projects.push(this.coreWorkspaceProject) - } - await Promise.all( - projects.map(p => p._fsCache.clearCache()), - ) + await this._fsCache.clearCache(this) } /** @@ -1192,7 +1186,7 @@ export class Vitest { * Invalidate a file in all projects. */ public invalidateFile(filepath: string): void { - this.projects.forEach(({ vite, browser, _fsCache }) => { + this.projects.forEach(({ vite, browser }) => { const environments = [ ...Object.values(vite.environments), ...Object.values(browser?.vite.environments || {}), diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 0b20448b8416..4601e8a2f02a 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -24,7 +24,6 @@ import pm from 'picomatch' import { glob } from 'tinyglobby' import { setup } from '../api/setup' import { createDefinesScript } from '../utils/config-helpers' -import { FileSystemModuleCache } from './cache/fsCache' import { isBrowserEnabled, resolveConfig } from './config/resolveConfig' import { serializeConfig } from './config/serializeConfig' import { createFetchModuleFunction } from './environments/fetchModule' @@ -66,7 +65,6 @@ export class TestProject { /** @internal */ _hash?: string /** @internal */ _resolver!: VitestResolver /** @internal */ _fetcher!: VitestFetchFunction - /** @internal */ _fsCache!: FileSystemModuleCache /** @internal */ _serializedDefines?: string /** @inetrnal */ testFilesList: string[] | null = null @@ -561,11 +559,10 @@ export class TestProject { this._resolver = new VitestResolver(server.config.cacheDir, this._config) this._vite = server this._serializedDefines = createDefinesScript(server.config.define) - this._fsCache = new FileSystemModuleCache(this.vitest.logger) this._fetcher = createFetchModuleFunction( this._resolver, this._config, - this._fsCache, + this.vitest._fsCache, this.vitest._traces, this.tmpDir, ) @@ -632,7 +629,6 @@ export class TestProject { project._config = vitest.config project._resolver = vitest._resolver project._fetcher = vitest._fetcher - project._fsCache = vitest._fsCache project._serializedDefines = createDefinesScript(vitest.vite.config.define) project._setHash() project._provideObject(vitest.config.provide) diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index b5a5c719baac..732b95148e0e 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -836,6 +836,11 @@ export interface InlineConfig { * Enable caching of modules on the file system between reruns. */ fsModuleCache?: boolean + /** + * Path relative to the root of the project where the fs module cache will be stored. + * @default join(tmpdir(), 'vitest') + */ + fsModuleCachePath?: string /** * {@link https://vitest.dev/guide/open-telemetry} */ From d345717d15db8ce39e126ff7c0fc37941082ed5e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 18:26:24 +0100 Subject: [PATCH 21/53] ci: run ci:cache --- .github/workflows/ci.yml | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db38bf0168eb..c4407b650845 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,7 +159,7 @@ jobs: run: pnpm run build - name: Test - run: pnpm run test:ci + run: pnpm run test:ci:cache test-browser: needs: changed diff --git a/package.json b/package.json index a19e5eed04b6..31ca7458f709 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "release": "tsx scripts/release.ts", "test": "pnpm --filter test-core test:threads", "test:ci": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser run test", + "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser run test --experimental.fsModuleCache", "test:examples": "CI=true pnpm -r --reporter-hide-prefix --stream --filter '@vitest/example-*' run test", "test:ecosystem-ci": "ECOSYSTEM_CI=true pnpm test:ci", "typebuild": "tsx ./scripts/explain-types.ts", From c771a4fd25a11ff26b0b0a1f21c0aed0a201f75a Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 19:04:02 +0100 Subject: [PATCH 22/53] feat(cache): support watch mode --- packages/vitest/src/node/cache/fsCache.ts | 47 +++++++++++++--- packages/vitest/src/node/cli/cli-config.ts | 1 + .../src/node/environments/fetchModule.ts | 55 +++++++++++++++++-- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index ea9d60acc678..3caf36cae5f9 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -4,6 +4,7 @@ import type { ResolvedConfig } from '../types/config' import { existsSync, mkdirSync } from 'node:fs' import { readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' +import { parse, stringify } from 'flatted' import { dirname, join } from 'pathe' import { version as viteVersion } from 'vite' import { Vitest } from '../core' @@ -30,7 +31,11 @@ export class FileSystemModuleCache { vitest.logger.log('[cache] cleared fs module cache at', uniquePaths.join(', ')) } - async getCachedModule(cachedFilePath: string): Promise { + async getCachedModule(cachedFilePath: string): Promise< + CachedInlineModuleMeta + | Extract + | undefined + > { if (!existsSync(cachedFilePath)) { return } @@ -41,7 +46,7 @@ export class FileSystemModuleCache { return } - const meta = JSON.parse(code.slice(matchIndex + 4)) + const meta = this.fromBase64(code.slice(matchIndex + 4)) if (meta.externalize) { return { externalize: meta.externalize, type: meta.type } } @@ -51,30 +56,39 @@ export class FileSystemModuleCache { url: meta.url, file: meta.file, code, - invalidate: false, + importers: meta.importers, } } async saveCachedModule( cachedFilePath: string, fetchResult: T, + importers: SerializedImporters[] = [], ): Promise { - // TODO: also keep dependencies, so they can populate the module graph on the next run - if ('externalize' in fetchResult) { - await atomicWriteFile(cachedFilePath, `\n// ${JSON.stringify(fetchResult)}`) + await atomicWriteFile(cachedFilePath, `\n// ${this.toBase64(fetchResult)}`) } else if ('code' in fetchResult) { const result = { file: fetchResult.file, id: fetchResult.id, url: fetchResult.url, - invalidate: false, - } satisfies Omit - await atomicWriteFile(cachedFilePath, `${fetchResult.code}\n// ${JSON.stringify(result)}`) + importers, + } satisfies Omit + await atomicWriteFile(cachedFilePath, `${fetchResult.code}\n// ${this.toBase64(result)}`) } } + private toBase64(obj: unknown) { + const json = stringify(obj) + return Buffer.from(json).toString('base64') + } + + private fromBase64(obj: string) { + const json = Buffer.from(obj, 'base64').toString('utf-8') + return parse(json) + } + getCachePath( vitestConfig: ResolvedConfig, environment: DevEnvironment, @@ -160,3 +174,18 @@ async function atomicWriteFile(realFilePath: string, data: string): Promise { + dependencies.push({ + id: importer.id!, + file: importer.file, + url: importer.url, + type: importer.type, + }) + }) + return dependencies } private recordResult(trace: Span, result: FetchResult | FetchCachedFileSystemResult): void { @@ -178,6 +192,7 @@ class ModuleFetcher { private async getCachedModule( cachePath: string, + environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode, ): Promise { const cachedModule = await this.fsCache.getCachedModule(cachePath) @@ -190,8 +205,37 @@ class ModuleFetcher { map.file = cachedModule.file } moduleGraphModule.transformResult = { code: cachedModule.code, map } + + // we populate the module graph to make the watch mode work because it relies on importers + cachedModule.importers.forEach((importer) => { + const environmentNode = environment.moduleGraph.getModuleById(importer.id) + if (environmentNode) { + moduleGraphModule.importers.add(environmentNode) + return + } + + const node = environment.moduleGraph.createFileOnlyEntry(importer.file || importer.id) + moduleGraphModule.importers.add(node) + if (!importer.file) { + environment.moduleGraph.fileToModulesMap.get(importer.id)?.delete(node) + } + + node.url = importer.url + node.id = importer.id + node.type = importer.type + + environment.moduleGraph.urlToModuleMap.set(importer.url, node) + environment.moduleGraph.idToModuleMap.set(importer.id, node) + }) + } + return { + cached: true as const, + file: cachedModule.file, + id: cachedModule.id, + tmp: cachePath, + url: cachedModule.url, + invalidate: false, } - return getCachedResult(cachedModule, cachePath) } return cachedModule @@ -225,6 +269,7 @@ class ModuleFetcher { private async cacheResult( result: FetchResult, cachePath: string, + importers: SerializedImporters[] = [], ): Promise { const returnResult = 'code' in result ? getCachedResult(result, cachePath) @@ -236,7 +281,7 @@ class ModuleFetcher { } const savePromise = this.fsCache - .saveCachedModule(cachePath, result) + .saveCachedModule(cachePath, result, importers) .then(() => result) .finally(() => { saveCachePromises.delete(cachePath) From 5dd14230b8986ee264d14f08403fa7f10c6dc828 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 19:10:23 +0100 Subject: [PATCH 23/53] chore: just use strings --- packages/vitest/src/node/cache/fsCache.ts | 11 ++----- .../src/node/environments/fetchModule.ts | 33 +++++-------------- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 3caf36cae5f9..8ddc0382c484 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -63,7 +63,7 @@ export class FileSystemModuleCache { async saveCachedModule( cachedFilePath: string, fetchResult: T, - importers: SerializedImporters[] = [], + importers: string[] = [], ): Promise { if ('externalize' in fetchResult) { await atomicWriteFile(cachedFilePath, `\n// ${this.toBase64(fetchResult)}`) @@ -180,12 +180,5 @@ export interface CachedInlineModuleMeta { id: string file: string | null code: string - importers: SerializedImporters[] -} - -export interface SerializedImporters { - url: string - id: string - file: string | null - type: 'js' | 'css' | 'asset' + importers: string[] } diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 34dd8896c53d..08ffc1bcb49d 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -3,7 +3,7 @@ import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, Transf import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' import type { OTELCarrier, Traces } from '../../utils/traces' -import type { FileSystemModuleCache, SerializedImporters } from '../cache/fsCache' +import type { FileSystemModuleCache } from '../cache/fsCache' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' import { existsSync, mkdirSync } from 'node:fs' @@ -128,15 +128,12 @@ class ModuleFetcher { return this.cacheResult(result, cachePath, importers) } - private getSerializedDependencies(node: EnvironmentModuleNode): SerializedImporters[] { - const dependencies: SerializedImporters[] = [] + private getSerializedDependencies(node: EnvironmentModuleNode): string[] { + const dependencies: string[] = [] node.importers.forEach((importer) => { - dependencies.push({ - id: importer.id!, - file: importer.file, - url: importer.url, - type: importer.type, - }) + if (importer.id) { + dependencies.push(importer.id) + } }) return dependencies } @@ -208,24 +205,10 @@ class ModuleFetcher { // we populate the module graph to make the watch mode work because it relies on importers cachedModule.importers.forEach((importer) => { - const environmentNode = environment.moduleGraph.getModuleById(importer.id) + const environmentNode = environment.moduleGraph.getModuleById(importer) if (environmentNode) { moduleGraphModule.importers.add(environmentNode) - return - } - - const node = environment.moduleGraph.createFileOnlyEntry(importer.file || importer.id) - moduleGraphModule.importers.add(node) - if (!importer.file) { - environment.moduleGraph.fileToModulesMap.get(importer.id)?.delete(node) } - - node.url = importer.url - node.id = importer.id - node.type = importer.type - - environment.moduleGraph.urlToModuleMap.set(importer.url, node) - environment.moduleGraph.idToModuleMap.set(importer.id, node) }) } return { @@ -269,7 +252,7 @@ class ModuleFetcher { private async cacheResult( result: FetchResult, cachePath: string, - importers: SerializedImporters[] = [], + importers: string[] = [], ): Promise { const returnResult = 'code' in result ? getCachedResult(result, cachePath) From 270d9c6a3050d3ecf52d5a79d62651503694a9f4 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Nov 2025 19:37:20 +0100 Subject: [PATCH 24/53] perf: keep the id in memory --- packages/vitest/src/node/cache/fsCache.ts | 30 +++++++++++++++++-- packages/vitest/src/node/core.ts | 5 +++- .../src/node/environments/fetchModule.ts | 23 ++++++++++---- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 8ddc0382c484..12cb87873cb8 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -18,6 +18,11 @@ import { hash } from '../hash' export class FileSystemModuleCache { private version = '1.0.0' private fsCacheRoots = new WeakMap() + private fsCacheKeys = new WeakMap< + DevEnvironment, + // Map + Map + >() async clearCache(vitest: Vitest): Promise { const defaultFsCache = join(tmpdir(), 'vitest') @@ -89,7 +94,21 @@ export class FileSystemModuleCache { return parse(json) } - getCachePath( + invalidateCachePath( + environment: DevEnvironment, + id: string, + ): void { + this.fsCacheKeys.get(environment)?.delete(id) + } + + getMemoryCachePath( + environment: DevEnvironment, + id: string, + ): string | undefined { + return this.fsCacheKeys.get(environment)?.get(id) + } + + generateCachePath( vitestConfig: ResolvedConfig, environment: DevEnvironment, resolver: VitestResolver, @@ -139,7 +158,14 @@ export class FileSystemModuleCache { mkdirSync(cacheRoot, { recursive: true }) } } - return join(cacheRoot, cacheKey) + let environmentKeys = this.fsCacheKeys.get(environment) + if (!environmentKeys) { + environmentKeys = new Map() + this.fsCacheKeys.set(environment, environmentKeys) + } + const fsResultPath = join(cacheRoot, cacheKey) + environmentKeys.set(id, fsResultPath) + return fsResultPath } } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 15397f1f6ee5..9255eeb01543 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -1199,7 +1199,10 @@ export class Vitest { return } - modules.forEach(module => moduleGraph.invalidateModule(module)) + modules.forEach((module) => { + moduleGraph.invalidateModule(module) + this._fsCache.invalidateCachePath(environment, module.id!) + }) }) }) } diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 08ffc1bcb49d..105cacd3b793 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -100,13 +100,9 @@ class ModuleFetcher { }) } - const fileContent = await this.readFileContentToCache(environment, moduleGraphModule) - const cachePath = this.fsCache.getCachePath( - this.config, + const cachePath = await this.getCachePath( environment, - this.resolver, - moduleGraphModule.id!, - fileContent, + moduleGraphModule, ) if (saveCachePromises.has(cachePath)) { @@ -161,6 +157,21 @@ class ModuleFetcher { } } + private async getCachePath(environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode) { + const memoryCacheKey = this.fsCache.getMemoryCachePath(environment, moduleGraphModule.id!) + if (memoryCacheKey != null) { + return memoryCacheKey + } + const fileContent = await this.readFileContentToCache(environment, moduleGraphModule) + return this.fsCache.generateCachePath( + this.config, + environment, + this.resolver, + moduleGraphModule.id!, + fileContent, + ) + } + private async readFileContentToCache( environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode, From c473ecbc8fa125065bb247c6b2626b62d4dc3f73 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 11:04:09 +0100 Subject: [PATCH 25/53] chore: run cached tests also on windows --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4407b650845..86c4412932eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,8 +137,10 @@ jobs: strategy: matrix: - os: [macos-latest] node_version: [24] + os: + - macos-latest + - windows-latest fail-fast: false steps: From 5e3bdc4b84bb030638e467447bcbfcf1efb7418c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 11:38:23 +0100 Subject: [PATCH 26/53] fix: check coverage transform --- packages/coverage-istanbul/src/provider.ts | 15 +++++++++++-- packages/vitest/src/node/cache/fsCache.ts | 25 ++++++++++++++++++---- packages/vitest/src/node/core.ts | 25 ++++++++++++++++++++-- packages/vitest/src/node/types/coverage.ts | 7 ++++++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 6cc6ba50fdb7..15ccfd9e0d72 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -50,15 +50,26 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider() + // this exists only to avoid the perf. cost of reading a file and generating a hash again + // on some machines this has negligible effect private fsCacheKeys = new WeakMap< DevEnvironment, // Map Map >() - async clearCache(vitest: Vitest): Promise { + constructor(private vitest: Vitest) {} + + async clearCache(): Promise { const defaultFsCache = join(tmpdir(), 'vitest') - const fsCachePaths = vitest.projects.map((r) => { + const fsCachePaths = this.vitest.projects.map((r) => { return r.config.experimental.fsModuleCachePath || defaultFsCache }) const uniquePaths = Array.from(new Set(fsCachePaths)) await Promise.all( uniquePaths.map(directory => rm(directory, { force: true, recursive: true })), ) - vitest.logger.log('[cache] cleared fs module cache at', uniquePaths.join(', ')) + this.vitest.logger.log('[cache] cleared fs module cache at', uniquePaths.join(', ')) } async getCachedModule(cachedFilePath: string): Promise< @@ -101,6 +105,10 @@ export class FileSystemModuleCache { this.fsCacheKeys.get(environment)?.delete(id) } + invalidateAllCachePaths(environment: DevEnvironment): void { + this.fsCacheKeys.get(environment)?.clear() + } + getMemoryCachePath( environment: DevEnvironment, id: string, @@ -116,6 +124,9 @@ export class FileSystemModuleCache { fileContent: string, ): string { const config = environment.config + // coverage provider is dynamic, so we also clear the whole cache if + // vitest.enableCoverage/vitest.disableCoverage is called + const coverageAffectsCache = !!(this.vitest.config.coverage.enabled && this.vitest.coverageProvider?.requiresTransform?.(id)) const cacheConfig = JSON.stringify( { root: config.root, @@ -131,7 +142,13 @@ export class FileSystemModuleCache { // this affects Vitest CSS plugin css: vitestConfig.css, // this affect externalization - resolver: resolver.options, + resolver: { + inline: resolver.options.inline, + external: resolver.options.external, + inlineFiles: resolver.options.inlineFiles, + moduleDirectories: resolver.options.moduleDirectories, + }, + coverageAffectsCache, }, (_, value) => { if (typeof value === 'function' || value instanceof RegExp) { diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 9255eeb01543..c602ec61e573 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -227,7 +227,7 @@ export class Vitest { } this._resolver = new VitestResolver(server.config.cacheDir, resolved) - this._fsCache = new FileSystemModuleCache() + this._fsCache = new FileSystemModuleCache(this) this._fetcher = createFetchModuleFunction( this._resolver, this._config, @@ -335,11 +335,32 @@ export class Vitest { this.configOverride.coverage!.enabled = true await this.createCoverageProvider() await this.coverageProvider?.onEnabled?.() + + // onFileTransform is the only thing that affects hash + if (this.coverageProvider?.onFileTransform) { + this.clearAllCachePaths() + } } public disableCoverage(): void { this.configOverride.coverage ??= {} as any this.configOverride.coverage!.enabled = false + // onFileTransform is the only thing that affects hash + if (this.coverageProvider?.onFileTransform) { + this.clearAllCachePaths() + } + } + + private clearAllCachePaths() { + this.projects.forEach(({ vite, browser }) => { + const environments = [ + ...Object.values(vite.environments), + ...Object.values(browser?.vite.environments || {}), + ] + environments.forEach(environment => + this._fsCache.invalidateAllCachePaths(environment), + ) + }) } private _coverageOverrideCache = new WeakMap() @@ -501,7 +522,7 @@ export class Vitest { */ public async experimental_clearCache(): Promise { await this.cache.results.clearCache() - await this._fsCache.clearCache(this) + await this._fsCache.clearCache() } /** diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index 93aa59116255..a148fc0901aa 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -53,6 +53,13 @@ export interface CoverageProvider { pluginCtx: any, ) => TransformResult | Promise + /** + * Return `true` if this file is transformed by the coverage provider. + * This is used to generate the persistent file hash by `fsModuleCache` + * @experimental + */ + requiresTransform?: (id: string) => boolean + /** Callback that's called when the coverage is enabled via a programmatic `enableCoverage` API. */ onEnabled?: () => void | Promise } From 1983f44cc8bb4291796d7ccfeab3073c055019bd Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 11:50:21 +0100 Subject: [PATCH 27/53] feat: add fsModuleCacheKeyGenerator --- packages/vitest/src/node/cache/fsCache.ts | 12 ++++++------ packages/vitest/src/node/types/config.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index f27f3bfaad69..291ef6aa4364 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -157,17 +157,17 @@ export class FileSystemModuleCache { return value }, ) - const cacheKey = hash( - 'sha1', - id + let hashString = id + fileContent + (process.env.NODE_ENV ?? '') + this.version + cacheConfig + viteVersion - + Vitest.version, - 'hex', - ) + + Vitest.version + if (vitestConfig.experimental.fsModuleCacheKeyGenerator) { + hashString += vitestConfig.experimental.fsModuleCacheKeyGenerator(environment, vitestConfig, id, fileContent) + } + const cacheKey = hash('sha1', hashString, 'hex') let cacheRoot = this.fsCacheRoots.get(vitestConfig) if (cacheRoot == null) { cacheRoot = vitestConfig.experimental.fsModuleCachePath || join(tmpdir(), 'vitest') diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 732b95148e0e..2b23e0823c3a 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -4,7 +4,7 @@ import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' import type { SnapshotStateOptions } from '@vitest/snapshot' import type { Arrayable } from '@vitest/utils' import type { SerializedDiffOptions } from '@vitest/utils/diff' -import type { AliasOptions, ConfigEnv, DepOptimizationConfig, ServerOptions, UserConfig as ViteUserConfig } from 'vite' +import type { AliasOptions, ConfigEnv, DepOptimizationConfig, DevEnvironment, ServerOptions, UserConfig as ViteUserConfig } from 'vite' import type { ChaiConfig } from '../../integrations/chai/config' import type { SerializedConfig } from '../../runtime/config' import type { LabelColor, ParsedStack, ProvidedContext, TestError } from '../../types/general' @@ -841,6 +841,18 @@ export interface InlineConfig { * @default join(tmpdir(), 'vitest') */ fsModuleCachePath?: string + /** + * A generator that should return any custom string. This string will be appended to the end of hash data. + * + * Note: this **IS NOT** the hash itself. Vitest will concatenate its own cache key + * and the custom one before turning it into a file system hash. + */ + fsModuleCacheKeyGenerator?: ( + environment: DevEnvironment, + config: ResolvedConfig, + id: string, + sourceCode: string, + ) => string /** * {@link https://vitest.dev/guide/open-telemetry} */ From eefd22b305ff68bb8fcb74aa9bd5747be041c147 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 13:48:57 +0100 Subject: [PATCH 28/53] fix: remove sourceMap.mappings --- packages/vitest/src/node/cache/fsCache.ts | 11 +++++++---- packages/vitest/src/node/environments/fetchModule.ts | 7 ++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 291ef6aa4364..efd8b60c89b7 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -10,6 +10,9 @@ import { version as viteVersion } from 'vite' import { Vitest } from '../core' import { hash } from '../hash' +const cacheComment = '\n//# vitestCache=' +const cacheCommentLength = cacheComment.length + // TODO: keep track of stale cache somehow? maybe in a meta file? /** @@ -50,12 +53,12 @@ export class FileSystemModuleCache { } const code = await readFile(cachedFilePath, 'utf-8') - const matchIndex = code.lastIndexOf('\n//') + const matchIndex = code.lastIndexOf(cacheComment) if (matchIndex === -1) { return } - const meta = this.fromBase64(code.slice(matchIndex + 4)) + const meta = this.fromBase64(code.slice(matchIndex + cacheCommentLength)) if (meta.externalize) { return { externalize: meta.externalize, type: meta.type } } @@ -75,7 +78,7 @@ export class FileSystemModuleCache { importers: string[] = [], ): Promise { if ('externalize' in fetchResult) { - await atomicWriteFile(cachedFilePath, `\n// ${this.toBase64(fetchResult)}`) + await atomicWriteFile(cachedFilePath, `${cacheComment}${this.toBase64(fetchResult)}`) } else if ('code' in fetchResult) { const result = { @@ -84,7 +87,7 @@ export class FileSystemModuleCache { url: fetchResult.url, importers, } satisfies Omit - await atomicWriteFile(cachedFilePath, `${fetchResult.code}\n// ${this.toBase64(result)}`) + await atomicWriteFile(cachedFilePath, `${fetchResult.code}${cacheComment}${this.toBase64(result)}`) } } diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 105cacd3b793..99a944a1232c 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -435,7 +435,12 @@ function extractSourceMap(code: string): null | Rollup.SourceMap { if (!mapString) { return null } - return JSON.parse(Buffer.from(mapString, 'base64').toString('utf-8')) + const sourceMap = JSON.parse(Buffer.from(mapString, 'base64').toString('utf-8')) + // remove source map mapping added by "inlineSourceMap" to keep the original behaviour of transformRequest + if (sourceMap.mappings.startsWith('AAAA,CAAA')) { + sourceMap.mappings = sourceMap.mappings.slice(9) + } + return sourceMap } // serialize rollup error on server to preserve details as a test error From 4d1a0a5e239d5d6b96e085a3cedb745ba3749ed7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 13:55:26 +0100 Subject: [PATCH 29/53] chore: add debug --- packages/vitest/src/node/cache/fsCache.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index efd8b60c89b7..609fd99f3bee 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -7,9 +7,12 @@ import { tmpdir } from 'node:os' import { parse, stringify } from 'flatted' import { dirname, join } from 'pathe' import { version as viteVersion } from 'vite' +import { createDebugger } from '../../utils/debugger' import { Vitest } from '../core' import { hash } from '../hash' +const debug = createDebugger('vitest:cache') + const cacheComment = '\n//# vitestCache=' const cacheCommentLength = cacheComment.length @@ -49,19 +52,23 @@ export class FileSystemModuleCache { | undefined > { if (!existsSync(cachedFilePath)) { + debug?.(`[read] ${cachedFilePath} doesn't exist, transforming by vite instead`) return } const code = await readFile(cachedFilePath, 'utf-8') const matchIndex = code.lastIndexOf(cacheComment) if (matchIndex === -1) { + debug?.(`[read] ${cachedFilePath} exists, but doesn't have a ${cacheComment} comment, transforming by vite instead`) return } const meta = this.fromBase64(code.slice(matchIndex + cacheCommentLength)) if (meta.externalize) { + debug?.(`[read] ${cachedFilePath} is externalized into ${meta.externalize}`) return { externalize: meta.externalize, type: meta.type } } + debug?.(`[read] ${cachedFilePath} is cached as ${meta.url}`) return { id: meta.id, @@ -78,6 +85,7 @@ export class FileSystemModuleCache { importers: string[] = [], ): Promise { if ('externalize' in fetchResult) { + debug?.(`[write] ${cachedFilePath} is externalized into ${fetchResult.externalize}`) await atomicWriteFile(cachedFilePath, `${cacheComment}${this.toBase64(fetchResult)}`) } else if ('code' in fetchResult) { @@ -87,6 +95,7 @@ export class FileSystemModuleCache { url: fetchResult.url, importers, } satisfies Omit + debug?.(`[write] ${cachedFilePath} is cached as ${fetchResult.url}`) await atomicWriteFile(cachedFilePath, `${fetchResult.code}${cacheComment}${this.toBase64(result)}`) } } @@ -105,10 +114,12 @@ export class FileSystemModuleCache { environment: DevEnvironment, id: string, ): void { + debug?.(`cache for ${id} in ${environment.name} environment is invalidated`) this.fsCacheKeys.get(environment)?.delete(id) } invalidateAllCachePaths(environment: DevEnvironment): void { + debug?.(`the ${environment.name} environment cache is invalidated`) this.fsCacheKeys.get(environment)?.clear() } @@ -116,7 +127,11 @@ export class FileSystemModuleCache { environment: DevEnvironment, id: string, ): string | undefined { - return this.fsCacheKeys.get(environment)?.get(id) + const result = this.fsCacheKeys.get(environment)?.get(id) + if (result) { + debug?.(`[memory][read] ${result} is cached from memory for ${id}`) + } + return result } generateCachePath( @@ -184,6 +199,7 @@ export class FileSystemModuleCache { this.fsCacheKeys.set(environment, environmentKeys) } const fsResultPath = join(cacheRoot, cacheKey) + debug?.(`[memory][write] ${fsResultPath} is cached from memory for ${id}`) environmentKeys.set(id, fsResultPath) return fsResultPath } From 53c481de5a373620dec9f05ff1ae860c280bd3d9 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 14:08:45 +0100 Subject: [PATCH 30/53] chore: keep mappings information --- packages/vitest/src/node/cache/fsCache.ts | 4 ++++ .../src/node/environments/fetchModule.ts | 22 ++++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 609fd99f3bee..03e9872adbf3 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -76,6 +76,7 @@ export class FileSystemModuleCache { file: meta.file, code, importers: meta.importers, + mappings: meta.mappings, } } @@ -83,6 +84,7 @@ export class FileSystemModuleCache { cachedFilePath: string, fetchResult: T, importers: string[] = [], + mappings: boolean = false, ): Promise { if ('externalize' in fetchResult) { debug?.(`[write] ${cachedFilePath} is externalized into ${fetchResult.externalize}`) @@ -94,6 +96,7 @@ export class FileSystemModuleCache { id: fetchResult.id, url: fetchResult.url, importers, + mappings, } satisfies Omit debug?.(`[write] ${cachedFilePath} is cached as ${fetchResult.url}`) await atomicWriteFile(cachedFilePath, `${fetchResult.code}${cacheComment}${this.toBase64(result)}`) @@ -243,4 +246,5 @@ export interface CachedInlineModuleMeta { file: string | null code: string importers: string[] + mappings: boolean } diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 99a944a1232c..ccf268c312b9 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -120,8 +120,10 @@ class ModuleFetcher { const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) const importers = this.getSerializedDependencies(moduleGraphModule) + const map = moduleGraphModule.transformResult?.map + const mappings = map && !('version' in map) && map.mappings === '' - return this.cacheResult(result, cachePath, importers) + return this.cacheResult(result, cachePath, importers, !!mappings) } private getSerializedDependencies(node: EnvironmentModuleNode): string[] { @@ -208,11 +210,19 @@ class ModuleFetcher { if (cachedModule && 'code' in cachedModule) { // keep the module graph in sync if (!moduleGraphModule.transformResult) { - const map = extractSourceMap(cachedModule.code) + let map: Rollup.SourceMap | null | { mappings: '' } = extractSourceMap(cachedModule.code) if (map && cachedModule.file) { map.file = cachedModule.file } - moduleGraphModule.transformResult = { code: cachedModule.code, map } + // mappings is a special source map identifier in rollup + if (!map && cachedModule.mappings) { + map = { mappings: '' } + } + moduleGraphModule.transformResult = { + code: cachedModule.code, + map, + ssr: true, + } // we populate the module graph to make the watch mode work because it relies on importers cachedModule.importers.forEach((importer) => { @@ -264,6 +274,7 @@ class ModuleFetcher { result: FetchResult, cachePath: string, importers: string[] = [], + mappings = false, ): Promise { const returnResult = 'code' in result ? getCachedResult(result, cachePath) @@ -275,7 +286,7 @@ class ModuleFetcher { } const savePromise = this.fsCache - .saveCachedModule(cachePath, result, importers) + .saveCachedModule(cachePath, result, importers, mappings) .then(() => result) .finally(() => { saveCachePromises.delete(cachePath) @@ -437,7 +448,8 @@ function extractSourceMap(code: string): null | Rollup.SourceMap { } const sourceMap = JSON.parse(Buffer.from(mapString, 'base64').toString('utf-8')) // remove source map mapping added by "inlineSourceMap" to keep the original behaviour of transformRequest - if (sourceMap.mappings.startsWith('AAAA,CAAA')) { + if (sourceMap.mappings.startsWith('AAAA,CAAA;')) { + // 9 because we want to only remove "AAAA,CAAA", but keep ; at the start sourceMap.mappings = sourceMap.mappings.slice(9) } return sourceMap From 961ed42edc533de4d167d2e22fdb148e82498e0d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 14:16:45 +0100 Subject: [PATCH 31/53] chore: remove test-dts-config --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 31ca7458f709..a9b5c5069943 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "release": "tsx scripts/release.ts", "test": "pnpm --filter test-core test:threads", "test:ci": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser run test", - "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser run test --experimental.fsModuleCache", + "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser --filter !test-dts-config run test --experimental.fsModuleCache", "test:examples": "CI=true pnpm -r --reporter-hide-prefix --stream --filter '@vitest/example-*' run test", "test:ecosystem-ci": "ECOSYSTEM_CI=true pnpm test:ci", "typebuild": "tsx ./scripts/explain-types.ts", From 516248566c33c435b47b144362d597e6c506781a Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 14:27:14 +0100 Subject: [PATCH 32/53] chore: ignore all dts tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a9b5c5069943..63acb1173d6b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "release": "tsx scripts/release.ts", "test": "pnpm --filter test-core test:threads", "test:ci": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser run test", - "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser --filter !test-dts-config run test --experimental.fsModuleCache", + "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser --filter !test-dts-config --filter !test-dts-fixture --filter !test-dts-playwright run test --experimental.fsModuleCache", "test:examples": "CI=true pnpm -r --reporter-hide-prefix --stream --filter '@vitest/example-*' run test", "test:ecosystem-ci": "ECOSYSTEM_CI=true pnpm test:ci", "typebuild": "tsx ./scripts/explain-types.ts", From a7cc69f67ede297d7fa3f0a58fdb5d2f60ec9193 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 14:47:15 +0100 Subject: [PATCH 33/53] chore: ignore ui tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 63acb1173d6b..ffb41961dc07 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "release": "tsx scripts/release.ts", "test": "pnpm --filter test-core test:threads", "test:ci": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser run test", - "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser --filter !test-dts-config --filter !test-dts-fixture --filter !test-dts-playwright run test --experimental.fsModuleCache", + "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser --filter !test-dts-config --filter !test-dts-fixture --filter !test-dts-playwright --filter !test-ui run test --experimental.fsModuleCache", "test:examples": "CI=true pnpm -r --reporter-hide-prefix --stream --filter '@vitest/example-*' run test", "test:ecosystem-ci": "ECOSYSTEM_CI=true pnpm test:ci", "typebuild": "tsx ./scripts/explain-types.ts", From f10869ec9f92f9563205f2d35327f3174f351960 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 14:48:27 +0100 Subject: [PATCH 34/53] feat: introduce `defineCacheKeyGenerator` --- docs/api/advanced/plugin.md | 44 +++++++++++++++++++++++ docs/config/experimental.md | 8 +++++ packages/vitest/src/node/cache/fsCache.ts | 24 +++++++++++-- packages/vitest/src/node/core.ts | 4 +++ packages/vitest/src/node/types/config.ts | 14 +------- packages/vitest/src/node/types/plugin.ts | 11 ++++++ 6 files changed, 89 insertions(+), 16 deletions(-) diff --git a/docs/api/advanced/plugin.md b/docs/api/advanced/plugin.md index 6620f14de02e..db1cf5cf6b6f 100644 --- a/docs/api/advanced/plugin.md +++ b/docs/api/advanced/plugin.md @@ -123,3 +123,47 @@ The project's `configFile` can be accessed in Vite's config: `project.vite.confi Note that this will also inherit the `name` - Vitest doesn't allow multiple projects with the same name, so this will throw an error. Make sure you specified a different name. You can access the current name via the `project.name` property and all used names are available in the `vitest.projects` array. ::: + +### experimental_defineCacheKeyGenerator {#definecachekeygenerator} + +```ts +function experimental_defineCacheKeyGenerator( + callback: ( + environment: DevEnvironment, + id: string, + sourceCode: string, + ) => string | undefined | null +): void +``` + +Define a generator that will be applied before hashing the cache key. + +Use this to make sure Vitest generates correct hash. It is a good idea to define this function if your plugin can be registered with different options. + +This is called only if [`experimental.fsModuleCache`](/config/experimental#fsmodulecache) is defined. + +```ts +interface PluginOptions { + replacePropertyKey: string + replacePropertyValue: string +} + +export function plugin(options: PluginOptions) { + return { + name: 'plugin-that-replaces-property', + transform(code) { + return code.replace( + options.replacePropertyKey, + options.replacePropertyValue + ) + }, + configureVitest({ experimental_defineCacheKeyGenerator }) { + experimental_defineCacheKeyGenerator(() => { + // since these options affect the transform result, + // return them together as a unique string + return options.replacePropertyKey + options.replacePropertyValue + }) + } + } +} +``` diff --git a/docs/config/experimental.md b/docs/config/experimental.md index b881ceec0594..8bd605cb1454 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -14,6 +14,14 @@ Enabling this option allows Vitest to keep cached modules on the file system, ma You can delete the old cache by running `vitest --clearCache`. +::: warning BROWSER SUPPORT +At the moment, this option does not affect [the browser](/guide/browser/). +::: + +::: danger ADVANCED +If you are a plugin author, consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) if your plugin can be registered with different options that affect the transform result. +::: + ## fsModuleCachePath 4.0.10 {#fsmodulecachepath} - **Type:** `string` diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 03e9872adbf3..003b2410393c 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -24,6 +24,7 @@ const cacheCommentLength = cacheComment.length export class FileSystemModuleCache { private version = '1.0.0' private fsCacheRoots = new WeakMap() + private fsCacheKeyGenerators = new Set() // this exists only to avoid the perf. cost of reading a file and generating a hash again // on some machines this has negligible effect private fsCacheKeys = new WeakMap< @@ -34,6 +35,10 @@ export class FileSystemModuleCache { constructor(private vitest: Vitest) {} + public defineCacheKeyGenerator(callback: CacheKeyIdGenerator): void { + this.fsCacheKeyGenerators.add(callback) + } + async clearCache(): Promise { const defaultFsCache = join(tmpdir(), 'vitest') const fsCachePaths = this.vitest.projects.map((r) => { @@ -185,9 +190,14 @@ export class FileSystemModuleCache { + cacheConfig + viteVersion + Vitest.version - if (vitestConfig.experimental.fsModuleCacheKeyGenerator) { - hashString += vitestConfig.experimental.fsModuleCacheKeyGenerator(environment, vitestConfig, id, fileContent) - } + + this.fsCacheKeyGenerators.forEach((generator) => { + const result = generator(environment, id, fileContent) + if (typeof result === 'string') { + hashString += result + } + }) + const cacheKey = hash('sha1', hashString, 'hex') let cacheRoot = this.fsCacheRoots.get(vitestConfig) if (cacheRoot == null) { @@ -248,3 +258,11 @@ export interface CachedInlineModuleMeta { importers: string[] mappings: boolean } + +export interface CacheKeyIdGenerator { + ( + environment: DevEnvironment, + id: string, + sourceCode: string, + ): string | undefined | null +} diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index c602ec61e573..ff38be0bd82e 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -281,6 +281,10 @@ export class Vitest { project, vitest: this, injectTestProjects: this.injectTestProject, + /** + * @experimental + */ + experimental_defineCacheKeyGenerator: callback => this._fsCache.defineCacheKeyGenerator(callback), })) })) diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 2b23e0823c3a..732b95148e0e 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -4,7 +4,7 @@ import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' import type { SnapshotStateOptions } from '@vitest/snapshot' import type { Arrayable } from '@vitest/utils' import type { SerializedDiffOptions } from '@vitest/utils/diff' -import type { AliasOptions, ConfigEnv, DepOptimizationConfig, DevEnvironment, ServerOptions, UserConfig as ViteUserConfig } from 'vite' +import type { AliasOptions, ConfigEnv, DepOptimizationConfig, ServerOptions, UserConfig as ViteUserConfig } from 'vite' import type { ChaiConfig } from '../../integrations/chai/config' import type { SerializedConfig } from '../../runtime/config' import type { LabelColor, ParsedStack, ProvidedContext, TestError } from '../../types/general' @@ -841,18 +841,6 @@ export interface InlineConfig { * @default join(tmpdir(), 'vitest') */ fsModuleCachePath?: string - /** - * A generator that should return any custom string. This string will be appended to the end of hash data. - * - * Note: this **IS NOT** the hash itself. Vitest will concatenate its own cache key - * and the custom one before turning it into a file system hash. - */ - fsModuleCacheKeyGenerator?: ( - environment: DevEnvironment, - config: ResolvedConfig, - id: string, - sourceCode: string, - ) => string /** * {@link https://vitest.dev/guide/open-telemetry} */ diff --git a/packages/vitest/src/node/types/plugin.ts b/packages/vitest/src/node/types/plugin.ts index 012084d8417c..83e6229f5281 100644 --- a/packages/vitest/src/node/types/plugin.ts +++ b/packages/vitest/src/node/types/plugin.ts @@ -1,3 +1,4 @@ +import type { CacheKeyIdGenerator } from '../cache/fsCache' import type { Vitest } from '../core' import type { TestProject } from '../project' import type { TestProjectConfiguration } from './config' @@ -6,4 +7,14 @@ export interface VitestPluginContext { vitest: Vitest project: TestProject injectTestProjects: (config: TestProjectConfiguration | TestProjectConfiguration[]) => Promise + /** + * Define a generator that will be applied before hashing the cache key. + * + * Use this to make sure Vitest generates correct hash. It is a good idea + * to define this function if your plugin can be registered with different options. + * + * This is called only if `experimental.fsModuleCache` is defined. + * @experimental + */ + experimental_defineCacheKeyGenerator: (callback: CacheKeyIdGenerator) => void } From fcbed42a2491903d083ed0c460ceb6846d8178a7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 15:22:20 +0100 Subject: [PATCH 35/53] docs: update versions and links --- docs/api/advanced/plugin.md | 2 +- docs/config/experimental.md | 8 ++++---- docs/guide/cli-generated.md | 13 +++++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/api/advanced/plugin.md b/docs/api/advanced/plugin.md index db1cf5cf6b6f..03447fe429b9 100644 --- a/docs/api/advanced/plugin.md +++ b/docs/api/advanced/plugin.md @@ -124,7 +124,7 @@ The project's `configFile` can be accessed in Vite's config: `project.vite.confi Note that this will also inherit the `name` - Vitest doesn't allow multiple projects with the same name, so this will throw an error. Make sure you specified a different name. You can access the current name via the `project.name` property and all used names are available in the `vitest.projects` array. ::: -### experimental_defineCacheKeyGenerator {#definecachekeygenerator} +### experimental_defineCacheKeyGenerator 4.0.11 {#definecachekeygenerator} ```ts function experimental_defineCacheKeyGenerator( diff --git a/docs/config/experimental.md b/docs/config/experimental.md index 8bd605cb1454..0dc8f2092d65 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -5,14 +5,14 @@ outline: deep # experimental -## fsModuleCache 4.0.10 {#fsmodulecache} +## experimental.fsModuleCache 4.0.11 {#experimental-fsmodulecache} - **Type:** `boolean` - **Default:** `false` Enabling this option allows Vitest to keep cached modules on the file system, making tests run faster between reruns. -You can delete the old cache by running `vitest --clearCache`. +You can delete the old cache by running [`vitest --clearCache`](/guide/cli#clearcache). ::: warning BROWSER SUPPORT At the moment, this option does not affect [the browser](/guide/browser/). @@ -22,14 +22,14 @@ At the moment, this option does not affect [the browser](/guide/browser/). If you are a plugin author, consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) if your plugin can be registered with different options that affect the transform result. ::: -## fsModuleCachePath 4.0.10 {#fsmodulecachepath} +## experimental.fsModuleCachePath 4.0.11 {#experimental-fsmodulecachepath} - **Type:** `string` - **Default:** `join(tmpdir(), 'vitest')` Directory where the file system cache is located. -## openTelemetry 4.0.10 {#opentelemetry} +## experimental.openTelemetry 4.0.11 {#experimental-opentelemetry} - **Type:** diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index 5d621a19015f..384b28cde52b 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -798,3 +798,16 @@ Use `bundle` to bundle the config with esbuild or `runner` (experimental) to pro - **CLI:** `--standalone` Start Vitest without running tests. Tests will be running only on change. This option is ignored when CLI file filters are passed. (default: `false`) + +### clearCache + +- **CLI:** `--clearCache` + +Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run. + +### experimental.fsModuleCache + +- **CLI:** `--experimental.fsModuleCache` +- **Config:** [experimental.fsModuleCache](/config/experimental#experimental-fsmodulecache) + +Enable caching of modules on the file system between reruns. From a3577d0df95524076e1377818c5a96e073881303 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 15:24:27 +0100 Subject: [PATCH 36/53] docs: cleanup --- docs/api/advanced/plugin.md | 2 +- docs/api/advanced/vitest.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/advanced/plugin.md b/docs/api/advanced/plugin.md index 03447fe429b9..c3d004d6da35 100644 --- a/docs/api/advanced/plugin.md +++ b/docs/api/advanced/plugin.md @@ -124,7 +124,7 @@ The project's `configFile` can be accessed in Vite's config: `project.vite.confi Note that this will also inherit the `name` - Vitest doesn't allow multiple projects with the same name, so this will throw an error. Make sure you specified a different name. You can access the current name via the `project.name` property and all used names are available in the `vitest.projects` array. ::: -### experimental_defineCacheKeyGenerator 4.0.11 {#definecachekeygenerator} +### experimental_defineCacheKeyGenerator 4.0.11 {#definecachekeygenerator} ```ts function experimental_defineCacheKeyGenerator( diff --git a/docs/api/advanced/vitest.md b/docs/api/advanced/vitest.md index a1e9a4229e51..ffc32a80519a 100644 --- a/docs/api/advanced/vitest.md +++ b/docs/api/advanced/vitest.md @@ -608,7 +608,7 @@ function experimental_parseSpecifications( This method will [collect tests](#parsespecification) from an array of specifications. By default, Vitest will run only `os.availableParallelism()` number of specifications at a time to reduce the potential performance degradation. You can specify a different number in a second argument. -## experimental_clearCache 4.0.10 experimental {#clearcache} +## experimental_clearCache 4.0.11 experimental {#clearcache} ```ts function experimental_clearCache(): Promise From 310a64d7fce4c5174d3f1692ab59419860d593b5 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 18 Nov 2025 16:42:21 +0100 Subject: [PATCH 37/53] chore: update debug messages --- packages/vitest/src/node/cache/fsCache.ts | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsCache.ts index 003b2410393c..7455532311b2 100644 --- a/packages/vitest/src/node/cache/fsCache.ts +++ b/packages/vitest/src/node/cache/fsCache.ts @@ -6,12 +6,14 @@ import { readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { parse, stringify } from 'flatted' import { dirname, join } from 'pathe' +import c from 'tinyrainbow' import { version as viteVersion } from 'vite' import { createDebugger } from '../../utils/debugger' import { Vitest } from '../core' import { hash } from '../hash' -const debug = createDebugger('vitest:cache') +const debugFs = createDebugger('vitest:cache:fs') +const debugMemory = createDebugger('vitest:cache:memory') const cacheComment = '\n//# vitestCache=' const cacheCommentLength = cacheComment.length @@ -57,23 +59,23 @@ export class FileSystemModuleCache { | undefined > { if (!existsSync(cachedFilePath)) { - debug?.(`[read] ${cachedFilePath} doesn't exist, transforming by vite instead`) + debugFs?.(`${c.red('[empty]')} ${cachedFilePath} doesn't exist, transforming by vite instead`) return } const code = await readFile(cachedFilePath, 'utf-8') const matchIndex = code.lastIndexOf(cacheComment) if (matchIndex === -1) { - debug?.(`[read] ${cachedFilePath} exists, but doesn't have a ${cacheComment} comment, transforming by vite instead`) + debugFs?.(`${c.red('[empty]')} ${cachedFilePath} exists, but doesn't have a ${cacheComment} comment, transforming by vite instead`) return } const meta = this.fromBase64(code.slice(matchIndex + cacheCommentLength)) if (meta.externalize) { - debug?.(`[read] ${cachedFilePath} is externalized into ${meta.externalize}`) + debugFs?.(`${c.green('[read]')} ${meta.externalize} is externalized inside ${cachedFilePath}`) return { externalize: meta.externalize, type: meta.type } } - debug?.(`[read] ${cachedFilePath} is cached as ${meta.url}`) + debugFs?.(`${c.green('[read]')} ${meta.id} is cached in ${cachedFilePath}`) return { id: meta.id, @@ -92,7 +94,7 @@ export class FileSystemModuleCache { mappings: boolean = false, ): Promise { if ('externalize' in fetchResult) { - debug?.(`[write] ${cachedFilePath} is externalized into ${fetchResult.externalize}`) + debugFs?.(`${c.yellow('[write]')} ${fetchResult.externalize} is externalized inside ${cachedFilePath}`) await atomicWriteFile(cachedFilePath, `${cacheComment}${this.toBase64(fetchResult)}`) } else if ('code' in fetchResult) { @@ -103,7 +105,7 @@ export class FileSystemModuleCache { importers, mappings, } satisfies Omit - debug?.(`[write] ${cachedFilePath} is cached as ${fetchResult.url}`) + debugFs?.(`${c.yellow('[write]')} ${fetchResult.id} is cached in ${cachedFilePath}`) await atomicWriteFile(cachedFilePath, `${fetchResult.code}${cacheComment}${this.toBase64(result)}`) } } @@ -122,12 +124,12 @@ export class FileSystemModuleCache { environment: DevEnvironment, id: string, ): void { - debug?.(`cache for ${id} in ${environment.name} environment is invalidated`) + debugFs?.(`cache for ${id} in ${environment.name} environment is invalidated`) this.fsCacheKeys.get(environment)?.delete(id) } invalidateAllCachePaths(environment: DevEnvironment): void { - debug?.(`the ${environment.name} environment cache is invalidated`) + debugFs?.(`the ${environment.name} environment cache is invalidated`) this.fsCacheKeys.get(environment)?.clear() } @@ -137,7 +139,7 @@ export class FileSystemModuleCache { ): string | undefined { const result = this.fsCacheKeys.get(environment)?.get(id) if (result) { - debug?.(`[memory][read] ${result} is cached from memory for ${id}`) + debugMemory?.(`${c.green('[write]')} ${id} was cached in ${result}`) } return result } @@ -212,7 +214,7 @@ export class FileSystemModuleCache { this.fsCacheKeys.set(environment, environmentKeys) } const fsResultPath = join(cacheRoot, cacheKey) - debug?.(`[memory][write] ${fsResultPath} is cached from memory for ${id}`) + debugMemory?.(`${c.yellow('[write]')} ${id} generated a cache in ${fsResultPath}`) environmentKeys.set(id, fsResultPath) return fsResultPath } From dbde0ce8aaf198709ce6522471043b6177e972e9 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 11:43:23 +0100 Subject: [PATCH 38/53] refactor: rename fsCache to fsModuelCache --- packages/vitest/src/node/cache/{fsCache.ts => fsModuleCache.ts} | 0 packages/vitest/src/node/core.ts | 2 +- packages/vitest/src/node/environments/fetchModule.ts | 2 +- packages/vitest/src/node/types/plugin.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/vitest/src/node/cache/{fsCache.ts => fsModuleCache.ts} (100%) diff --git a/packages/vitest/src/node/cache/fsCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts similarity index 100% rename from packages/vitest/src/node/cache/fsCache.ts rename to packages/vitest/src/node/cache/fsModuleCache.ts diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index ff38be0bd82e..0c5a034efdbb 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -28,7 +28,7 @@ import { Traces } from '../utils/traces' import { astCollectTests, createFailedFileTask } from './ast-collect' import { BrowserSessions } from './browser/sessions' import { VitestCache } from './cache' -import { FileSystemModuleCache } from './cache/fsCache' +import { FileSystemModuleCache } from './cache/fsModuleCache' import { resolveConfig } from './config/resolveConfig' import { getCoverageProvider } from './coverage' import { createFetchModuleFunction } from './environments/fetchModule' diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index ccf268c312b9..54f7c3858373 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -3,7 +3,7 @@ import type { DevEnvironment, EnvironmentModuleNode, FetchResult, Rollup, Transf import type { FetchFunctionOptions } from 'vite/module-runner' import type { FetchCachedFileSystemResult } from '../../types/general' import type { OTELCarrier, Traces } from '../../utils/traces' -import type { FileSystemModuleCache } from '../cache/fsCache' +import type { FileSystemModuleCache } from '../cache/fsModuleCache' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' import { existsSync, mkdirSync } from 'node:fs' diff --git a/packages/vitest/src/node/types/plugin.ts b/packages/vitest/src/node/types/plugin.ts index 83e6229f5281..4eebe8276e3a 100644 --- a/packages/vitest/src/node/types/plugin.ts +++ b/packages/vitest/src/node/types/plugin.ts @@ -1,4 +1,4 @@ -import type { CacheKeyIdGenerator } from '../cache/fsCache' +import type { CacheKeyIdGenerator } from '../cache/fsModuleCache' import type { Vitest } from '../core' import type { TestProject } from '../project' import type { TestProjectConfiguration } from './config' From c4b210bc5eea6950079e4dbe6f5b9724897b886e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 11:46:26 +0100 Subject: [PATCH 39/53] refactor: cache environment configs --- .../vitest/src/node/cache/fsModuleCache.ts | 69 +++++++++++-------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 7455532311b2..9136d53fabe0 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -26,6 +26,7 @@ const cacheCommentLength = cacheComment.length export class FileSystemModuleCache { private version = '1.0.0' private fsCacheRoots = new WeakMap() + private fsEnvironmentHashMap = new WeakMap() private fsCacheKeyGenerators = new Set() // this exists only to avoid the perf. cost of reading a file and generating a hash again // on some machines this has negligible effect @@ -154,42 +155,47 @@ export class FileSystemModuleCache { const config = environment.config // coverage provider is dynamic, so we also clear the whole cache if // vitest.enableCoverage/vitest.disableCoverage is called - const coverageAffectsCache = !!(this.vitest.config.coverage.enabled && this.vitest.coverageProvider?.requiresTransform?.(id)) - const cacheConfig = JSON.stringify( - { - root: config.root, - // at the moment, Vitest always forces base to be / - base: config.base, - mode: config.mode, - consumer: config.consumer, - resolve: config.resolve, - // plugins can have different options, so this is not the best key, - // but we canot access the options because there is no standard API for it - plugins: config.plugins.map(p => p.name), - environment: environment.name, - // this affects Vitest CSS plugin - css: vitestConfig.css, - // this affect externalization - resolver: { - inline: resolver.options.inline, - external: resolver.options.external, - inlineFiles: resolver.options.inlineFiles, - moduleDirectories: resolver.options.moduleDirectories, + const coverageAffectsCache = String(this.vitest.config.coverage.enabled && this.vitest.coverageProvider?.requiresTransform?.(id)) + let cacheConfig = this.fsEnvironmentHashMap.get(environment) + if (!cacheConfig) { + cacheConfig = JSON.stringify( + { + root: config.root, + // at the moment, Vitest always forces base to be / + base: config.base, + mode: config.mode, + consumer: config.consumer, + resolve: config.resolve, + // plugins can have different options, so this is not the best key, + // but we canot access the options because there is no standard API for it + plugins: config.plugins.map(p => p.name), + environment: environment.name, + // this affects Vitest CSS plugin + css: vitestConfig.css, + // this affect externalization + resolver: { + inline: resolver.options.inline, + external: resolver.options.external, + inlineFiles: resolver.options.inlineFiles, + moduleDirectories: resolver.options.moduleDirectories, + }, }, - coverageAffectsCache, - }, - (_, value) => { - if (typeof value === 'function' || value instanceof RegExp) { - return value.toString() - } - return value - }, - ) + (_, value) => { + if (typeof value === 'function' || value instanceof RegExp) { + return value.toString() + } + return value + }, + ) + this.fsEnvironmentHashMap.set(environment, cacheConfig) + } + let hashString = id + fileContent + (process.env.NODE_ENV ?? '') + this.version + cacheConfig + + coverageAffectsCache + viteVersion + Vitest.version @@ -201,6 +207,7 @@ export class FileSystemModuleCache { }) const cacheKey = hash('sha1', hashString, 'hex') + let cacheRoot = this.fsCacheRoots.get(vitestConfig) if (cacheRoot == null) { cacheRoot = vitestConfig.experimental.fsModuleCachePath || join(tmpdir(), 'vitest') @@ -208,11 +215,13 @@ export class FileSystemModuleCache { mkdirSync(cacheRoot, { recursive: true }) } } + let environmentKeys = this.fsCacheKeys.get(environment) if (!environmentKeys) { environmentKeys = new Map() this.fsCacheKeys.set(environment, environmentKeys) } + const fsResultPath = join(cacheRoot, cacheKey) debugMemory?.(`${c.yellow('[write]')} ${id} generated a cache in ${fsResultPath}`) environmentKeys.set(id, fsResultPath) From 570da8281990f2873a8d5f61e56a92288fe50f08 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 11:58:25 +0100 Subject: [PATCH 40/53] refactor: provide context to defineCacheKeyGenerator --- docs/api/advanced/plugin.md | 12 ++++++----- .../vitest/src/node/cache/fsModuleCache.ts | 20 +++++++++++++------ packages/vitest/src/public/node.ts | 1 + 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/api/advanced/plugin.md b/docs/api/advanced/plugin.md index c3d004d6da35..e8e32c2ae200 100644 --- a/docs/api/advanced/plugin.md +++ b/docs/api/advanced/plugin.md @@ -127,12 +127,14 @@ Note that this will also inherit the `name` - Vitest doesn't allow multiple proj ### experimental_defineCacheKeyGenerator 4.0.11 {#definecachekeygenerator} ```ts +interface CacheKeyIdGeneratorContext { + environment: DevEnvironment + id: string + sourceCode: string +} + function experimental_defineCacheKeyGenerator( - callback: ( - environment: DevEnvironment, - id: string, - sourceCode: string, - ) => string | undefined | null + callback: (context: CacheKeyIdGeneratorContext) => string | undefined | null ): void ``` diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 9136d53fabe0..477ef4e08914 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -200,7 +200,7 @@ export class FileSystemModuleCache { + Vitest.version this.fsCacheKeyGenerators.forEach((generator) => { - const result = generator(environment, id, fileContent) + const result = generator({ environment, id, sourceCode: fileContent }) if (typeof result === 'string') { hashString += result } @@ -270,10 +270,18 @@ export interface CachedInlineModuleMeta { mappings: boolean } +/** + * @experimental + */ export interface CacheKeyIdGenerator { - ( - environment: DevEnvironment, - id: string, - sourceCode: string, - ): string | undefined | null + (context: CacheKeyIdGeneratorContext): string | undefined | null +} + +/** + * @experimental + */ +export interface CacheKeyIdGeneratorContext { + environment: DevEnvironment + id: string + sourceCode: string } diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index 7ca1e31f532c..d6dde3671b29 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -5,6 +5,7 @@ export const version: string = Vitest.version export { isValidApiRequest } from '../api/check' export { escapeTestName } from '../node/ast-collect' +export type { CacheKeyIdGenerator, CacheKeyIdGeneratorContext } from '../node/cache/fsModuleCache' export { parseCLI } from '../node/cli/cac' export type { CliParseOptions } from '../node/cli/cac' export type { CliOptions } from '../node/cli/cli-api' From 8ff2cd9f0f064889978ade2ce84b1a77cab30471 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 11:58:55 +0100 Subject: [PATCH 41/53] chore: cleanup --- packages/coverage-istanbul/src/provider.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 15ccfd9e0d72..56e230771b32 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -66,9 +66,6 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider Date: Wed, 19 Nov 2025 14:38:19 +0100 Subject: [PATCH 42/53] fix: nuke cache if lockfile changes --- docs/config/experimental.md | 12 +- .../vitest/src/node/cache/fsModuleCache.ts | 240 ++++++++++++++++-- packages/vitest/src/node/core.ts | 2 + packages/vitest/src/node/types/config.ts | 2 +- 4 files changed, 237 insertions(+), 19 deletions(-) diff --git a/docs/config/experimental.md b/docs/config/experimental.md index 0dc8f2092d65..faf45c18f034 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -22,13 +22,23 @@ At the moment, this option does not affect [the browser](/guide/browser/). If you are a plugin author, consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) if your plugin can be registered with different options that affect the transform result. ::: +You can debug if your modules are cached by running vitest with a `DEBUG=vitest:cache:fs` environment variable: + +```shell +DEBUG=vitest:cache:fs vitest --experimental.fsModuleCache +``` + ## experimental.fsModuleCachePath 4.0.11 {#experimental-fsmodulecachepath} - **Type:** `string` -- **Default:** `join(tmpdir(), 'vitest')` +- **Default:** `'node_modules/.experimental-vitest-cache'` Directory where the file system cache is located. +By default, Vitest will try to find the workspace root and store the cache inside the `node_modules` folder. The root is based on your package manager's lockfile (for example, `.package-lock.json`, `.yarn-state.yml`, `.pnpm/lock.yaml` and so on). + +At the moment, Vitest ignores the [test.cache.dir](/config/cache) or [cacheDir](https://vite.dev/config/shared-options#cachedir) options completely and creates a separate folder. + ## experimental.openTelemetry 4.0.11 {#experimental-opentelemetry} - **Type:** diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 477ef4e08914..47704774748a 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -1,15 +1,14 @@ -import type { DevEnvironment, FetchResult } from 'vite' +import type { DevEnvironment, FetchResult, Rollup } from 'vite' +import type { Vitest } from '../core' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' -import { existsSync, mkdirSync } from 'node:fs' +import fs, { existsSync, mkdirSync } from 'node:fs' import { readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' import { parse, stringify } from 'flatted' import { dirname, join } from 'pathe' import c from 'tinyrainbow' -import { version as viteVersion } from 'vite' +import { searchForWorkspaceRoot } from 'vite' import { createDebugger } from '../../utils/debugger' -import { Vitest } from '../core' import { hash } from '../hash' const debugFs = createDebugger('vitest:cache:fs') @@ -18,40 +17,55 @@ const debugMemory = createDebugger('vitest:cache:memory') const cacheComment = '\n//# vitestCache=' const cacheCommentLength = cacheComment.length -// TODO: keep track of stale cache somehow? maybe in a meta file? +const METADATA_FILE = '_metadata.json' /** * @experimental */ export class FileSystemModuleCache { - private version = '1.0.0' + /** + * Even though it's possible to override the folder of project's caches + * We still keep a single metadata file for all projects because + * - they can reference files between each other + * - lockfile changes are reflected for the whole workspace, not just for a single project + */ + private rootCache: string + private metadataFilePath: string + + private version = '1.0.0-beta.1' private fsCacheRoots = new WeakMap() private fsEnvironmentHashMap = new WeakMap() private fsCacheKeyGenerators = new Set() // this exists only to avoid the perf. cost of reading a file and generating a hash again - // on some machines this has negligible effect + // surprisingly, on some machines this has negligible effect private fsCacheKeys = new WeakMap< DevEnvironment, // Map Map >() - constructor(private vitest: Vitest) {} + constructor(private vitest: Vitest) { + const workspaceRoot = searchForWorkspaceRoot(vitest.vite.config.root) + this.rootCache = vitest.config.experimental.fsModuleCachePath + || join(workspaceRoot, 'node_modules', '.experimental-vitest-cache') + this.metadataFilePath = join(this.rootCache, METADATA_FILE) + } public defineCacheKeyGenerator(callback: CacheKeyIdGenerator): void { this.fsCacheKeyGenerators.add(callback) } - async clearCache(): Promise { - const defaultFsCache = join(tmpdir(), 'vitest') + async clearCache(log = true): Promise { const fsCachePaths = this.vitest.projects.map((r) => { - return r.config.experimental.fsModuleCachePath || defaultFsCache + return r.config.experimental.fsModuleCachePath || this.rootCache }) const uniquePaths = Array.from(new Set(fsCachePaths)) await Promise.all( uniquePaths.map(directory => rm(directory, { force: true, recursive: true })), ) - this.vitest.logger.log('[cache] cleared fs module cache at', uniquePaths.join(', ')) + if (log) { + this.vitest.logger.log(`[cache] cleared fs module cache at ${uniquePaths.join(', ')}`) + } } async getCachedModule(cachedFilePath: string): Promise< @@ -168,7 +182,15 @@ export class FileSystemModuleCache { resolve: config.resolve, // plugins can have different options, so this is not the best key, // but we canot access the options because there is no standard API for it - plugins: config.plugins.map(p => p.name), + // we bust the cache if lockfile changes, so it covers installed plugins, + // but users can also have local plugins - for this use case we + // serialize hooks in case the implementation ever changes + plugins: config.plugins.map((p) => { + return p.name + + serializePluginHook(p.transform) + + serializePluginHook(p.load) + + serializePluginHook(p.resolveId) + }), environment: environment.name, // this affects Vitest CSS plugin css: vitestConfig.css, @@ -196,8 +218,6 @@ export class FileSystemModuleCache { + this.version + cacheConfig + coverageAffectsCache - + viteVersion - + Vitest.version this.fsCacheKeyGenerators.forEach((generator) => { const result = generator({ environment, id, sourceCode: fileContent }) @@ -210,7 +230,7 @@ export class FileSystemModuleCache { let cacheRoot = this.fsCacheRoots.get(vitestConfig) if (cacheRoot == null) { - cacheRoot = vitestConfig.experimental.fsModuleCachePath || join(tmpdir(), 'vitest') + cacheRoot = vitestConfig.experimental.fsModuleCachePath || this.rootCache if (!existsSync(cacheRoot)) { mkdirSync(cacheRoot, { recursive: true }) } @@ -227,6 +247,66 @@ export class FileSystemModuleCache { environmentKeys.set(id, fsResultPath) return fsResultPath } + + private async readMetadata(): Promise<{ lockfileHash: string } | undefined> { + // metadata is shared between every projects in the workspace, so we ignore project's fsModuleCachePath + if (!existsSync(this.metadataFilePath)) { + return undefined + } + try { + const content = await readFile(this.metadataFilePath, 'utf-8') + return JSON.parse(content) + } + catch {} + } + + // before vitest starts running tests, we check that the lockfile wasn't updated + // if it was, we nuke the previous cache in case a custom plugin was updated + // or a new version of vite/vitest is installed + // for the same reason we also serialize plugin hooks, but that won't catch changes made outside of those hooks + public async ensureCacheIntegrity(): Promise { + const enabled = [ + this.vitest.getRootProject(), + ...this.vitest.projects, + ].some(p => p.config.experimental.fsModuleCache) + if (!enabled) { + return + } + + const metadata = await this.readMetadata() + const currentLockfileHash = getLockfileHash(this.vitest.vite.config.root) + + // no metadata found, just store a new one, don't reset the cache + if (!metadata) { + if (!existsSync(this.rootCache)) { + mkdirSync(this.rootCache, { recursive: true }) + } + debugFs?.(`fs metadata file was created with hash ${currentLockfileHash}`) + + await writeFile( + this.metadataFilePath, + JSON.stringify({ lockfileHash: currentLockfileHash }, null, 2), + 'utf-8', + ) + return + } + + // if lockfile didn't change, don't do anything + if (metadata.lockfileHash === currentLockfileHash) { + return + } + + // lockfile changed, let's clear all caches + await this.clearCache(false) + this.vitest.vite.config.logger.info( + `fs cache was cleared because lockfile has changed`, + { + timestamp: true, + environment: c.yellow('[vitest]'), + }, + ) + debugFs?.(`fs cache was cleared because lockfile has changed`) + } } /** @@ -285,3 +365,129 @@ export interface CacheKeyIdGeneratorContext { id: string sourceCode: string } + +function serializePluginHook(hook: Rollup.ObjectHook<(...args: any[]) => any> | undefined) { + if (hook == null) { + return '' + } + if (typeof hook === 'function') { + return hook.toString() + } + if (typeof hook === 'object' && 'handler' in hook && typeof hook.handler === 'function') { + return hook.handler.toString() + } + return '' +} + +// lockfile hash resolution taken from vite +// since this is experimental, we don't ask to expose it +const lockfileFormats = [ + { + path: 'node_modules/.package-lock.json', + checkPatchesDir: 'patches', + manager: 'npm', + }, + { + // Yarn non-PnP + path: 'node_modules/.yarn-state.yml', + checkPatchesDir: false, + manager: 'yarn', + }, + { + // Yarn v3+ PnP + path: '.pnp.cjs', + checkPatchesDir: '.yarn/patches', + manager: 'yarn', + }, + { + // Yarn v2 PnP + path: '.pnp.js', + checkPatchesDir: '.yarn/patches', + manager: 'yarn', + }, + { + // yarn 1 + path: 'node_modules/.yarn-integrity', + checkPatchesDir: 'patches', + manager: 'yarn', + }, + { + path: 'node_modules/.pnpm/lock.yaml', + // Included in lockfile + checkPatchesDir: false, + manager: 'pnpm', + }, + { + path: '.rush/temp/shrinkwrap-deps.json', + // Included in lockfile + checkPatchesDir: false, + manager: 'pnpm', + }, + { + path: 'bun.lock', + checkPatchesDir: 'patches', + manager: 'bun', + }, + { + path: 'bun.lockb', + checkPatchesDir: 'patches', + manager: 'bun', + }, +].sort((_, { manager }) => { + return process.env.npm_config_user_agent?.startsWith(manager) ? 1 : -1 +}) +const lockfilePaths = lockfileFormats.map(l => l.path) + +function getLockfileHash(root: string): string { + const lockfilePath = lookupFile(root, lockfilePaths) + let content = lockfilePath ? fs.readFileSync(lockfilePath, 'utf-8') : '' + if (lockfilePath) { + const normalizedLockfilePath = lockfilePath.replaceAll('\\', '/') + const lockfileFormat = lockfileFormats.find(f => + normalizedLockfilePath.endsWith(f.path), + )! + if (lockfileFormat.checkPatchesDir) { + // Default of https://github.com/ds300/patch-package + const baseDir = lockfilePath.slice(0, -lockfileFormat.path.length) + const fullPath = join( + baseDir, + lockfileFormat.checkPatchesDir as string, + ) + const stat = tryStatSync(fullPath) + if (stat?.isDirectory()) { + content += stat.mtimeMs.toString() + } + } + } + return hash('sha256', content, 'hex').substring(0, 8).padEnd(8, '_') +} + +function lookupFile( + dir: string, + fileNames: string[], +): string | undefined { + while (dir) { + for (const fileName of fileNames) { + const fullPath = join(dir, fileName) + if (tryStatSync(fullPath)?.isFile()) { + return fullPath + } + } + const parentDir = dirname(dir) + if (parentDir === dir) { + return + } + + dir = parentDir + } +} + +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 + } +} diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 0c5a034efdbb..fb22d5e57b8c 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -320,6 +320,8 @@ export class Vitest { ? await createBenchmarkReporters(toArray(resolved.benchmark?.reporters), this.runner) : await createReporters(resolved.reporters, this) + await this._fsCache.ensureCacheIntegrity() + await Promise.all([ ...this._onSetServer.map(fn => fn()), this._traces.waitInit(), diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 732b95148e0e..c09b4c3b01d1 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -838,7 +838,7 @@ export interface InlineConfig { fsModuleCache?: boolean /** * Path relative to the root of the project where the fs module cache will be stored. - * @default join(tmpdir(), 'vitest') + * @default node_modules/.experimental-vitest-cache */ fsModuleCachePath?: string /** From 1547588ab0998fbfc96c64590f80bb2d48f6227c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 15:04:39 +0100 Subject: [PATCH 43/53] test: add defineCacheKey test --- package.json | 2 +- .../dynamic-cache-key/replaced.test.js | 5 + .../dynamic-cache-key/vitest.config.fails.js | 12 +++ .../dynamic-cache-key/vitest.config.passes.js | 17 ++++ test/cache/package.json | 11 +++ .../test/defineCacheKeyGenerator.test.ts | 96 +++++++++++++++++++ test/cache/vitest.config.ts | 14 +++ test/test-utils/index.ts | 22 ++++- 8 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 test/cache/fixtures/dynamic-cache-key/replaced.test.js create mode 100644 test/cache/fixtures/dynamic-cache-key/vitest.config.fails.js create mode 100644 test/cache/fixtures/dynamic-cache-key/vitest.config.passes.js create mode 100644 test/cache/package.json create mode 100644 test/cache/test/defineCacheKeyGenerator.test.ts create mode 100644 test/cache/vitest.config.ts diff --git a/package.json b/package.json index ffb41961dc07..1604d75ce096 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "release": "tsx scripts/release.ts", "test": "pnpm --filter test-core test:threads", "test:ci": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser run test", - "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser --filter !test-dts-config --filter !test-dts-fixture --filter !test-dts-playwright --filter !test-ui run test --experimental.fsModuleCache", + "test:ci:cache": "CI=true pnpm -r --reporter-hide-prefix --stream --sequential --filter '@vitest/test-*' --filter !test-browser --filter !test-dts-config --filter !test-dts-fixture --filter !test-dts-playwright --filter !test-ui --filter !test-cache run test --experimental.fsModuleCache", "test:examples": "CI=true pnpm -r --reporter-hide-prefix --stream --filter '@vitest/example-*' run test", "test:ecosystem-ci": "ECOSYSTEM_CI=true pnpm test:ci", "typebuild": "tsx ./scripts/explain-types.ts", diff --git a/test/cache/fixtures/dynamic-cache-key/replaced.test.js b/test/cache/fixtures/dynamic-cache-key/replaced.test.js new file mode 100644 index 000000000000..28b6ea6ebf8d --- /dev/null +++ b/test/cache/fixtures/dynamic-cache-key/replaced.test.js @@ -0,0 +1,5 @@ +import { test, expect } from 'vitest' + +test('replaced variable is the same', () => { + expect(__REPLACED__).toBe(process.env.REPLACED) +}) diff --git a/test/cache/fixtures/dynamic-cache-key/vitest.config.fails.js b/test/cache/fixtures/dynamic-cache-key/vitest.config.fails.js new file mode 100644 index 000000000000..810ee11921db --- /dev/null +++ b/test/cache/fixtures/dynamic-cache-key/vitest.config.fails.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [ + { + name: 'test:replacer', + transform(code) { + return code.replace('__REPLACED__', JSON.stringify(process.env.REPLACED)) + }, + }, + ], +}) diff --git a/test/cache/fixtures/dynamic-cache-key/vitest.config.passes.js b/test/cache/fixtures/dynamic-cache-key/vitest.config.passes.js new file mode 100644 index 000000000000..f2bc8a06fc3d --- /dev/null +++ b/test/cache/fixtures/dynamic-cache-key/vitest.config.passes.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [ + { + name: 'test:replacer', + transform(code) { + return code.replace('__REPLACED__', JSON.stringify(process.env.REPLACED)) + }, + configureVitest(ctx) { + ctx.experimental_defineCacheKeyGenerator(() => { + return String(process.env.REPLACED) + }) + }, + }, + ], +}) diff --git a/test/cache/package.json b/test/cache/package.json new file mode 100644 index 000000000000..3a7e3264d171 --- /dev/null +++ b/test/cache/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vitest/test-cache", + "type": "module", + "private": true, + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vitest": "workspace:*" + } +} diff --git a/test/cache/test/defineCacheKeyGenerator.test.ts b/test/cache/test/defineCacheKeyGenerator.test.ts new file mode 100644 index 000000000000..66cf2ed6969d --- /dev/null +++ b/test/cache/test/defineCacheKeyGenerator.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from 'vitest' +import { runVitest } from '../../test-utils' + +test('if no cache key generator is defined, the hash is invalid', async () => { + process.env.REPLACED = 'value1' + + const { errorTree: errorTree1 } = await runVitest({ + root: './fixtures/dynamic-cache-key', + config: './vitest.config.fails.js', + experimental: { + fsModuleCache: true, + }, + reporters: [ + { + async onInit(vitest) { + // make sure cache is empty + await vitest.experimental_clearCache() + }, + }, + ], + }) + + expect(errorTree1()).toMatchInlineSnapshot(` + { + "replaced.test.js": { + "replaced variable is the same": "passed", + }, + } + `) + + process.env.REPLACED = 'value2' + + const { errorTree: errorTree2 } = await runVitest({ + root: './fixtures/dynamic-cache-key', + config: './vitest.config.fails.js', + experimental: { + fsModuleCache: true, + }, + }) + + expect(errorTree2()).toMatchInlineSnapshot(` + { + "replaced.test.js": { + "replaced variable is the same": [ + "expected 'value1' to be 'value2' // Object.is equality", + ], + }, + } + `) +}) + +test('if cache key generator is defined, the hash is valid', async () => { + process.env.REPLACED = 'value1' + + const { errorTree: errorTree1 } = await runVitest({ + root: './fixtures/dynamic-cache-key', + config: './vitest.config.passes.js', + experimental: { + fsModuleCache: true, + }, + reporters: [ + { + async onInit(vitest) { + // make sure cache is empty + await vitest.experimental_clearCache() + }, + }, + ], + }) + + expect(errorTree1()).toMatchInlineSnapshot(` + { + "replaced.test.js": { + "replaced variable is the same": "passed", + }, + } + `) + + process.env.REPLACED = 'value2' + + const { errorTree: errorTree2 } = await runVitest({ + root: './fixtures/dynamic-cache-key', + config: './vitest.config.passes.js', + experimental: { + fsModuleCache: true, + }, + }) + + expect(errorTree2()).toMatchInlineSnapshot(` + { + "replaced.test.js": { + "replaced variable is the same": "passed", + }, + } + `) +}) diff --git a/test/cache/vitest.config.ts b/test/cache/vitest.config.ts new file mode 100644 index 000000000000..bc7f1bea6e55 --- /dev/null +++ b/test/cache/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + test: { + include: ['test/**.test.ts'], + includeTaskLocation: true, + reporters: ['verbose'], + testTimeout: 60_000, + fileParallelism: false, + chaiConfig: { + truncateThreshold: 999, + }, + }, +}) diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 1277e2570e99..054e336f83eb 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -2,7 +2,7 @@ import type { Options } from 'tinyexec' import type { UserConfig as ViteUserConfig } from 'vite' import type { SerializedConfig, WorkerGlobalState } from 'vitest' import type { TestProjectConfiguration } from 'vitest/config' -import type { TestCollection, TestModule, TestSpecification, TestUserConfig, Vitest, VitestRunMode } from 'vitest/node' +import type { TestCase, TestCollection, TestModule, TestResult, TestSpecification, TestUserConfig, Vitest, VitestRunMode } from 'vitest/node' import { webcrypto as crypto } from 'node:crypto' import fs from 'node:fs' import { Readable, Writable } from 'node:stream' @@ -148,6 +148,17 @@ export async function runVitest( vitest: cli, stdout: cli.stdout, stderr: cli.stderr, + get results() { + return ctx?.state.getTestModules() || [] + }, + errorTree() { + return buildTestTree(ctx?.state.getTestModules() || [], (result) => { + if (result.state === 'failed') { + return result.errors.map(e => e.message) + } + return result.state + }) + }, testTree() { return buildTestTree(ctx?.state.getTestModules() || []) }, @@ -399,7 +410,7 @@ export class StableTestFileOrderSorter { } } -function buildTestTree(testModules: TestModule[]) { +function buildTestTree(testModules: TestModule[], onResult?: (result: TestResult) => unknown) { type TestTree = Record function walkCollection(collection: TestCollection): TestTree { @@ -413,7 +424,12 @@ function buildTestTree(testModules: TestModule[]) { } else if (child.type === 'test') { const result = child.result() - node[child.name] = result.state + if (onResult) { + node[child.name] = onResult(result) + } + else { + node[child.name] = result.state + } } } From d4a54507baa5cb637e6dcb9804132ed5399c264d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 15:05:30 +0100 Subject: [PATCH 44/53] chore: lockfile --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72f63f831f2f..92adc41dfc30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1167,6 +1167,12 @@ importers: specifier: 'catalog:' version: 8.18.3 + test/cache: + devDependencies: + vitest: + specifier: workspace:* + version: link:../../packages/vitest + test/cli: devDependencies: '@opentelemetry/sdk-node': From d870c04a056a251f72f1ff0f3cae72c566f2375a Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 15:08:01 +0100 Subject: [PATCH 45/53] chore: cleanup --- test/test-utils/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 054e336f83eb..9dcd8c57d76b 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -2,7 +2,15 @@ import type { Options } from 'tinyexec' import type { UserConfig as ViteUserConfig } from 'vite' import type { SerializedConfig, WorkerGlobalState } from 'vitest' import type { TestProjectConfiguration } from 'vitest/config' -import type { TestCase, TestCollection, TestModule, TestResult, TestSpecification, TestUserConfig, Vitest, VitestRunMode } from 'vitest/node' +import type { + TestCollection, + TestModule, + TestResult, + TestSpecification, + TestUserConfig, + Vitest, + VitestRunMode, +} from 'vitest/node' import { webcrypto as crypto } from 'node:crypto' import fs from 'node:fs' import { Readable, Writable } from 'node:stream' From b7ed675f76a1a74a9ef9c873b39da38a0c52b81c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 15:10:53 +0100 Subject: [PATCH 46/53] test: specify cache path to avoid clearing global cache --- test/cache/test/defineCacheKeyGenerator.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/cache/test/defineCacheKeyGenerator.test.ts b/test/cache/test/defineCacheKeyGenerator.test.ts index 66cf2ed6969d..e437267a9163 100644 --- a/test/cache/test/defineCacheKeyGenerator.test.ts +++ b/test/cache/test/defineCacheKeyGenerator.test.ts @@ -9,6 +9,7 @@ test('if no cache key generator is defined, the hash is invalid', async () => { config: './vitest.config.fails.js', experimental: { fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', }, reporters: [ { @@ -35,6 +36,7 @@ test('if no cache key generator is defined, the hash is invalid', async () => { config: './vitest.config.fails.js', experimental: { fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', }, }) @@ -57,6 +59,7 @@ test('if cache key generator is defined, the hash is valid', async () => { config: './vitest.config.passes.js', experimental: { fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', }, reporters: [ { @@ -83,6 +86,7 @@ test('if cache key generator is defined, the hash is valid', async () => { config: './vitest.config.passes.js', experimental: { fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', }, }) From dc20427a892d254b73ddad9318eebfc1bdc4debb Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 15:23:14 +0100 Subject: [PATCH 47/53] chore: hash config file dependencies --- .../vitest/src/node/cache/fsModuleCache.ts | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 47704774748a..ba3ef47b1b03 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -1,8 +1,8 @@ -import type { DevEnvironment, FetchResult, Rollup } from 'vite' +import type { DevEnvironment, FetchResult } from 'vite' import type { Vitest } from '../core' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' -import fs, { existsSync, mkdirSync } from 'node:fs' +import fs, { existsSync, mkdirSync, readFileSync } from 'node:fs' import { readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' import { parse, stringify } from 'flatted' import { dirname, join } from 'pathe' @@ -182,15 +182,10 @@ export class FileSystemModuleCache { resolve: config.resolve, // plugins can have different options, so this is not the best key, // but we canot access the options because there is no standard API for it - // we bust the cache if lockfile changes, so it covers installed plugins, - // but users can also have local plugins - for this use case we - // serialize hooks in case the implementation ever changes - plugins: config.plugins.map((p) => { - return p.name - + serializePluginHook(p.transform) - + serializePluginHook(p.load) - + serializePluginHook(p.resolveId) - }), + plugins: config.plugins.map(p => p.name), + // in case local plugins change + // configFileDependencies also includes configFile + configFileDependencies: config.configFileDependencies.map(file => tryReadFileSync(file)), environment: environment.name, // this affects Vitest CSS plugin css: vitestConfig.css, @@ -366,19 +361,6 @@ export interface CacheKeyIdGeneratorContext { sourceCode: string } -function serializePluginHook(hook: Rollup.ObjectHook<(...args: any[]) => any> | undefined) { - if (hook == null) { - return '' - } - if (typeof hook === 'function') { - return hook.toString() - } - if (typeof hook === 'object' && 'handler' in hook && typeof hook.handler === 'function') { - return hook.handler.toString() - } - return '' -} - // lockfile hash resolution taken from vite // since this is experimental, we don't ask to expose it const lockfileFormats = [ @@ -482,6 +464,15 @@ function lookupFile( } } +function tryReadFileSync(file: string): string { + try { + return readFileSync(file, 'utf-8') + } + catch { + return '' + } +} + function tryStatSync(file: string): fs.Stats | undefined { try { // The "throwIfNoEntry" is a performance optimization for cases where the file does not exist From f3f126af415b6675566f9f1aef498506cd362da0 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 15:24:04 +0100 Subject: [PATCH 48/53] chore: cleanup --- packages/vitest/src/node/cache/fsModuleCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index ba3ef47b1b03..d87b468cd5f8 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -258,7 +258,7 @@ export class FileSystemModuleCache { // before vitest starts running tests, we check that the lockfile wasn't updated // if it was, we nuke the previous cache in case a custom plugin was updated // or a new version of vite/vitest is installed - // for the same reason we also serialize plugin hooks, but that won't catch changes made outside of those hooks + // for the same reason we also cache config file content, but that won't catch changes made in external plugins public async ensureCacheIntegrity(): Promise { const enabled = [ this.vitest.getRootProject(), From 4c4197179b4bb8d8a449c737fdeb4768a6cad56b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Nov 2025 15:30:36 +0100 Subject: [PATCH 49/53] chore: fix log --- packages/vitest/src/node/cache/fsModuleCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index d87b468cd5f8..c7ea55234c54 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -154,7 +154,7 @@ export class FileSystemModuleCache { ): string | undefined { const result = this.fsCacheKeys.get(environment)?.get(id) if (result) { - debugMemory?.(`${c.green('[write]')} ${id} was cached in ${result}`) + debugMemory?.(`${c.green('[read]')} ${id} was cached in ${result}`) } return result } From aba901aa4eef024beb14cb8205cfe90d233c4d34 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 20 Nov 2025 09:42:46 +0100 Subject: [PATCH 50/53] feat: allow bailing out of caching in the generator --- docs/api/advanced/plugin.md | 4 +- .../vitest/src/node/cache/fsModuleCache.ts | 62 +++++++++++++------ .../src/node/environments/fetchModule.ts | 26 +++++--- 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/docs/api/advanced/plugin.md b/docs/api/advanced/plugin.md index e8e32c2ae200..11bebbcaac04 100644 --- a/docs/api/advanced/plugin.md +++ b/docs/api/advanced/plugin.md @@ -134,7 +134,7 @@ interface CacheKeyIdGeneratorContext { } function experimental_defineCacheKeyGenerator( - callback: (context: CacheKeyIdGeneratorContext) => string | undefined | null + callback: (context: CacheKeyIdGeneratorContext) => string | undefined | null | false ): void ``` @@ -169,3 +169,5 @@ export function plugin(options: PluginOptions) { } } ``` + +If the `false` is returned, the module will not be cached on the file system. diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index c7ea55234c54..4d85e21333d1 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -40,8 +40,8 @@ export class FileSystemModuleCache { // surprisingly, on some machines this has negligible effect private fsCacheKeys = new WeakMap< DevEnvironment, - // Map - Map + // Map + Map >() constructor(private vitest: Vitest) { @@ -151,11 +151,14 @@ export class FileSystemModuleCache { getMemoryCachePath( environment: DevEnvironment, id: string, - ): string | undefined { + ): string | null | undefined { const result = this.fsCacheKeys.get(environment)?.get(id) - if (result) { + if (result != null) { debugMemory?.(`${c.green('[read]')} ${id} was cached in ${result}`) } + else if (result === null) { + debugMemory?.(`${c.green('[read]')} ${id} was bailed out`) + } return result } @@ -165,7 +168,29 @@ export class FileSystemModuleCache { resolver: VitestResolver, id: string, fileContent: string, - ): string { + ): string | null { + let hashString = '' + + // bail out if file has import.meta.glob because it depends on other files + // TODO: figure out a way to still support it + if (fileContent.includes('import.meta.glob')) { + this.saveMemoryCache(environment, id, null) + debugMemory?.(`${c.yellow('[write]')} ${id} was bailed out`) + return null + } + + for (const generator of this.fsCacheKeyGenerators) { + const result = generator({ environment, id, sourceCode: fileContent }) + if (typeof result === 'string') { + hashString += result + } + if (result === false) { + this.saveMemoryCache(environment, id, null) + debugMemory?.(`${c.yellow('[write]')} ${id} was bailed out by a custom generator`) + return null + } + } + const config = environment.config // coverage provider is dynamic, so we also clear the whole cache if // vitest.enableCoverage/vitest.disableCoverage is called @@ -207,20 +232,13 @@ export class FileSystemModuleCache { this.fsEnvironmentHashMap.set(environment, cacheConfig) } - let hashString = id + hashString += id + fileContent + (process.env.NODE_ENV ?? '') + this.version + cacheConfig + coverageAffectsCache - this.fsCacheKeyGenerators.forEach((generator) => { - const result = generator({ environment, id, sourceCode: fileContent }) - if (typeof result === 'string') { - hashString += result - } - }) - const cacheKey = hash('sha1', hashString, 'hex') let cacheRoot = this.fsCacheRoots.get(vitestConfig) @@ -231,16 +249,19 @@ export class FileSystemModuleCache { } } + const fsResultPath = join(cacheRoot, cacheKey) + debugMemory?.(`${c.yellow('[write]')} ${id} generated a cache in ${fsResultPath}`) + this.saveMemoryCache(environment, id, fsResultPath) + return fsResultPath + } + + private saveMemoryCache(environment: DevEnvironment, id: string, cache: string | null) { let environmentKeys = this.fsCacheKeys.get(environment) if (!environmentKeys) { environmentKeys = new Map() this.fsCacheKeys.set(environment, environmentKeys) } - - const fsResultPath = join(cacheRoot, cacheKey) - debugMemory?.(`${c.yellow('[write]')} ${id} generated a cache in ${fsResultPath}`) - environmentKeys.set(id, fsResultPath) - return fsResultPath + environmentKeys.set(id, cache) } private async readMetadata(): Promise<{ lockfileHash: string } | undefined> { @@ -346,10 +367,13 @@ export interface CachedInlineModuleMeta { } /** + * Generate a unique cache identifier. + * + * Return `false` to disable caching of the file. * @experimental */ export interface CacheKeyIdGenerator { - (context: CacheKeyIdGeneratorContext): string | undefined | null + (context: CacheKeyIdGeneratorContext): string | undefined | null | false } /** diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 54f7c3858373..2cbbc946649b 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -66,9 +66,14 @@ class ModuleFetcher { return { cache: true } } + const cachePath = await this.getCachePath( + environment, + moduleGraphModule, + ) + // full fs caching is disabled, but we still want to keep tmp files if makeTmpCopies is enabled // this is primarily used by the forks pool to avoid using process.send(bigBuffer) - if (!this.fsCacheEnabled) { + if (cachePath == null) { const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) this.recordResult(trace, result) @@ -100,11 +105,6 @@ class ModuleFetcher { }) } - const cachePath = await this.getCachePath( - environment, - moduleGraphModule, - ) - if (saveCachePromises.has(cachePath)) { return saveCachePromises.get(cachePath)!.then((result) => { this.recordResult(trace, result) @@ -159,11 +159,19 @@ class ModuleFetcher { } } - private async getCachePath(environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode) { - const memoryCacheKey = this.fsCache.getMemoryCachePath(environment, moduleGraphModule.id!) - if (memoryCacheKey != null) { + private async getCachePath(environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode): Promise { + if (!this.fsCacheEnabled) { + return null + } + const moduleId = moduleGraphModule.id! + + const memoryCacheKey = this.fsCache.getMemoryCachePath(environment, moduleId) + // undefined means there is no key in memory + // null means the file should not be cached + if (memoryCacheKey !== undefined) { return memoryCacheKey } + const fileContent = await this.readFileContentToCache(environment, moduleGraphModule) return this.fsCache.generateCachePath( this.config, From 16ffcbc4b6651e2c7f3db19e197b245f995e8edd Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 20 Nov 2025 10:58:15 +0100 Subject: [PATCH 51/53] test: add more tests --- docs/config/experimental.md | 4 +- .../vitest/src/node/cache/fsModuleCache.ts | 2 +- .../dynamic-cache-key/vitest.config.bails.js | 17 ++++++ .../fixtures/import-meta-glob/glob.test.js | 7 +++ .../import-meta-glob/vitest.config.js | 3 + .../test/defineCacheKeyGenerator.test.ts | 48 +++++++++++++++ test/cache/test/importMetaGlob.test.ts | 59 +++++++++++++++++++ test/test-utils/index.ts | 7 ++- 8 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 test/cache/fixtures/dynamic-cache-key/vitest.config.bails.js create mode 100644 test/cache/fixtures/import-meta-glob/glob.test.js create mode 100644 test/cache/fixtures/import-meta-glob/vitest.config.js create mode 100644 test/cache/test/importMetaGlob.test.ts diff --git a/docs/config/experimental.md b/docs/config/experimental.md index faf45c18f034..d99085cd5817 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -19,7 +19,9 @@ At the moment, this option does not affect [the browser](/guide/browser/). ::: ::: danger ADVANCED -If you are a plugin author, consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) if your plugin can be registered with different options that affect the transform result. +Consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) if your plugin can be registered with different options that affect the transform result. + +You can also define it in a local plugin if you are having issues with specific files caching when they shouldn't. ::: You can debug if your modules are cached by running vitest with a `DEBUG=vitest:cache:fs` environment variable: diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 4d85e21333d1..0feacc5df2a9 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -173,7 +173,7 @@ export class FileSystemModuleCache { // bail out if file has import.meta.glob because it depends on other files // TODO: figure out a way to still support it - if (fileContent.includes('import.meta.glob')) { + if (fileContent.includes('import.meta.glob(')) { this.saveMemoryCache(environment, id, null) debugMemory?.(`${c.yellow('[write]')} ${id} was bailed out`) return null diff --git a/test/cache/fixtures/dynamic-cache-key/vitest.config.bails.js b/test/cache/fixtures/dynamic-cache-key/vitest.config.bails.js new file mode 100644 index 000000000000..5c8ea37b179e --- /dev/null +++ b/test/cache/fixtures/dynamic-cache-key/vitest.config.bails.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [ + { + name: 'test:replacer', + transform(code) { + return code.replace('__REPLACED__', JSON.stringify(process.env.REPLACED)) + }, + configureVitest(ctx) { + ctx.experimental_defineCacheKeyGenerator(() => { + return false + }) + }, + }, + ], +}) diff --git a/test/cache/fixtures/import-meta-glob/glob.test.js b/test/cache/fixtures/import-meta-glob/glob.test.js new file mode 100644 index 000000000000..d0e7969bb013 --- /dev/null +++ b/test/cache/fixtures/import-meta-glob/glob.test.js @@ -0,0 +1,7 @@ +import { test, expect, inject } from 'vitest' + +test('replaced variable is the same', () => { + const files = import.meta.glob('./generated/*') + console.log(files) + expect(Object.keys(files)).toEqual(inject('generated')) +}) diff --git a/test/cache/fixtures/import-meta-glob/vitest.config.js b/test/cache/fixtures/import-meta-glob/vitest.config.js new file mode 100644 index 000000000000..abed6b2116e1 --- /dev/null +++ b/test/cache/fixtures/import-meta-glob/vitest.config.js @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({}) diff --git a/test/cache/test/defineCacheKeyGenerator.test.ts b/test/cache/test/defineCacheKeyGenerator.test.ts index e437267a9163..47b36bc02e3e 100644 --- a/test/cache/test/defineCacheKeyGenerator.test.ts +++ b/test/cache/test/defineCacheKeyGenerator.test.ts @@ -98,3 +98,51 @@ test('if cache key generator is defined, the hash is valid', async () => { } `) }) + +test('if cache key generator bails out, the file is not cached', async () => { + process.env.REPLACED = 'value1' + + const { errorTree: errorTree1 } = await runVitest({ + root: './fixtures/dynamic-cache-key', + config: './vitest.config.bails.js', + experimental: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + reporters: [ + { + async onInit(vitest) { + // make sure cache is empty + await vitest.experimental_clearCache() + }, + }, + ], + }) + + expect(errorTree1()).toMatchInlineSnapshot(` + { + "replaced.test.js": { + "replaced variable is the same": "passed", + }, + } + `) + + process.env.REPLACED = 'value2' + + const { errorTree: errorTree2 } = await runVitest({ + root: './fixtures/dynamic-cache-key', + config: './vitest.config.bails.js', + experimental: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + }) + + expect(errorTree2()).toMatchInlineSnapshot(` + { + "replaced.test.js": { + "replaced variable is the same": "passed", + }, + } + `) +}) diff --git a/test/cache/test/importMetaGlob.test.ts b/test/cache/test/importMetaGlob.test.ts new file mode 100644 index 000000000000..726f61812eb3 --- /dev/null +++ b/test/cache/test/importMetaGlob.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'vitest' +import { runVitest, useFS } from '../../test-utils' + +test('if file has import.meta.glob, it\'s not cached', async () => { + const { createFile } = useFS('./fixtures/import-meta-glob/generated', { + 1: '1', + 2: '2', + }, false) + + const { errorTree: errorTree1 } = await runVitest({ + root: './fixtures/import-meta-glob', + provide: { + generated: ['./generated/1', './generated/2'], + }, + experimental: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + }) + + expect(errorTree1()).toMatchInlineSnapshot(` + { + "glob.test.js": { + "replaced variable is the same": "passed", + }, + } + `) + + createFile('3', '3') + + const { errorTree: errorTree2 } = await runVitest({ + root: './fixtures/import-meta-glob', + provide: { + generated: [ + './generated/1', + './generated/2', + './generated/3', + ], + }, + experimental: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + }) + + expect(errorTree2()).toMatchInlineSnapshot(` + { + "glob.test.js": { + "replaced variable is the same": "passed", + }, + } + `) +}) + +declare module 'vitest' { + export interface ProvidedContext { + generated: string[] + } +} diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 9dcd8c57d76b..b62595bbdeda 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -332,10 +332,10 @@ export default config return `export default ${JSON.stringify(content)}` } -export function useFS(root: string, structure: T) { +export function useFS(root: string, structure: T, ensureConfig = true) { const files = new Set() const hasConfig = Object.keys(structure).some(file => file.includes('.config.')) - if (!hasConfig) { + if (ensureConfig && !hasConfig) { ;(structure as any)['./vitest.config.js'] = {} } for (const file in structure) { @@ -364,9 +364,10 @@ export function useFS(root: string, structure: T) { throw new Error(`file ${file} is outside of the test file system`) } const filepath = resolve(root, file) - if (!files.has(filepath)) { + if (files.has(filepath)) { throw new Error(`file ${file} already exists in the test file system`) } + files.add(filepath) createFile(filepath, content) }, statFile: (file: string): fs.Stats => { From 0bba4cffb8dc1085a051467c00bdacf9397d00f5 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 20 Nov 2025 11:09:32 +0100 Subject: [PATCH 52/53] docs: add known issues section --- docs/config/experimental.md | 44 ++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/config/experimental.md b/docs/config/experimental.md index d99085cd5817..f87fcb71ef84 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -18,18 +18,50 @@ You can delete the old cache by running [`vitest --clearCache`](/guide/cli#clear At the moment, this option does not affect [the browser](/guide/browser/). ::: -::: danger ADVANCED -Consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) if your plugin can be registered with different options that affect the transform result. - -You can also define it in a local plugin if you are having issues with specific files caching when they shouldn't. -::: - You can debug if your modules are cached by running vitest with a `DEBUG=vitest:cache:fs` environment variable: ```shell DEBUG=vitest:cache:fs vitest --experimental.fsModuleCache ``` +### Known Issues + +Vitest creates persistent file hash based on file content, its id, vite's environment configuration and coverage status. Vitest tries to use as much information it has about the configuration, but it is still incomplete. At the moment, it is not possible to track your plugin options because there is no standard interface for it. + +If you have a plugin that relies on things outside the file content or the public configuration (like reading another file or a folder), it's possible that the cache will get stale. To workaround that, you can define a [cache key generator](/api/advanced/plugin#definecachekeygenerator) to specify dynamic option or to opt-out of caching for that module: + +```js [vitest.config.js] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [ + { + name: 'vitest-cache', + configureVitest({ experimental_defineCacheKeyGenerator }) { + experimental_defineCacheKeyGenerator(({ id, sourceCode }) => { + // never cache this id + if (id.includes('do-not-cache')) { + return false + } + + // cache this file based on the value of a dynamic variable + if (sourceCode.includes('myDynamicVar')) { + return process.env.DYNAMIC_VAR_VALUE + } + }) + } + } + ], + test: { + experimental: { + fsModuleCache: true, + }, + }, +}) +``` + +If you are a plugin author, consider defining a [cache key generator](/api/advanced/plugin#definecachekeygenerator) in your plugin if it can be registered with different options that affect the transform result. + ## experimental.fsModuleCachePath 4.0.11 {#experimental-fsmodulecachepath} - **Type:** `string` From ff5cce42adb50fd4b25c144db89ec136eb54196c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 20 Nov 2025 11:25:19 +0100 Subject: [PATCH 53/53] chore: remove log --- test/cache/fixtures/import-meta-glob/glob.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/cache/fixtures/import-meta-glob/glob.test.js b/test/cache/fixtures/import-meta-glob/glob.test.js index d0e7969bb013..b0b5f62ad8c8 100644 --- a/test/cache/fixtures/import-meta-glob/glob.test.js +++ b/test/cache/fixtures/import-meta-glob/glob.test.js @@ -2,6 +2,5 @@ import { test, expect, inject } from 'vitest' test('replaced variable is the same', () => { const files = import.meta.glob('./generated/*') - console.log(files) expect(Object.keys(files)).toEqual(inject('generated')) })