From 9673c49a40535529db6042df7176f123e95bb8f5 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 2 Oct 2026 18:07:03 +0200 Subject: [PATCH] fix: refactor caching, fix `:` in project and change the shape of `results` cache (#11453) --- docs/.vitepress/scripts/cli-generator.ts | 2 +- docs/api/advanced/vitest.md | 35 +- docs/config/cache.md | 15 +- docs/config/deps.md | 2 +- docs/guide/cli-generated.md | 7 + packages/vitest/src/node/cache/files.ts | 40 -- .../vitest/src/node/cache/fsModuleCache.ts | 2 +- packages/vitest/src/node/cache/index.ts | 88 ++- packages/vitest/src/node/cache/results.ts | 265 ++++++--- packages/vitest/src/node/cli/cli-config.ts | 18 +- .../vitest/src/node/config/resolveConfig.ts | 5 +- packages/vitest/src/node/core.ts | 41 +- packages/vitest/src/node/plugins/utils.ts | 12 +- packages/vitest/src/node/project.ts | 2 +- .../src/node/sequencers/BaseSequencer.ts | 42 +- packages/vitest/src/node/types/config.ts | 9 +- packages/vitest/src/node/watcher.ts | 2 - packages/vitest/src/public/node.ts | 1 + test/e2e/fixtures/cache/basic.test.ts | 5 - test/e2e/fixtures/cache/second.test.ts | 5 - test/e2e/test/cache.test.ts | 513 +++++++++++++----- test/typescript/test/cache.test.ts | 63 +++ test/unit/test/cli-test.test.ts | 4 +- test/unit/test/sequencers.test.ts | 94 +--- 24 files changed, 828 insertions(+), 444 deletions(-) delete mode 100644 packages/vitest/src/node/cache/files.ts delete mode 100644 test/e2e/fixtures/cache/basic.test.ts delete mode 100644 test/e2e/fixtures/cache/second.test.ts create mode 100644 test/typescript/test/cache.test.ts diff --git a/docs/.vitepress/scripts/cli-generator.ts b/docs/.vitepress/scripts/cli-generator.ts index 68ebcdf80..717b037b5 100644 --- a/docs/.vitepress/scripts/cli-generator.ts +++ b/docs/.vitepress/scripts/cli-generator.ts @@ -62,7 +62,7 @@ function resolveCommand(name: string, config: CLIOption | null): any { title += `-${config.shorthand}, ` } title += `--${config.alias || name}` - if ('argument' in config) { + if ('argument' in config && config.argument) { title += ` ${config.argument}` } title += '`' diff --git a/docs/api/advanced/vitest.md b/docs/api/advanced/vitest.md index 923b99f92..2e781faa6 100644 --- a/docs/api/advanced/vitest.md +++ b/docs/api/advanced/vitest.md @@ -44,7 +44,40 @@ You can get the latest summary of snapshots via the `vitest.snapshot.summary` pr ## cache -Cache manager that stores information about latest test results and test file stats. In Vitest itself this is only used by the default sequencer to sort tests. +Cache manager that stores the results of the previous test runs. In Vitest itself this is only used by the default sequencer to sort tests. + +### cache.getTestSpecificationResult 5.0.4 {#cache-gettestspecificationresult} + +Returns the result of the test file from the previous test runs, or `undefined` if Vitest has no result for it. + +```ts +function getTestSpecificationResult( + specification: TestSpecification +): CachedTestFileResult | undefined + +interface CachedTestFileResult { + /** + * The file has a known failure. + * Only a complete run of the file clears it. + */ + failed: boolean + /** + * Duration of the last complete run in milliseconds. + */ + duration: number + /** + * Unix timestamp in milliseconds of the start of the last complete run. + * It is not set if the file never ran completely. + */ + lastRun?: number +} +``` + +A run is not complete if it was cancelled or if it executed only a part of the file, for example, because of a test name pattern, a line filter or `.only`. + +::: warning +`vitest.cache.getFileTestResults` and `vitest.cache.getFileStats` are deprecated and will be removed in the next major version. Use `getTestSpecificationResult` instead of `getFileTestResults`. Vitest does not cache file sizes anymore; read the size from the file system if your sequencer needs it. +::: ## watcher 4.0.0 {#watcher} diff --git a/docs/config/cache.md b/docs/config/cache.md index 2c7058e6d..95da25190 100644 --- a/docs/config/cache.md +++ b/docs/config/cache.md @@ -5,10 +5,15 @@ outline: deep # cache -- **Type:** `false` -- **CLI:** `--no-cache`, `--cache=false` +- **Type:** `boolean` +- **Default:** `true` +- **CLI:** `--cache`, `--no-cache` -Use this option if you want to disable the cache feature. At the moment Vitest stores cache for test results to run the longer and failed tests first. +Store the results of test runs on the file system. Vitest uses them to run failed and longer test files first. + +For every test file, Vitest stores whether the file failed, how long it ran and when it ran last. A run that did not execute the whole file (for example, a run filtered with [`--testNamePattern`](/config/testnamepattern) or cancelled by [`bail`](/config/bail)) can mark the file as failed, but it cannot mark it as passed. + +You can delete the cache by running [`vitest --clearCache`](/guide/cli#clearcache). The cache directory is controlled by the Vite's [`cacheDir`](https://vitejs.dev/config/shared-options.html#cachedir) option: @@ -29,3 +34,7 @@ export default defineConfig({ cacheDir: process.env.VITEST ? 'custom-folder/.vitest' : undefined }) ``` + +::: warning +The deprecated `cache.dir` option has no effect anymore. Use `cacheDir` to change the cache directory. +::: diff --git a/docs/config/deps.md b/docs/config/deps.md index c72a2238d..dad21d838 100644 --- a/docs/config/deps.md +++ b/docs/config/deps.md @@ -28,7 +28,7 @@ Be aware that only packages in `deps.optimizer?.[mode].include` option are bundl This options also inherits your `optimizeDeps` configuration (for web Vitest will extend `optimizeDeps`, for ssr - `ssr.optimizeDeps`). If you redefine `include`/`exclude` option in `deps.optimizer` it will extend your `optimizeDeps` when running tests. Vitest automatically removes the same options from `include`, if they are listed in `exclude`. ::: tip -You will not be able to edit your `node_modules` code for debugging, since the code is actually located in your `cacheDir` or `test.cache.dir` directory. If you want to debug with `console.log` statements, edit it directly or force rebundling with `deps.optimizer?.[mode].force` option. +You will not be able to edit your `node_modules` code for debugging, since the code is actually located in your `cacheDir` directory. If you want to debug with `console.log` statements, edit it directly or force rebundling with `deps.optimizer?.[mode].force` option. ::: ### deps.optimizer.{mode}.enabled diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index d1e405554..39e8bfdd1 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -812,6 +812,13 @@ Threshold in milliseconds for a test or suite to be considered slow (default: `3 Default timeout of a teardown function in milliseconds (default: `10000`) +### cache + +- **CLI:** `--cache` +- **Config:** [cache](/config/cache) + +Store the results of test runs to run failed and longer test files first (default: `true`) + ### maxConcurrency - **CLI:** `--maxConcurrency ` diff --git a/packages/vitest/src/node/cache/files.ts b/packages/vitest/src/node/cache/files.ts deleted file mode 100644 index 17bebf2ed..000000000 --- a/packages/vitest/src/node/cache/files.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Stats } from 'node:fs' -import type { TestSpecification } from '../test-specification' -import fs from 'node:fs' -import { relative } from 'pathe' - -type FileStatsCache = Pick - -export class FilesStatsCache { - public cache: Map = new Map() - - public getStats(key: string): FileStatsCache | undefined { - return this.cache.get(key) - } - - public async populateStats(root: string, specs: TestSpecification[]): Promise { - const promises = specs.map((spec) => { - const key = `${spec.project.name}:${relative(root, spec.moduleId)}` - return this.updateStats(spec.moduleId, key) - }) - await Promise.all(promises) - } - - public async updateStats(fsPath: string, key: string): Promise { - try { - const stats = await fs.promises.stat(fsPath) - this.cache.set(key, { size: stats.size }) - } catch { - // the file can be deleted while the stat is in flight; a file - // without stats only loses sorting heuristics - } - } - - public removeStats(fsPath: string): void { - this.cache.forEach((_, key) => { - if (key.endsWith(fsPath)) { - this.cache.delete(key) - } - }) - } -} diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 44677d2f4..e20516f18 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -390,7 +390,7 @@ export class FileSystemModuleCache { * * Added in https://github.com/vitest-dev/vitest/pull/7531 */ -async function atomicWriteFile(realFilePath: string, data: string): Promise { +export 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)}`) diff --git a/packages/vitest/src/node/cache/index.ts b/packages/vitest/src/node/cache/index.ts index 4ce638e03..b7fc74712 100644 --- a/packages/vitest/src/node/cache/index.ts +++ b/packages/vitest/src/node/cache/index.ts @@ -1,33 +1,83 @@ -import type { Logger } from '../logger' -import type { SuiteResultCache } from './results' -import { slash } from '@vitest/utils/helpers' +import type { Vitest } from '../core' +import type { TestSpecification } from '../test-specification' +import type { CachedTestFileResult } from './results' +import { statSync } from 'node:fs' import { resolve } from 'pathe' -import { hash } from '../hash' -import { FilesStatsCache } from './files' +import { FileSystemModuleCache } from './fsModuleCache' import { ResultsCache } from './results' export class VitestCache { - results: ResultsCache - stats: FilesStatsCache = new FilesStatsCache() + /** @internal */ + _results: ResultsCache + /** @internal */ + _modules: FileSystemModuleCache - constructor(logger: Logger) { - this.results = new ResultsCache(logger) + private fileStats = new Map() + private warnedMethods = new Set() + + /** @internal */ + constructor(private vitest: Vitest) { + this._results = new ResultsCache(vitest) + this._modules = new FileSystemModuleCache(vitest) + } + + /** + * Returns the result of the test file from the previous test runs. + */ + getTestSpecificationResult(specification: TestSpecification): CachedTestFileResult | undefined { + return this._results.get(specification) + } + + /** + * @deprecated Use `getTestSpecificationResult(specification)` instead. + */ + getFileTestResults(key: string): CachedTestFileResult | undefined { + this.deprecate('getFileTestResults', 'Use "getTestSpecificationResult(specification)" instead.') + return this._results.getByLegacyKey(key) + } + + /** + * @deprecated Vitest does not cache file sizes anymore. Read the size from the file system instead. + */ + getFileStats(key: string): { size: number } | undefined { + this.deprecate('getFileStats', 'Read the size from the file system instead.') + if (!this.fileStats.has(key)) { + this.fileStats.set(key, this.readFileStats(key)) + } + return this.fileStats.get(key) + } + + /** @internal */ + async _load(): Promise { + await Promise.all([this._results.read(), this._modules.ensureCacheIntegrity()]) } - getFileTestResults(key: string): SuiteResultCache | undefined { - return this.results.getResults(key) + /** @internal */ + async _clear(): Promise { + await this._results.clear() + await this._modules.clearCache() } - getFileStats(key: string): - | { - size: number + private readFileStats(key: string): { size: number } | undefined { + for (const project of this.vitest.projects) { + const prefix = `${project.name}:` + if (!key.startsWith(prefix)) { + continue } - | undefined { - return this.stats.getStats(key) + try { + const file = resolve(this.vitest.config.root, key.slice(prefix.length)) + const stats = statSync(file, { throwIfNoEntry: false }) + if (stats) { + return { size: stats.size } + } + } catch {} + } } - static resolveCacheDir(root: string, dir?: string, projectName?: string): string { - const baseDir = slash(dir || 'node_modules/.vite') - return resolve(root, baseDir, 'vitest', hash('sha1', projectName || '', 'hex')) + private deprecate(method: string, message: string): void { + if (!this.warnedMethods.has(method)) { + this.warnedMethods.add(method) + this.vitest.logger.deprecate(`"vitest.cache.${method}" is deprecated. ${message}`) + } } } diff --git a/packages/vitest/src/node/cache/results.ts b/packages/vitest/src/node/cache/results.ts index 83ee9352a..830cc9155 100644 --- a/packages/vitest/src/node/cache/results.ts +++ b/packages/vitest/src/node/cache/results.ts @@ -1,116 +1,219 @@ import type { File } from '../../runtime/runner/types' -import type { Logger } from '../logger' -import type { ResolvedConfig } from '../types/config' -import fs, { existsSync } from 'node:fs' -import { rm } from 'node:fs/promises' +import type { Vitest } from '../core' +import type { TestSpecification } from '../test-specification' +import { existsSync } from 'node:fs' +import { mkdir, readFile, rm, stat } from 'node:fs/promises' import { dirname, relative, resolve } from 'pathe' -import { Vitest } from '../core' +import { createDebugger } from '../../utils/debugger' +import { atomicWriteFile } from './fsModuleCache' -export interface SuiteResultCache { +const debug = createDebugger('vitest:cache:results') + +const RESULTS_VERSION = 1 + +export interface CachedTestFileResult { + /** + * The file has a known failure. Only a complete run of the file clears it. + */ failed: boolean + /** + * Duration of the last complete run in milliseconds. + * A file that never ran completely keeps the duration of its first recorded run. + */ duration: number + /** + * Unix timestamp in milliseconds of the start of the last complete run. + * It is not set if the file never ran completely. + */ + lastRun?: number } -export class ResultsCache { - private cache = new Map() - private workspacesKeyMap = new Map() - private cachePath: string | null = null - private version: string - private root = '/' - - constructor(private logger: Logger) { - this.version = Vitest.version - } - - public getCachePath(): string | null { - return this.cachePath - } +interface ProjectResults { + files: Map + typecheck: Map +} - setConfig(root: string, config: ResolvedConfig['cache']): void { - this.root = root - if (config) { - this.cachePath = resolve(config.dir, 'results.json') +interface SerializedResults { + version: number + projects: Record< + string, + { + files?: Record + typecheck?: Record } + > +} + +export class ResultsCache { + private projects = new Map() + private legacyResults: Map | undefined + private recordedTasks = new WeakSet() + private path: string | null + // the version of the file that this process read or wrote last + private fileStamp: string | undefined + + constructor(private vitest: Vitest) { + this.path = vitest.config.cache ? resolve(vitest.viteConfig.cacheDir, 'results.json') : null } - getResults(key: string): SuiteResultCache | undefined { - return this.cache.get(key) + get(specification: TestSpecification): CachedTestFileResult | undefined { + const project = this.projects.get(specification.project.name) + const results = specification.pool === 'typescript' ? project?.typecheck : project?.files + return results?.get(this.getFilePath(specification)) } - 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) + getByLegacyKey(key: string): CachedTestFileResult | undefined { + if (!this.legacyResults) { + this.legacyResults = new Map() + for (const [name, { files }] of this.projects) { + for (const [file, result] of files) { + this.legacyResults.set(`${name}:${file}`, result) + } + } } + return this.legacyResults.get(key) } - async readFromCache(): Promise { - if (!this.cachePath) { + async read(): Promise { + const path = this.path + const stamp = path ? await getFileStamp(path) : undefined + // the file does not exist, or this process already has its content + if (!path || !stamp || stamp === this.fileStamp) { return } + this.fileStamp = stamp + try { + const { version, projects }: SerializedResults = JSON.parse(await readFile(path, 'utf-8')) + if (version !== RESULTS_VERSION) { + debug?.(`ignored ${this.path}, the version ${version} is not supported`) + return + } + const results = new Map() + for (const name in projects) { + const { files, typecheck } = projects[name] + results.set(name, { + files: new Map(Object.entries(files ?? {})), + typecheck: new Map(Object.entries(typecheck ?? {})), + }) + } + this.projects = results + this.legacyResults = undefined + } catch (error) { + debug?.(`failed to read ${this.path}: ${error}`) + } + } - if (!fs.existsSync(this.cachePath)) { - return + async update(specifications: TestSpecification[], startTime: number): Promise { + // another process could write new results after this one read the file + await this.read() + + let changed = false + for (const specification of specifications) { + const task = specification.testModule?.task + const state = task?.result?.state + // a file that did not start in this run still has the task of a previous run + if (!task || this.recordedTasks.has(task) || !isFinalState(state)) { + continue + } + this.recordedTasks.add(task) + + const project = this.getProjectResults(specification.project.name) + const results = task.meta.typecheck ? project.typecheck : project.files + const file = this.getFilePath(specification) + const previous = results.get(file) + const failed = state === 'fail' + const duration = Math.max(task.result?.duration ?? 0, 0) + + if (!this.isPartialRun(specification, task)) { + results.set(file, { failed, duration, lastRun: startTime }) + } else if (failed) { + results.set(file, { duration, ...previous, failed }) + } else if (!previous) { + results.set(file, { failed, duration }) + } else { + continue + } + changed = true } - const resultsCache = await fs.promises.readFile(this.cachePath, 'utf8') - const { results, version } = JSON.parse(resultsCache || '[]') - const [major, minor] = version.split('.') - // handling changed in 0.30.0 - if (major > 0 || Number(minor) >= 30) { - this.cache = new Map(results) - this.version = version - results.forEach(([spec]: [string]) => { - const [projectName, relativePath] = spec.split(':') - const keyMap = this.workspacesKeyMap.get(relativePath) || [] - keyMap.push(projectName) - this.workspacesKeyMap.set(relativePath, keyMap) - }) + if (changed) { + this.legacyResults = undefined + await this.write() } } - updateResults(files: File[]): void { - files.forEach((file) => { - const result = file.result - if (!result) { - return - } - const duration = result.duration || 0 - // store as relative, so cache would be the same in CI and locally - const relativePath = relative(this.root, file.filepath) - this.cache.set(`${file.projectName || ''}:${relativePath}`, { - duration: duration >= 0 ? duration : 0, - failed: result.state === 'fail', - }) - }) + async clear(): Promise { + this.projects.clear() + this.legacyResults = undefined + this.fileStamp = undefined + if (this.path && existsSync(this.path)) { + await rm(this.path, { force: true }) + this.vitest.logger.log('[cache] cleared results cache at', this.path) + } } - removeFromCache(filepath: string): void { - this.cache.forEach((_, key) => { - if (key.endsWith(filepath)) { - this.cache.delete(key) - } - }) + // a partial run does not prove that the whole file passes + private isPartialRun(specification: TestSpecification, task: File): boolean { + return !!( + this.vitest.isCancelling || + task.containsOnly || + specification.testLines?.length || + specification.testIds?.length || + specification.testNamePattern || + specification.testTagsFilter?.length || + this.vitest.getGlobalTestNamePattern() || + this.vitest.config.tagsFilter?.length + ) } - async writeToCache(): Promise { - if (!this.cachePath) { - return + // relative to the root, so the cache is the same in CI and locally + private getFilePath(specification: TestSpecification): string { + const root = this.vitest.config.root + const moduleId = specification.moduleId + // "relative" normalizes both paths, which is slow for thousands of files + if (moduleId.startsWith(root) && moduleId[root.length] === '/') { + return moduleId.slice(root.length + 1) } + return relative(root, moduleId) + } - const results = Array.from(this.cache.entries()) - - const cacheDirname = dirname(this.cachePath) + private getProjectResults(name: string): ProjectResults { + let project = this.projects.get(name) + if (!project) { + project = { files: new Map(), typecheck: new Map() } + this.projects.set(name, project) + } + return project + } - if (!fs.existsSync(cacheDirname)) { - await fs.promises.mkdir(cacheDirname, { recursive: true }) + private async write(): Promise { + if (!this.path) { + return + } + const projects: SerializedResults['projects'] = Object.create(null) + for (const [name, { files, typecheck }] of this.projects) { + projects[name] = { + files: files.size ? Object.fromEntries(files) : undefined, + typecheck: typecheck.size ? Object.fromEntries(typecheck) : undefined, + } + } + const results: SerializedResults = { version: RESULTS_VERSION, projects } + try { + await mkdir(dirname(this.path), { recursive: true }) + await atomicWriteFile(this.path, JSON.stringify(results)) + this.fileStamp = await getFileStamp(this.path) + } catch (error) { + debug?.(`failed to write ${this.path}: ${error}`) } + } +} - const cache = JSON.stringify({ - version: this.version, - results, - }) +function isFinalState(state: string | undefined): boolean { + return state === 'pass' || state === 'fail' || state === 'skip' || state === 'todo' +} - await fs.promises.writeFile(this.cachePath, cache) - } +// every write replaces the file, so a write by another process changes the inode +async function getFileStamp(path: string): Promise { + const stats = await stat(path).catch(() => undefined) + return stats && `${stats.ino}:${stats.mtimeMs}:${stats.size}` } diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 5f4a0c1a9..7223bb5f0 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -740,22 +740,10 @@ export const cliOptionsConfig: VitestCLIOptions = { argument: '', }, cache: { - description: 'Enable cache', + description: + 'Store the results of test runs to run failed and longer test files first (default: `true`)', argument: '', // allow only boolean - subcommands: { - dir: null, - }, - default: true, - // cache can only be "false" or an object - transform(cache) { - if (typeof cache !== 'boolean' && cache) { - throw new Error('--cache.dir is deprecated') - } - if (cache) { - return {} - } - return cache - }, + subcommands: null, }, maxConcurrency: { description: diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 2d8c10a76..30bf6f2bb 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -822,9 +822,10 @@ export function resolveTestConfig( } if (resolved.cache !== false) { - if (resolved.cache && typeof resolved.cache.dir === 'string') { + // projects inherit the root option, so only the root config warns + if (!globalConfig && resolved.cache && typeof resolved.cache.dir === 'string') { logger.deprecate( - `"cache.dir" is deprecated, use Vite's "cacheDir" instead if you want to change the cache director. Note caches will be written to "cacheDir/vitest"`, + `"cache.dir" is deprecated and has no effect. Use Vite's "cacheDir" instead if you want to change the cache directory. Note that the cache is written to "cacheDir/vitest".`, ) } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 418c7b34a..aea7a7518 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -37,7 +37,6 @@ 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 { matchesProjectFilter, resolveConfig } from './config/resolveConfig' import { getCoverageProvider } from './coverage' import { ServerModuleRunner } from './environments/serverRunner' @@ -144,7 +143,7 @@ export class Vitest { */ public snapshot!: SnapshotManager /** - * Test results and test file stats cache. Primarily used by the sequencer to sort tests. + * The cache of the previous test runs. Primarily used by the sequencer to sort tests. */ public cache!: VitestCache @@ -169,7 +168,6 @@ export class Vitest { /** @internal */ _testRun: TestRun /** @internal */ _resolver!: VitestResolver /** @internal */ _transformService!: ModuleTransformService - /** @internal */ _fsCache!: FileSystemModuleCache /** @internal */ _tmpDir: string | undefined = join(tmpdir(), nanoid()) /** @internal */ _traces!: Traces /** @internal */ _harness: PluginHarness @@ -243,14 +241,13 @@ export class Vitest { this.state = new StateManager({ onUnhandledError: resolved.onUnhandledError, }) - this.cache = new VitestCache(this.logger) + this.cache = new VitestCache(this) const otelSdkPath = this.config.experimental.openTelemetry?.sdkPath this._traces = new Traces({ enabled: !!this.config.experimental.openTelemetry?.enabled, sdkPath: otelSdkPath, watchMode: this.config.watch, }) - this._fsCache = new FileSystemModuleCache(this) this.snapshot = new SnapshotManager({ ...resolved.snapshotOptions }) this._resolver = new VitestResolver(this.viteConfig.cacheDir, resolved) // a closed run removes the temp dir, so a restart must allocate a new one @@ -258,7 +255,7 @@ export class Vitest { this._transformService = new ModuleTransformService( this._resolver, resolved, - this._fsCache, + this.cache._modules, this._traces, tmpDir, ) @@ -369,11 +366,6 @@ export class Vitest { if (!resolved.watch) { await server.watcher.close() } - - this.cache.results.setConfig(resolved.root, resolved.cache) - try { - await this.cache.results.readFromCache() - } catch {} } /** @internal */ @@ -397,7 +389,7 @@ export class Vitest { vitest: this, injectTestProjects: this.injectTestProject, defineCacheKeyGenerator: (callback) => - this._fsCache.defineCacheKeyGenerator(project.config, callback), + this.cache._modules.defineCacheKeyGenerator(project.config, callback), /** * @deprecated Use `defineCacheKeyGenerator` instead. */ @@ -408,7 +400,7 @@ export class Vitest { '`experimental_defineCacheKeyGenerator` is deprecated. Use `defineCacheKeyGenerator` instead.', ) } - this._fsCache.defineCacheKeyGenerator(project.config, callback) + this.cache._modules.defineCacheKeyGenerator(project.config, callback) }, }), ) @@ -475,7 +467,7 @@ export class Vitest { } } - await this._fsCache.ensureCacheIntegrity() + await this.cache._load() await Promise.all([...this._onSetServer.map((fn) => fn()), this._traces.waitInit()]) } @@ -540,7 +532,9 @@ export class Vitest { private clearAllCachePaths() { this.projects.forEach(({ vite }) => { const environments = Object.values(vite.environments) - environments.forEach((environment) => this._fsCache.invalidateAllCachePaths(environment)) + environments.forEach((environment) => + this.cache._modules.invalidateAllCachePaths(environment), + ) }) } @@ -688,8 +682,7 @@ export class Vitest { * Deletes all Vitest caches, including the `fsModuleCache`. */ public async clearCache(): Promise { - await this.cache.results.clearCache() - await this._fsCache.clearCache() + await this.cache._clear() } /** @@ -915,9 +908,6 @@ export class Vitest { } if (specifications.length) { - // populate once, update cache on watch - await this.cache.stats.populateStats(this.config.root, specifications) - testModules = await this.runFiles(specifications, true) } @@ -1055,6 +1045,8 @@ export class Vitest { // schedule the new run this.runningPromise = (async () => { try { + const startTime = Date.now() + if (!this.pool) { this.pool = createPool(this) } @@ -1076,12 +1068,7 @@ export class Vitest { this.state.catchError(err, 'Unhandled Error') } - const files = this.state.getFiles() - - this.cache.results.updateResults(files) - try { - await this.cache.results.writeToCache() - } catch {} + await this.cache._results.update(specs, startTime) return { testModules: this.state.getTestModules(), @@ -1602,7 +1589,7 @@ export class Vitest { modules.forEach((module) => { moduleGraph.invalidateModule(module) - this._fsCache.invalidateCachePath(environment, module.id!) + this.cache._modules.invalidateCachePath(environment, module.id!) }) }) }) diff --git a/packages/vitest/src/node/plugins/utils.ts b/packages/vitest/src/node/plugins/utils.ts index d169ab13c..2ca0ce816 100644 --- a/packages/vitest/src/node/plugins/utils.ts +++ b/packages/vitest/src/node/plugins/utils.ts @@ -1,10 +1,11 @@ import type { DepOptimizationOptions, UserConfig as ViteConfig } from 'vite' import type { DepsOptimizationOptions, UserConfig } from '../types/config' -import { dirname } from 'pathe' +import { slash } from '@vitest/utils/helpers' +import { dirname, resolve } from 'pathe' import { searchForWorkspaceRoot, version as viteVersion } from 'vite' import * as vite from 'vite' import { rootDir } from '../../paths' -import { VitestCache } from '../cache' +import { hash } from '../hash' export function resolveTestCacheDir( root: string, @@ -13,11 +14,8 @@ export function resolveTestCacheDir( ): string { const name = testConfig.name const label = typeof name === 'string' ? name : name?.label || '' - return VitestCache.resolveCacheDir( - root, - testConfig.cache != null && testConfig.cache !== false ? testConfig.cache.dir : viteCacheDir, - label, - ) + const baseDir = slash(viteCacheDir || 'node_modules/.vite') + return resolve(root, baseDir, 'vitest', hash('sha1', label, 'hex')) } export function resolveOptimizerConfig( diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index c047cdcce..d9f60ba23 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -99,7 +99,7 @@ export class TestProject { this._transformService = new ModuleTransformService( this._resolver, this.config, - this.vitest._fsCache, + this.vitest.cache._modules, this.vitest._traces, this.tmpDir, ) diff --git a/packages/vitest/src/node/sequencers/BaseSequencer.ts b/packages/vitest/src/node/sequencers/BaseSequencer.ts index 1c56e04f3..33df64ae1 100644 --- a/packages/vitest/src/node/sequencers/BaseSequencer.ts +++ b/packages/vitest/src/node/sequencers/BaseSequencer.ts @@ -1,8 +1,9 @@ import type { Vitest } from '../core' import type { TestSpecification } from '../test-specification' import type { TestSequencer } from './types' +import { stat } from 'node:fs/promises' import { slash } from '@vitest/utils/helpers' -import { relative, resolve } from 'pathe' +import { resolve } from 'pathe' import { hash } from '../hash' export class BaseSequencer implements TestSequencer { @@ -33,6 +34,11 @@ export class BaseSequencer implements TestSequencer { // async so it can be extended by other sequencers public async sort(files: TestSpecification[]): Promise { const cache = this.ctx.cache + const results = new Map(files.map((spec) => [spec, cache.getTestSpecificationResult(spec)])) + // the size is only a fallback for files without results + const sizes = files.some((spec) => !results.get(spec)) + ? await getFileSizes(files) + : new Map() return [...files].sort((a, b) => { // "sequence.groupOrder" is higher priority const groupOrderDiff = @@ -54,23 +60,20 @@ export class BaseSequencer implements TestSequencer { return 1 } - const keyA = `${a.project.name}:${relative(this.ctx.config.root, a.moduleId)}` - const keyB = `${b.project.name}:${relative(this.ctx.config.root, b.moduleId)}` - - const aState = cache.getFileTestResults(keyA) - const bState = cache.getFileTestResults(keyB) + const aState = results.get(a) + const bState = results.get(b) if (!aState || !bState) { - const statsA = cache.getFileStats(keyA) - const statsB = cache.getFileStats(keyB) + const sizeA = sizes.get(a.moduleId) + const sizeB = sizes.get(b.moduleId) // run unknown first - if (!statsA || !statsB) { - return !statsA && statsB ? -1 : !statsB && statsA ? 1 : 0 + if (sizeA == null || sizeB == null) { + return sizeA == null && sizeB != null ? -1 : sizeB == null && sizeA != null ? 1 : 0 } // run larger files first - return statsB.size - statsA.size + return sizeB - sizeA } // run failed first @@ -104,3 +107,20 @@ export class BaseSequencer implements TestSequencer { return [shardStart, shardEnd] } } + +async function getFileSizes(files: TestSpecification[]): Promise> { + const sizes = new Map() + const moduleIds = new Set(files.map((spec) => spec.moduleId)) + await Promise.all( + Array.from(moduleIds, async (moduleId) => { + try { + const stats = await stat(moduleId) + sizes.set(moduleId, stats.size) + } catch { + // the file can be virtual or deleted; a file without + // a size only loses the sorting heuristic + } + }), + ) + return sizes +} diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index b6144f839..2d7801b4f 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -700,14 +700,15 @@ export interface InlineConfig { maxConcurrency?: number /** - * Options for configuring cache policy. - * @default { dir: 'node_modules/.vite/vitest/{project-hash}' } + * Store the results of test runs on the file system to run failed + * and longer test files first. The cache is stored in Vite's "cacheDir". + * @default true */ cache?: - | false + | boolean | { /** - * @deprecated Use Vite's "cacheDir" instead if you want to change the cache director. Note caches will be written to "cacheDir\/vitest". + * @deprecated This option has no effect. Use Vite's "cacheDir" instead if you want to change the cache directory. */ dir: string } diff --git a/packages/vitest/src/node/watcher.ts b/packages/vitest/src/node/watcher.ts index 408751569..b835321c5 100644 --- a/packages/vitest/src/node/watcher.ts +++ b/packages/vitest/src/node/watcher.ts @@ -107,8 +107,6 @@ export class VitestWatcher { if (this.vitest.state.filesMap.has(id)) { this.vitest.projects.forEach((project) => project._removeCachedTestFile(id)) this.vitest.state.filesMap.delete(id) - this.vitest.cache.results.removeFromCache(id) - this.vitest.cache.stats.removeStats(id) this.changedTests.delete(id) this.vitest.report('onTestRemoved', id) } diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index dc2481dc0..4c8c8be8d 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -6,6 +6,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 type { CachedTestFileResult } from '../node/cache/results' export { createCLI, parseCLI } from '../node/cli/cac' export type { CliParseOptions } from '../node/cli/cac' export type { CliOptions } from '../node/cli/cli-api' diff --git a/test/e2e/fixtures/cache/basic.test.ts b/test/e2e/fixtures/cache/basic.test.ts deleted file mode 100644 index 36c7cc207..000000000 --- a/test/e2e/fixtures/cache/basic.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { expect, test } from "vitest"; - -test('', () => { - expect(true).toBe(true) -}) diff --git a/test/e2e/fixtures/cache/second.test.ts b/test/e2e/fixtures/cache/second.test.ts deleted file mode 100644 index 36c7cc207..000000000 --- a/test/e2e/fixtures/cache/second.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { expect, test } from "vitest"; - -test('', () => { - expect(true).toBe(true) -}) diff --git a/test/e2e/test/cache.test.ts b/test/e2e/test/cache.test.ts index c7dd92b95..e0dae23be 100644 --- a/test/e2e/test/cache.test.ts +++ b/test/e2e/test/cache.test.ts @@ -1,164 +1,429 @@ -import { readFileSync } from 'node:fs' +import type { TestModule, Vitest } from 'vitest/node' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'pathe' -import { describe, expect, test } from 'vitest' -import { runVitest } from '../../test-utils' +import { expect, test } from 'vitest' +import { replaceRoot, runInlineTests, runVitest } from '../../test-utils' -const root = resolve(import.meta.dirname, '../fixtures/cache') +const cacheDir = `node_modules/.vite/vitest/${createHash('sha1').update('').digest('hex')}` +const resultsFile = `${cacheDir}/results.json` -test('default', async () => { - const { ctx, stdout, stderr } = await runVitest({ - root, - include: ['*.test.ts'], - cache: undefined, +const passingTest = /* js */ ` +import { test } from 'vitest' +test('passes', () => {}) +` + +const failingTest = /* js */ ` +import { expect, test } from 'vitest' +test('passes', () => {}) +test('fails', () => { + expect(1).toBe(2) +}) +` + +function getResultsPath(ctx: Vitest | undefined) { + return resolve(ctx!.viteConfig.cacheDir, 'results.json') +} + +function readRawResults(ctx: Vitest | undefined) { + return JSON.parse(readFileSync(getResultsPath(ctx), 'utf-8')) +} + +function readResults(ctx: Vitest | undefined) { + // durations and timestamps are different in every run + return JSON.parse(readFileSync(getResultsPath(ctx), 'utf-8'), (key, value) => { + return key === 'duration' || key === 'lastRun' ? `<${key}>` : value }) +} - expect(stdout).toContain('✓ basic.test.ts >') - expect(stderr).toBe('') +function recordOrder(order: string[]) { + return { + onTestModuleStart(testModule: TestModule) { + order.push(testModule.relativeModuleId) + }, + } +} - const cachePath = ctx!.cache.results.getCachePath() - const path = resolve( - root, - 'node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json', +test('stores the results of test files by project', async () => { + const { ctx } = await runInlineTests( + { + 'unit/basic.test.js': passingTest, + 'api/basic.test.js': failingTest, + 'vitest.config.js': { + test: { + projects: [ + { test: { name: 'unit', include: ['unit/*.test.js'] } }, + { test: { name: 'api:v2', include: ['api/*.test.js'] } }, + ], + }, + }, + }, + { cache: true }, ) - expect(cachePath).toMatch(path) + + expect(readResults(ctx)).toMatchInlineSnapshot(` + { + "projects": { + "api:v2": { + "files": { + "api/basic.test.js": { + "duration": "", + "failed": true, + "lastRun": "", + }, + }, + }, + "unit": { + "files": { + "unit/basic.test.js": { + "duration": "", + "failed": false, + "lastRun": "", + }, + }, + }, + }, + "version": 1, + } + `) }) -test('use cache.dir', async () => { - const { ctx, stdout, stderr } = await runVitest({ - root, - include: ['*.test.ts'], - cache: { - dir: 'node_modules/.vitest-custom', +test('returns the cached result of a specification', async () => { + const { ctx, fs } = await runInlineTests( + { + 'basic.test.js': failingTest, + 'vitest.config.js': { test: { name: 'unit:node' } }, }, + { cache: true, fsModuleCachePath: './node_modules/.vitest-fs-cache' }, + ) + + const [specification] = ctx!.getModuleSpecifications(fs.resolveFile('basic.test.js')) + const result = ctx!.cache.getTestSpecificationResult(specification) + expect(result).toEqual({ + failed: true, + duration: expect.any(Number), + lastRun: expect.any(Number), }) + expect(result).toEqual(readRawResults(ctx).projects['unit:node'].files['basic.test.js']) - expect(stdout).toContain('✓ basic.test.ts >') - expect(stderr).toContain('"cache.dir" is deprecated') + const resultsPath = getResultsPath(ctx) + await ctx!.clearCache() - const cachePath = ctx!.cache.results.getCachePath() - const path = resolve( - root, - 'node_modules/.vitest-custom/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json', - ) - expect(cachePath).toMatch(path) + expect(ctx!.cache.getTestSpecificationResult(specification)).toBe(undefined) + expect(existsSync(resultsPath)).toBe(false) }) -test('use cacheDir', async () => { - const { ctx, stdout, stderr } = await runVitest({ - root, - include: ['*.test.ts'], - cache: undefined, - $viteConfig: { - cacheDir: 'node_modules/.vite-custom', - }, +test('a partial run cannot mark a failed file as passed', async () => { + const { ctx, fs } = await runInlineTests({ 'basic.test.js': failingTest }, { cache: true }) + + const firstRun = readRawResults(ctx).projects[''].files['basic.test.js'] + expect(firstRun).toEqual({ + failed: true, + duration: expect.any(Number), + lastRun: expect.any(Number), }) - expect(stdout).toContain('✓ basic.test.ts >') - expect(stderr).toBe('') + const filteredRun = await runVitest({ root: fs.root, cache: true, testNamePattern: 'passes' }) - const cachePath = ctx!.cache.results.getCachePath() - const path = resolve( - root, - 'node_modules/.vite-custom/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json', + expect(filteredRun.stderr).toBe('') + expect(filteredRun.testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "fails": "skipped", + "passes": "passed", + }, + } + `) + expect(readRawResults(filteredRun.ctx).projects[''].files['basic.test.js']).toEqual(firstRun) + + fs.editFile('basic.test.js', (content) => content.replace('toBe(2)', 'toBe(1)')) + const fullRun = await runVitest({ root: fs.root, cache: true }) + + expect(fullRun.stderr).toBe('') + const lastRun = readRawResults(fullRun.ctx).projects[''].files['basic.test.js'] + expect(lastRun.failed).toBe(false) + expect(lastRun.lastRun).toBeGreaterThan(firstRun.lastRun) +}) + +test('a partial run records a failure without the time of the run', async () => { + const { ctx } = await runInlineTests( + { 'basic.test.js': failingTest }, + { cache: true, testNamePattern: 'fails' }, ) - expect(cachePath).toMatch(path) + + expect(readResults(ctx)).toMatchInlineSnapshot(` + { + "projects": { + "": { + "files": { + "basic.test.js": { + "duration": "", + "failed": true, + }, + }, + }, + }, + "version": 1, + } + `) }) -test('preserves previous test results', async () => { - const firstRun = await runVitest({ - root, - include: ['basic.test.ts'], - cache: undefined, - }) +test('a cancelled run does not record passed files', async () => { + const { ctx } = await runInlineTests( + { + // the largest file runs first when there are no results + 'a-fail.test.js': `${failingTest}\n// ${'-'.repeat(1000)}`, + 'b-pass.test.js': passingTest, + 'c-pass.test.js': passingTest, + }, + { cache: true, bail: 1 }, + ) - expect(firstRun.stdout).toContain('✓ basic.test.ts >') - expect(firstRun.stderr).toBe('') + const { files } = readRawResults(ctx).projects[''] + expect(files['a-fail.test.js'].failed).toBe(true) + const completeRuns = Object.keys(files).filter((file) => files[file].lastRun != null) + expect(completeRuns).toEqual([]) +}) - const cachePath = firstRun.ctx!.cache.results.getCachePath() - const firstRunCacheContent = readFileSync(cachePath!, 'utf-8') - expect(firstRunCacheContent).toContain('basic.test.ts') +test('the cache is stored in the cacheDir of Vite', async () => { + const defaultRun = await runInlineTests({ 'basic.test.js': passingTest }, { cache: true }) - const secondRun = await runVitest({ - root, - include: ['second.test.ts'], - cache: undefined, - }) - expect(secondRun.stdout).toContain('✓ second.test.ts >') - expect(secondRun.stderr).toBe('') + expect(defaultRun.stderr).toBe('') + expect(getResultsPath(defaultRun.ctx)).toBe(defaultRun.fs.resolveFile(resultsFile)) + expect(existsSync(getResultsPath(defaultRun.ctx))).toBe(true) - const secondRunCacheContent = readFileSync(cachePath!, 'utf-8') - expect(secondRunCacheContent).toContain('basic.test.ts') - expect(secondRunCacheContent).toContain('second.test.ts') + const customRun = await runInlineTests( + { 'basic.test.js': passingTest }, + { cache: true, $viteConfig: { cacheDir: 'node_modules/.vite-custom' } }, + ) + + expect(customRun.stderr).toBe('') + expect(getResultsPath(customRun.ctx)).toBe( + customRun.fs.resolveFile(resultsFile.replace('.vite', '.vite-custom')), + ) + expect(existsSync(getResultsPath(customRun.ctx))).toBe(true) }) -describe('with optimizer enabled', () => { - const deps = { - optimizer: { - client: { - enabled: true, +test('the deprecated cache.dir option has no effect', async () => { + const { ctx, fs, stderr } = await runInlineTests( + { + 'basic.test.js': passingTest, + 'vitest.config.js': { + test: { + cache: { dir: 'node_modules/.vitest-custom' }, + projects: [{ test: { name: 'first' } }, { test: { name: 'second' } }], + }, }, }, - } + { cache: undefined }, + ) - test('default', async () => { - const { ctx, stdout, stderr } = await runVitest({ - root, - include: ['*.test.ts'], - deps, - cache: undefined, - }) - - expect(stdout).toContain('✓ basic.test.ts >') - expect(stderr).toBe('') - - const cachePath = ctx!.cache.results.getCachePath() - const path = resolve( - root, - 'node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json', - ) - expect(cachePath).toBe(path) - }) + expect(stderr).toMatchInlineSnapshot(` + " DEPRECATED "cache.dir" is deprecated and has no effect. Use Vite's "cacheDir" instead if you want to change the cache directory. Note that the cache is written to "cacheDir/vitest". + " + `) + expect(getResultsPath(ctx)).toBe(fs.resolveFile(resultsFile)) + expect(existsSync(getResultsPath(ctx))).toBe(true) + expect(existsSync(fs.resolveFile('node_modules/.vitest-custom'))).toBe(false) +}) - test('use cache.dir', async () => { - const { ctx, stdout, stderr } = await runVitest({ - root, - include: ['*.test.ts'], - deps, - cache: { - dir: 'node_modules/.vitest-custom', +test('the cache is not stored if it is disabled', async () => { + const { ctx, stderr, testTree } = await runInlineTests( + { 'basic.test.js': passingTest }, + { cache: false }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "passes": "passed", }, - }) + } + `) + expect(existsSync(getResultsPath(ctx))).toBe(false) +}) - expect(stdout).toContain('✓ basic.test.ts >') - expect(stderr).toContain('"cache.dir" is deprecated') +test('a file in the previous format is replaced', async () => { + const { ctx, stderr } = await runInlineTests( + { + 'basic.test.js': passingTest, + [resultsFile]: JSON.stringify({ + version: '5.0.2', + results: [[':basic.test.js', { duration: 1, failed: true }]], + }), + }, + { cache: true }, + ) - const cachePath = ctx!.cache.results.getCachePath() - const path = resolve( - root, - 'node_modules/.vitest-custom/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json', - ) - expect(cachePath).toBe(path) - }) + expect(stderr).toBe('') + expect(readResults(ctx)).toMatchInlineSnapshot(` + { + "projects": { + "": { + "files": { + "basic.test.js": { + "duration": "", + "failed": false, + "lastRun": "", + }, + }, + }, + }, + "version": 1, + } + `) +}) + +test('results of another process are not lost', async () => { + const { ctx, fs } = await runInlineTests({ 'basic.test.js': passingTest }, { cache: true }) + + const results = readRawResults(ctx) + results.projects.other = { files: { 'other.test.js': { failed: true, duration: 1 } } } + writeFileSync(getResultsPath(ctx), JSON.stringify(results)) - test('use cacheDir', async () => { - const { ctx, stdout, stderr } = await runVitest({ - root, - include: ['*.test.ts'], - deps, - cache: undefined, - $viteConfig: { - cacheDir: 'node_modules/.vite-custom', + await ctx!.runTestSpecifications(ctx!.getModuleSpecifications(fs.resolveFile('basic.test.js'))) + + expect(readResults(ctx)).toMatchInlineSnapshot(` + { + "projects": { + "": { + "files": { + "basic.test.js": { + "duration": "", + "failed": false, + "lastRun": "", + }, + }, + }, + "other": { + "files": { + "other.test.js": { + "duration": "", + "failed": true, + }, + }, + }, }, - }) + "version": 1, + } + `) +}) - expect(stdout).toContain('✓ basic.test.ts >') - expect(stderr).toBe('') +test('--clearCache deletes the results', async () => { + const { ctx, fs } = await runInlineTests( + { 'basic.test.js': passingTest }, + { cache: true, fsModuleCachePath: './node_modules/.vitest-fs-cache' }, + ) + const resultsPath = getResultsPath(ctx) + expect(existsSync(resultsPath)).toBe(true) - const cachePath = ctx!.cache.results.getCachePath() - const path = resolve( - root, - 'node_modules/.vite-custom/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json', - ) - expect(cachePath).toBe(path) + const { stdout, stderr } = await runVitest({ + root: fs.root, + cache: true, + clearCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', }) + + expect(stderr).toBe('') + expect(replaceRoot(stdout, fs.root)).toMatchInlineSnapshot(` + "[cache] cleared results cache at /node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json + [cache] cleared fs module cache at /node_modules/.vitest-fs-cache + " + `) + expect(existsSync(resultsPath)).toBe(false) +}) + +test('failed and longer files run first', async () => { + const order: string[] = [] + const { stderr } = await runInlineTests( + { + 'a-long.test.js': passingTest, + 'b-short.test.js': passingTest, + 'c-failed.test.js': passingTest, + [resultsFile]: JSON.stringify({ + version: 1, + projects: { + '': { + files: { + 'a-long.test.js': { failed: false, duration: 1000, lastRun: 1 }, + 'b-short.test.js': { failed: false, duration: 1, lastRun: 1 }, + 'c-failed.test.js': { failed: true, duration: 1, lastRun: 1 }, + }, + }, + }, + }), + }, + { cache: true, reporters: [recordOrder(order)] }, + ) + + expect(stderr).toBe('') + expect(order).toEqual(['c-failed.test.js', 'a-long.test.js', 'b-short.test.js']) +}) + +test('larger files run first if there are no results', async () => { + const order: string[] = [] + const { stderr } = await runInlineTests( + { + 'a-small.test.js': passingTest, + 'b-large.test.js': `${passingTest}\n// ${'-'.repeat(2000)}`, + 'c-medium.test.js': `${passingTest}\n// ${'-'.repeat(1000)}`, + }, + { cache: true, reporters: [recordOrder(order)] }, + ) + + expect(stderr).toBe('') + expect(order).toEqual(['b-large.test.js', 'c-medium.test.js', 'a-small.test.js']) +}) + +test('the deprecated methods of the cache still work in a custom sequencer', async () => { + const order: string[] = [] + const { stderr } = await runInlineTests( + { + 'short.test.js': passingTest, + 'long.test.js': passingTest, + 'unknown.test.js': `${passingTest}\n// ${'-'.repeat(2000)}`, + [resultsFile]: JSON.stringify({ + version: 1, + projects: { + 'unit:node': { + files: { + 'short.test.js': { failed: false, duration: 1, lastRun: 1 }, + 'long.test.js': { failed: false, duration: 1000, lastRun: 1 }, + }, + }, + }, + }), + 'vitest.config.js': /* js */ ` + import { BaseSequencer } from 'vitest/node' + + class LegacySequencer extends BaseSequencer { + async sort(files) { + const { cache, config } = this.ctx + const getKey = spec => spec.project.name + ':' + spec.moduleId.slice(config.root.length + 1) + const getRank = (spec) => { + const key = getKey(spec) + return cache.getFileTestResults(key)?.duration ?? cache.getFileStats(key).size + } + return [...files].sort((a, b) => getRank(b) - getRank(a)) + } + } + + export default { + test: { + name: 'unit:node', + sequence: { sequencer: LegacySequencer }, + }, + } + `, + }, + { cache: true, reporters: [recordOrder(order)] }, + ) + + expect(stderr).toMatchInlineSnapshot(` + " DEPRECATED "vitest.cache.getFileTestResults" is deprecated. Use "getTestSpecificationResult(specification)" instead. + DEPRECATED "vitest.cache.getFileStats" is deprecated. Read the size from the file system instead. + " + `) + expect(order).toEqual(['unknown.test.js', 'long.test.js', 'short.test.js']) }) diff --git a/test/typescript/test/cache.test.ts b/test/typescript/test/cache.test.ts new file mode 100644 index 000000000..21515c7b0 --- /dev/null +++ b/test/typescript/test/cache.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'pathe' +import { expect, test } from 'vitest' +import { runInlineTests } from '../../test-utils' + +test('the results of typecheck and runtime tests of the same file are stored separately', async () => { + const { ctx } = await runInlineTests( + { + // the type assertion fails only in the typecheck run + 'basic.check.ts': /* ts */ ` + import { expectTypeOf, test } from 'vitest' + test('number is a string', () => { + expectTypeOf(1).toBeString() + }) + `, + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: 'esnext', + module: 'esnext', + moduleResolution: 'bundler', + skipLibCheck: true, + }, + include: ['*.check.ts'], + }), + }, + { + cache: true, + include: ['*.check.ts'], + typecheck: { enabled: true, include: ['*.check.ts'] }, + }, + ) + + const results = JSON.parse( + readFileSync(resolve(ctx!.viteConfig.cacheDir, 'results.json'), 'utf-8'), + // durations and timestamps are different in every run + (key, value) => (key === 'duration' || key === 'lastRun' ? `<${key}>` : value), + ) + expect(results).toMatchInlineSnapshot(` + { + "projects": { + "": { + "files": { + "basic.check.ts": { + "duration": "", + "failed": false, + "lastRun": "", + }, + }, + "typecheck": { + "basic.check.ts": { + "duration": "", + "failed": true, + "lastRun": "", + }, + }, + }, + }, + "version": 1, + } + `) +}) diff --git a/test/unit/test/cli-test.test.ts b/test/unit/test/cli-test.test.ts index ee4944c65..2a2c83543 100644 --- a/test/unit/test/cli-test.test.ts +++ b/test/unit/test/cli-test.test.ts @@ -247,9 +247,9 @@ test('injectCjsGlobals is parsed correctly', () => { }) test('cache is parsed correctly', () => { - expect(getCLIOptions('--cache')).toEqual({ cache: {} }) + expect(getCLIOptions('--cache')).toEqual({ cache: true }) expect(getCLIOptions('--no-cache')).toEqual({ cache: false }) - expect(() => getCLIOptions('--cache.dir=./cache')).toThrow('--cache.dir is deprecated') + expect(getCLIOptions('--cache=false')).toEqual({ cache: false }) }) test('shuffle is parsed correctly', () => { diff --git a/test/unit/test/sequencers.test.ts b/test/unit/test/sequencers.test.ts index e0c54f7dc..3f53adf46 100644 --- a/test/unit/test/sequencers.test.ts +++ b/test/unit/test/sequencers.test.ts @@ -1,5 +1,5 @@ import type { TestProject, Vitest } from 'vitest/node' -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, test } from 'vitest' import { BaseSequencer } from '../../../packages/vitest/src/node/sequencers/BaseSequencer' import { RandomSequencer } from '../../../packages/vitest/src/node/sequencers/RandomSequencer' import { TestSpecification } from '../../../packages/vitest/src/node/test-specification' @@ -11,8 +11,7 @@ function buildCtx(config?: Partial) { ...config, }, cache: { - getFileTestResults: vi.fn(), - getFileStats: vi.fn(), + getTestSpecificationResult: () => undefined, }, } as unknown as Vitest } @@ -41,95 +40,6 @@ describe('base sequencer', () => { expect(sorted).toStrictEqual(files) }) - test('prioritize unknown files', async () => { - const ctx = buildCtx() - vi.spyOn(ctx.cache, 'getFileStats').mockImplementation((file) => { - if (file === 'test:b') { - return { size: 2 } - } - }) - const sequencer = new BaseSequencer(ctx) - const files = workspaced(['b', 'a', 'c']) - const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(workspaced(['a', 'c', 'b'])) - }) - - test('sort by size, larger first', async () => { - const ctx = buildCtx() - vi.spyOn(ctx.cache, 'getFileStats').mockImplementation((file) => { - if (file === 'test:a') { - return { size: 1 } - } - if (file === 'test:b') { - return { size: 2 } - } - if (file === 'test:c') { - return { size: 3 } - } - }) - const sequencer = new BaseSequencer(ctx) - const files = workspaced(['b', 'a', 'c']) - const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(workspaced(['c', 'b', 'a'])) - }) - - test('sort by results, failed first', async () => { - const ctx = buildCtx() - vi.spyOn(ctx.cache, 'getFileTestResults').mockImplementation((file) => { - if (file === 'test:a') { - return { failed: false, duration: 1 } - } - if (file === 'test:b') { - return { failed: true, duration: 1 } - } - if (file === 'test:c') { - return { failed: true, duration: 1 } - } - }) - const sequencer = new BaseSequencer(ctx) - const files = workspaced(['b', 'a', 'c']) - const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(workspaced(['b', 'c', 'a'])) - }) - - test('sort by results, long first', async () => { - const ctx = buildCtx() - vi.spyOn(ctx.cache, 'getFileTestResults').mockImplementation((file) => { - if (file === 'test:a') { - return { failed: true, duration: 1 } - } - if (file === 'test:b') { - return { failed: true, duration: 2 } - } - if (file === 'test:c') { - return { failed: true, duration: 3 } - } - }) - const sequencer = new BaseSequencer(ctx) - const files = workspaced(['b', 'a', 'c']) - const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(workspaced(['c', 'b', 'a'])) - }) - - test('sort by results, long and failed first', async () => { - const ctx = buildCtx() - vi.spyOn(ctx.cache, 'getFileTestResults').mockImplementation((file) => { - if (file === 'test:a') { - return { failed: false, duration: 1 } - } - if (file === 'test:b') { - return { failed: false, duration: 6 } - } - if (file === 'test:c') { - return { failed: true, duration: 3 } - } - }) - const sequencer = new BaseSequencer(ctx) - const files = workspaced(['b', 'a', 'c']) - const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(workspaced(['c', 'b', 'a'])) - }) - test.each([ { files: 4, count: 3, expected: [2, 1, 1] }, { files: 5, count: 4, expected: [2, 1, 1, 1] }, -- 2.51.2