diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b378204f6cb..86c4412932eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,42 @@ 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: + node_version: [24] + os: + - macos-latest + - windows-latest + 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:cache + test-browser: needs: changed name: 'Browsers: node-${{ matrix.node_version }}, ${{ matrix.os }}' 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/docs/api/advanced/plugin.md b/docs/api/advanced/plugin.md index 6620f14de02e..11bebbcaac04 100644 --- a/docs/api/advanced/plugin.md +++ b/docs/api/advanced/plugin.md @@ -123,3 +123,51 @@ 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} + +```ts +interface CacheKeyIdGeneratorContext { + environment: DevEnvironment + id: string + sourceCode: string +} + +function experimental_defineCacheKeyGenerator( + callback: (context: CacheKeyIdGeneratorContext) => string | undefined | null | false +): 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 + }) + } + } +} +``` + +If the `false` is returned, the module will not be cached on the file system. diff --git a/docs/api/advanced/vitest.md b/docs/api/advanced/vitest.md index 127320209227..ffc32a80519a 100644 --- a/docs/api/advanced/vitest.md +++ b/docs/api/advanced/vitest.md @@ -607,3 +607,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.11 experimental {#clearcache} + +```ts +function experimental_clearCache(): Promise +``` + +Deletes all Vitest caches, including [`experimental.fsModuleCache`](/config/experimental#fsmodulecache). diff --git a/docs/config/experimental.md b/docs/config/experimental.md index 179b462bfae3..f87fcb71ef84 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -5,7 +5,75 @@ outline: deep # experimental -## openTelemetry 4.0.10 +## 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`](/guide/cli#clearcache). + +::: warning BROWSER SUPPORT +At the moment, this option does not affect [the browser](/guide/browser/). +::: + +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` +- **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/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/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. diff --git a/package.json b/package.json index a19e5eed04b6..1604d75ce096 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 --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/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 6cc6ba50fdb7..56e230771b32 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -50,15 +50,23 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider() + 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 + // surprisingly, on some machines this has negligible effect + private fsCacheKeys = new WeakMap< + DevEnvironment, + // Map + Map + >() + + 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(log = true): Promise { + const fsCachePaths = this.vitest.projects.map((r) => { + 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 })), + ) + if (log) { + this.vitest.logger.log(`[cache] cleared fs module cache at ${uniquePaths.join(', ')}`) + } + } + + async getCachedModule(cachedFilePath: string): Promise< + CachedInlineModuleMeta + | Extract + | undefined + > { + if (!existsSync(cachedFilePath)) { + 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) { + 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) { + debugFs?.(`${c.green('[read]')} ${meta.externalize} is externalized inside ${cachedFilePath}`) + return { externalize: meta.externalize, type: meta.type } + } + debugFs?.(`${c.green('[read]')} ${meta.id} is cached in ${cachedFilePath}`) + + return { + id: meta.id, + url: meta.url, + file: meta.file, + code, + importers: meta.importers, + mappings: meta.mappings, + } + } + + async saveCachedModule( + cachedFilePath: string, + fetchResult: T, + importers: string[] = [], + mappings: boolean = false, + ): Promise { + if ('externalize' in fetchResult) { + debugFs?.(`${c.yellow('[write]')} ${fetchResult.externalize} is externalized inside ${cachedFilePath}`) + await atomicWriteFile(cachedFilePath, `${cacheComment}${this.toBase64(fetchResult)}`) + } + else if ('code' in fetchResult) { + const result = { + file: fetchResult.file, + id: fetchResult.id, + url: fetchResult.url, + importers, + mappings, + } satisfies Omit + debugFs?.(`${c.yellow('[write]')} ${fetchResult.id} is cached in ${cachedFilePath}`) + await atomicWriteFile(cachedFilePath, `${fetchResult.code}${cacheComment}${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) + } + + invalidateCachePath( + environment: DevEnvironment, + id: string, + ): void { + debugFs?.(`cache for ${id} in ${environment.name} environment is invalidated`) + this.fsCacheKeys.get(environment)?.delete(id) + } + + invalidateAllCachePaths(environment: DevEnvironment): void { + debugFs?.(`the ${environment.name} environment cache is invalidated`) + this.fsCacheKeys.get(environment)?.clear() + } + + getMemoryCachePath( + environment: DevEnvironment, + id: string, + ): string | null | undefined { + const result = this.fsCacheKeys.get(environment)?.get(id) + 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 + } + + generateCachePath( + vitestConfig: ResolvedConfig, + environment: DevEnvironment, + resolver: VitestResolver, + id: string, + fileContent: 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 + 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), + // 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, + // this affect externalization + resolver: { + inline: resolver.options.inline, + external: resolver.options.external, + inlineFiles: resolver.options.inlineFiles, + moduleDirectories: resolver.options.moduleDirectories, + }, + }, + (_, value) => { + if (typeof value === 'function' || value instanceof RegExp) { + return value.toString() + } + return value + }, + ) + this.fsEnvironmentHashMap.set(environment, cacheConfig) + } + + hashString += id + + fileContent + + (process.env.NODE_ENV ?? '') + + this.version + + cacheConfig + + coverageAffectsCache + + const cacheKey = hash('sha1', hashString, 'hex') + + let cacheRoot = this.fsCacheRoots.get(vitestConfig) + if (cacheRoot == null) { + cacheRoot = vitestConfig.experimental.fsModuleCachePath || this.rootCache + if (!existsSync(cacheRoot)) { + mkdirSync(cacheRoot, { recursive: true }) + } + } + + 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) + } + environmentKeys.set(id, cache) + } + + 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 cache config file content, but that won't catch changes made in external plugins + 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`) + } +} + +/** + * 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 {} + } +} + +export interface CachedInlineModuleMeta { + url: string + id: string + file: string | null + code: string + importers: string[] + mappings: boolean +} + +/** + * Generate a unique cache identifier. + * + * Return `false` to disable caching of the file. + * @experimental + */ +export interface CacheKeyIdGenerator { + (context: CacheKeyIdGeneratorContext): string | undefined | null | false +} + +/** + * @experimental + */ +export interface CacheKeyIdGeneratorContext { + environment: DevEnvironment + id: string + sourceCode: string +} + +// 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 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 + return fs.statSync(file, { throwIfNoEntry: false }) + } + catch { + // Ignore errors + } +} 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..48b524e2233c 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.experimental_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 c7a46141a777..49a93d963bca 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -774,8 +774,21 @@ 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: null, + experimental: { + description: 'Experimental features.', + argument: '', + subcommands: { + fsModuleCache: { + description: 'Enable caching of modules on the file system between reruns.', + }, + fsModuleCachePath: null, + openTelemetry: null, + }, + }, // disable CLI options cliExclude: null, server: null, 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/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 04baa15963d5..672b64532555 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -130,5 +130,8 @@ export function serializeConfig(project: TestProject): SerializedConfig { serializedDefines: config.browser.enabled ? '' : project._serializedDefines || '', + experimental: { + fsModuleCache: config.experimental.fsModuleCache ?? false, + }, } } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index be59f4669e9a..fb22d5e57b8c 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -28,6 +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/fsModuleCache' import { resolveConfig } from './config/resolveConfig' import { getCoverageProvider } from './coverage' import { createFetchModuleFunction } from './environments/fetchModule' @@ -110,6 +111,7 @@ export class Vitest { /** @internal */ _testRun: TestRun = undefined! /** @internal */ _resolver!: VitestResolver /** @internal */ _fetcher!: VitestFetchFunction + /** @internal */ _fsCache!: FileSystemModuleCache /** @internal */ _tmpDir = join(tmpdir(), nanoid()) /** @internal */ _traces!: Traces @@ -210,7 +212,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) const otelSdkPath = resolved.experimental.openTelemetry?.sdkPath @@ -225,14 +227,13 @@ export class Vitest { } this._resolver = new VitestResolver(server.config.cacheDir, resolved) + this._fsCache = new FileSystemModuleCache(this) this._fetcher = createFetchModuleFunction( this._resolver, + this._config, + this._fsCache, this._traces, this._tmpDir, - { - dumpFolder: this.config.dumpDir, - readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, - }, ) const environment = server.environments.__vitest__ this.runner = new ServerModuleRunner( @@ -280,6 +281,10 @@ export class Vitest { project, vitest: this, injectTestProjects: this.injectTestProject, + /** + * @experimental + */ + experimental_defineCacheKeyGenerator: callback => this._fsCache.defineCacheKeyGenerator(callback), })) })) @@ -315,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(), @@ -334,11 +341,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() @@ -494,6 +522,15 @@ export class Vitest { return this._coverageProvider } + /** + * Deletes all Vitest caches, including `experimental.fsModuleCache`. + * @experimental + */ + public async experimental_clearCache(): Promise { + await this.cache.results.clearCache() + await this._fsCache.clearCache() + } + /** * Merge reports from multiple runs located in the specified directory (value from `--merge-reports` if not specified). */ @@ -1182,9 +1219,17 @@ export class Vitest { ...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) + 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 c7f17c0876e6..2cbbc946649b 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -1,204 +1,345 @@ import type { Span } from '@opentelemetry/api' -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 { OTELCarrier, Traces } from '../../utils/traces' +import type { FileSystemModuleCache } from '../cache/fsModuleCache' import type { VitestResolver } from '../resolver' +import type { ResolvedConfig } from '../types/config' 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 { 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 created = new Set() -const promises = new Map>() +const saveCachePromises = new Map>() +const readFilePromises = new Map>() -interface DumpOptions { - dumpFolder?: string - readFromDump?: boolean -} +class ModuleFetcher { + private tmpDirectories = new Set() + private fsCacheEnabled: boolean -export interface VitestFetchFunction { - ( - url: string, - importer: string | undefined, - environment: DevEnvironment, - cacheFs: boolean, - options?: FetchFunctionOptions, - otelCarrier?: OTELCarrier - ): Promise -} + constructor( + private resolver: VitestResolver, + private config: ResolvedConfig, + private fsCache: FileSystemModuleCache, + private traces: Traces, + private tmpProjectDir: string, + ) { + this.fsCacheEnabled = config.experimental?.fsModuleCache === true + } -export function createFetchModuleFunction( - resolver: VitestResolver, - traces: Traces, - tmpDir: string = join(tmpdir(), nanoid()), - dump?: DumpOptions, -): VitestFetchFunction { - const fetcher = async ( - fetcherSpan: Span, + async fetch( + trace: Span, url: string, importer: string | undefined, environment: DevEnvironment, - cacheFs: boolean, + makeTmpCopies?: boolean, options?: FetchFunctionOptions, - ): Promise => { - // 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) + ): Promise { if (url.startsWith('data:')) { - fetcherSpan.setAttribute('vitest.module.external', url) + trace.setAttribute('vitest.module.external', url) return { externalize: url, type: 'builtin' } } if (url === '/@vite/client' || url === '@vite/client') { - fetcherSpan.setAttribute('vitest.module.external', url) - // this will be stubbed + trace.setAttribute('vitest.module.external', url) return { externalize: '/@vite/client', type: 'module' } } const isFileUrl = url.startsWith('file://') if (isExternalUrl(url) && !isFileUrl) { - fetcherSpan.setAttribute('vitest.module.external', url) + trace.setAttribute('vitest.module.external', url) 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 (moduleGraphModule.file) { - fetcherSpan.setAttribute('code.file.path', moduleGraphModule.file) + trace.setAttribute('code.file.path', moduleGraphModule.file) } - // if url is already cached, we can just confirm it's also cached on the server if (options?.cached && cached) { return { cache: true } } - if (moduleGraphModule.id) { - const id = moduleGraphModule.id - const externalize = await resolver.shouldExternalize(id) - if (externalize) { - fetcherSpan.setAttribute('vitest.module.external', externalize) - return { externalize, type: 'module' } + 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 (cachePath == null) { + const result = await this.fetchAndProcess(environment, url, importer, moduleGraphModule, options) + + this.recordResult(trace, result) + + if (!makeTmpCopies || !('code' in result)) { + return result } - } - fetcherSpan.setAttribute('vitest.module.external', false) - - 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, - } + 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 + }) } - if (!moduleRunnerModule) { - moduleRunnerModule = await fetchModule( - environment, - url, - importer, - { - ...options, - inlineSourceMap: false, - }, - ).catch(handleRollupError) + if (saveCachePromises.has(cachePath)) { + return saveCachePromises.get(cachePath)!.then((result) => { + this.recordResult(trace, result) + return result + }) + } + + const cachedModule = await this.getCachedModule(cachePath, environment, moduleGraphModule) + if (cachedModule) { + this.recordResult(trace, cachedModule) + return cachedModule } - if ('id' in moduleRunnerModule) { - fetcherSpan.setAttributes({ - 'vitest.fetched_module.invalidate': moduleRunnerModule.invalidate, - 'vitest.fetched_module.code_length': moduleRunnerModule.code.length, - 'vitest.fetched_module.id': moduleRunnerModule.id, - 'vitest.fetched_module.url': moduleRunnerModule.url, + 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, !!mappings) + } + + private getSerializedDependencies(node: EnvironmentModuleNode): string[] { + const dependencies: string[] = [] + node.importers.forEach((importer) => { + if (importer.id) { + dependencies.push(importer.id) + } + }) + return dependencies + } + + 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 (moduleRunnerModule.file) { - fetcherSpan.setAttribute('code.file.path', moduleRunnerModule.file) + if (result.file) { + trace.setAttribute('code.file.path', result.file) } } - else if ('cache' in moduleRunnerModule) { - fetcherSpan.setAttribute('vitest.fetched_module.cache', moduleRunnerModule.cache) - } - else { - fetcherSpan.setAttribute('vitest.fetched_module.type', moduleRunnerModule.type) - fetcherSpan.setAttribute('vitest.fetched_module.external', moduleRunnerModule.externalize) + if ('code' in result) { + trace.setAttribute('vitest.fetched_module.code_length', result.code.length) } + } - const result = processResultSource(environment, moduleRunnerModule) + private async getCachePath(environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode): Promise { + if (!this.fsCacheEnabled) { + return null + } + const moduleId = moduleGraphModule.id! - 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') + 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 } - if (!cacheFs || !('code' in result)) { - return result + 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, + ): Promise { + 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 code = result.code - const transformResult = result.transformResult! - if (!transformResult) { - throw new Error(`"transformResult" in not defined. This is a bug in Vitest.`) + const loadResult = await environment.pluginContainer.load(moduleGraphModule.id!) + if (typeof loadResult === 'string') { + return loadResult } - // 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 (loadResult != null) { + return loadResult.code } - 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) + return '' + } + + private async getCachedModule( + cachePath: string, + environment: DevEnvironment, + moduleGraphModule: EnvironmentModuleNode, + ): Promise { + const cachedModule = await this.fsCache.getCachedModule(cachePath) + + if (cachedModule && 'code' in cachedModule) { + // keep the module graph in sync + if (!moduleGraphModule.transformResult) { + let map: Rollup.SourceMap | null | { mappings: '' } = extractSourceMap(cachedModule.code) + if (map && cachedModule.file) { + map.file = cachedModule.file + } + // 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) => { + const environmentNode = environment.moduleGraph.getModuleById(importer) + if (environmentNode) { + moduleGraphModule.importers.add(environmentNode) + } + }) + } + return { + cached: true as const, + file: cachedModule.file, + id: cachedModule.id, + tmp: cachePath, + url: cachedModule.url, + invalidate: false, + } } - if (promises.has(tmp)) { - await promises.get(tmp) - return getCachedResult(result, tmp) + + 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' } } - promises.set( - tmp, - - atomicWriteFile(tmp, code) - // Fallback to non-atomic write for windows case where file already exists: - .catch(() => writeFile(tmp, code, 'utf-8')) - .finally(() => { - Reflect.set(transformResult, '_vitestTmp', tmp) - promises.delete(tmp) + + const moduleRunnerModule = await fetchModule( + environment, + url, + importer, + { + ...options, + inlineSourceMap: false, + }, + ).catch(handleRollupError) + + return processResultSource(environment, moduleRunnerModule) + } + + private async cacheResult( + result: FetchResult, + cachePath: string, + importers: string[] = [], + mappings = false, + ): Promise { + const returnResult = 'code' in result + ? getCachedResult(result, cachePath) + : result + + if (saveCachePromises.has(cachePath)) { + await saveCachePromises.get(cachePath) + return returnResult + } + + const savePromise = this.fsCache + .saveCachedModule(cachePath, result, importers, mappings) + .then(() => 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, + // virtual file can have a "file" property + readFile(file, 'utf-8').catch(() => null).finally(() => { + readFilePromises.delete(file) }), - ) - await promises.get(tmp) - return getCachedResult(result, tmp) + ) + } + return readFilePromises.get(file)! } - return async ( - url, - importer, - environment, - cacheFs, - options, - otelCarrier, - ) => { +} + +export interface VitestFetchFunction { + ( + url: string, + importer: string | undefined, + environment: DevEnvironment, + cacheFs?: boolean, + options?: FetchFunctionOptions, + otelCarrier?: OTELCarrier + ): Promise +} + +export function createFetchModuleFunction( + resolver: VitestResolver, + config: ResolvedConfig, + fsCache: FileSystemModuleCache, + traces: Traces, + tmpProjectDir: string, +): VitestFetchFunction { + 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) @@ -208,7 +349,7 @@ export function createFetchModuleFunction( context ? { context } : {}, - span => fetcher(span, url, importer, environment, cacheFs, options), + span => fetcher.fetch(span, url, importer, environment, cacheFs, options), ) } } @@ -218,9 +359,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 } @@ -235,7 +374,6 @@ function processResultSource(environment: DevEnvironment, result: FetchResult): return { ...result, code: node?.transformResult?.code || result.code, - transformResult: node?.transformResult, } } @@ -299,6 +437,32 @@ 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 + } + 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;')) { + // 9 because we want to only remove "AAAA,CAAA", but keep ; at the start + sourceMap.mappings = sourceMap.mappings.slice(9) + } + return sourceMap +} + // serialize rollup error on server to preserve details as a test error export function handleRollupError(e: unknown): never { if ( @@ -321,35 +485,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/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) { diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts index 8d467d0e6290..4210f37175d4 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,16 @@ export function WorkspaceVitestPlugin( } } + const vitestConfig: UserConfig = { + name: { label: name, color }, + } + + // 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 { base: '/', environments: { @@ -100,9 +110,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 9ada72ee567e..4601e8a2f02a 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -561,12 +561,10 @@ export class TestProject { this._serializedDefines = createDefinesScript(server.config.define) this._fetcher = createFetchModuleFunction( this._resolver, + this._config, + this.vitest._fsCache, this.vitest._traces, this.tmpDir, - { - dumpFolder: this.config.dumpDir, - readFromDump: this.config.server.debug?.load ?? process.env.VITEST_DEBUG_LOAD_DUMP != null, - }, ) const environment = server.environments.__vitest__ diff --git a/packages/vitest/src/node/resolver.ts b/packages/vitest/src/node/resolver.ts index 63c4877b9552..7c4813a996f1 100644 --- a/packages/vitest/src/node/resolver.ts +++ b/packages/vitest/src/node/resolver.ts @@ -9,12 +9,21 @@ 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) { + // 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, } } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index c9a5c5e2cd9e..c09b4c3b01d1 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -826,8 +826,21 @@ export interface InlineConfig { */ attachmentsDir?: string - /** @experimental */ + /** + * Experimental features + * + * @experimental + */ experimental?: { + /** + * 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 node_modules/.experimental-vitest-cache + */ + fsModuleCachePath?: string /** * {@link https://vitest.dev/guide/open-telemetry} */ @@ -965,6 +978,12 @@ export interface UserConfig extends InlineConfig { * @default '.vitest-reports' */ mergeReports?: string + + /** + * Delete all Vitest caches, including `experimental.fsModuleCache`. + * @experimental + */ + clearCache?: boolean } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void 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 } diff --git a/packages/vitest/src/node/types/plugin.ts b/packages/vitest/src/node/types/plugin.ts index 012084d8417c..4eebe8276e3a 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/fsModuleCache' 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 } 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' diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 3fc4cf1fc9f2..a71095a6e382 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -117,6 +117,9 @@ export interface SerializedConfig { includeSamples: boolean } | undefined serializedDefines: string + experimental: { + fsModuleCache: boolean + } } export interface SerializedCoverageConfig { 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': 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.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/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/fixtures/import-meta-glob/glob.test.js b/test/cache/fixtures/import-meta-glob/glob.test.js new file mode 100644 index 000000000000..b0b5f62ad8c8 --- /dev/null +++ b/test/cache/fixtures/import-meta-glob/glob.test.js @@ -0,0 +1,6 @@ +import { test, expect, inject } from 'vitest' + +test('replaced variable is the same', () => { + const files = import.meta.glob('./generated/*') + 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/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..47b36bc02e3e --- /dev/null +++ b/test/cache/test/defineCacheKeyGenerator.test.ts @@ -0,0 +1,148 @@ +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, + 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.fails.js', + experimental: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + }) + + 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, + 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.passes.js', + experimental: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + }) + + expect(errorTree2()).toMatchInlineSnapshot(` + { + "replaced.test.js": { + "replaced variable is the same": "passed", + }, + } + `) +}) + +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/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/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) +}) diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index c3fdaff0c559..b62595bbdeda 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -1,8 +1,16 @@ 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 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' @@ -70,6 +78,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 +98,11 @@ export async function runVitest( NO_COLOR: 'true', ...rest.env, }, + // override cache config with the one that was used to run `vitest` formt the CLI + experimental: { + fsModuleCache: currentConfig.experimental.fsModuleCache, + ...rest.experimental, + }, }, { ...viteOverrides, server: { @@ -141,6 +156,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() || []) }, @@ -306,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) { @@ -338,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 => { @@ -392,7 +419,7 @@ export class StableTestFileOrderSorter { } } -function buildTestTree(testModules: TestModule[]) { +function buildTestTree(testModules: TestModule[], onResult?: (result: TestResult) => unknown) { type TestTree = Record function walkCollection(collection: TestCollection): TestTree { @@ -406,7 +433,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 + } } }