Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/config/stale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: stale | Config
outline: deep
---

# stale <CRoot />

- **Type**: `boolean`
- **Default**: `false`
- **CLI:** `--stale`

Run only tests that are stale. A test is considered stale when it or any of its dependencies (recursively) have been modified since the last time tests were run with `--stale`.

The first time tests are run with `--stale`, all tests are executed and a manifest is generated. On subsequent runs, only stale tests are executed. If no tests are stale, Vitest exits with code 0.

This option is useful for fast iteration during development, particularly for agentic coding systems that run tests continuously during their development loops.

Cannot be used together with [`changed`](/guide/cli#changed).

::: tip
When paired with [`forceRerunTriggers`](/config/forcereruntriggers), changes to matched files will cause the entire test suite to run.
:::
7 changes: 7 additions & 0 deletions docs/guide/cli-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,13 @@ Allow tests and suites that are marked as only (default: `!process.env.CI`)

Ignore any unhandled errors that occur

### stale

- **CLI:** `--stale`
- **Config:** [stale](/config/stale)

Run only tests that are stale. A test is stale when it or any of its dependencies have changed since the last run with --stale (default: `false`)

### sequence.shuffle.files

- **CLI:** `--sequence.shuffle.files`
Expand Down
15 changes: 15 additions & 0 deletions docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,21 @@ When used with code coverage the report will contain only the files that were re

If paired with the [`forceRerunTriggers`](/config/forcereruntriggers) config option it will run the whole test suite if at least one of the files listed in the `forceRerunTriggers` list changes. By default, changes to the Vitest config file and `package.json` will always rerun the whole suite.

### stale

- **Type**: `boolean`
- **Default**: false

Run only tests that are stale. A test is considered stale when it or any of its dependencies (recursively) have been modified since the last time tests were run with `--stale`.

The first time tests are run with `--stale`, all tests are executed and a manifest is generated. On subsequent runs, only stale tests are executed. If no tests are stale, Vitest exits with code 0.

This option is useful for fast iteration during development, allowing you to run only the tests affected by your recent changes without relying on git.

Cannot be used together with [`--changed`](#changed).

If paired with the [`forceRerunTriggers`](/config/forcereruntriggers) config option, changes to matched files will cause the entire test suite to run.

### shard

- **Type**: `string`
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default antfu(
// uses invalid js example
'docs/api/advanced/import-example.md',
'docs/guide/examples/*.md',
'.sisyphus/**',
],
},
{
Expand Down
3 changes: 3 additions & 0 deletions packages/vitest/src/node/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { resolve } from 'pathe'
import { hash } from '../hash'
import { FilesStatsCache } from './files'
import { ResultsCache } from './results'
import { StaleManifest } from './stale'

export class VitestCache {
results: ResultsCache
stats: FilesStatsCache = new FilesStatsCache()
stale: StaleManifest

constructor(logger: Logger) {
this.results = new ResultsCache(logger)
this.stale = new StaleManifest(logger)
}

getFileTestResults(key: string): SuiteResultCache | undefined {
Expand Down
118 changes: 118 additions & 0 deletions packages/vitest/src/node/cache/stale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { Logger } from '../logger'
import type { ResolvedConfig } from '../types/config'
import fs, { existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { dirname, relative, resolve } from 'pathe'
import { Vitest } from '../core'

export interface StaleManifestData {
version: string
timestamp: number
files: Record<string, { mtimeMs: number }>
}

export class StaleManifest {
private manifest: StaleManifestData | null = null
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
}

setConfig(root: string, config: ResolvedConfig['cache']): void {
this.root = root
if (config) {
this.cachePath = resolve(config.dir, 'stale.json')
}
}

async clearCache(): Promise<void> {
if (this.cachePath && existsSync(this.cachePath)) {
await rm(this.cachePath, { force: true, recursive: true })
this.logger.log('[cache] cleared stale manifest at', this.cachePath)
}
this.manifest = null
}

async readFromCache(): Promise<void> {
if (!this.cachePath) {
return
}

if (!fs.existsSync(this.cachePath)) {
return
}

const staleData = await fs.promises.readFile(this.cachePath, 'utf8')
const parsed = JSON.parse(staleData || '{}') as StaleManifestData
const [major, minor] = parsed.version?.split('.') || ['0', '0']

// handling changed in 0.30.0
if (Number(major) > 0 || Number(minor) >= 30) {
this.manifest = parsed
this.version = parsed.version
}
}

async writeToCache(): Promise<void> {
if (!this.cachePath || !this.manifest) {
return
}

const cacheDirname = dirname(this.cachePath)

if (!fs.existsSync(cacheDirname)) {
await fs.promises.mkdir(cacheDirname, { recursive: true })
}

const cache = JSON.stringify({
version: this.version,
timestamp: this.manifest.timestamp,
files: this.manifest.files,
})

await fs.promises.writeFile(this.cachePath, cache)
}

getFileMtime(relativePath: string): number | undefined {
if (!this.manifest) {
return undefined
}
return this.manifest.files[relativePath]?.mtimeMs
}

async updateFiles(root: string, filePaths: string[]): Promise<void> {
if (!this.manifest) {
this.manifest = {
version: this.version,
timestamp: Date.now(),
files: {},
}
}

for (const filePath of filePaths) {
try {
const stats = await fs.promises.stat(filePath)
const relativePath = relative(root, filePath)
this.manifest.files[relativePath] = {
mtimeMs: stats.mtimeMs,
}
}
catch {
// file may have been deleted, skip
}
}

this.manifest.timestamp = Date.now()
}

hasManifest(): boolean {
return this.manifest !== null
}
}
4 changes: 4 additions & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ export const cliOptionsConfig: VitestCLIOptions = {
'Run tests that are affected by the changed files (default: `false`)',
argument: '[since]',
},
stale: {
description:
'Run only tests that are stale. A test is stale when it or any of its dependencies have changed since the last run with --stale (default: `false`)',
},
sequence: {
description: 'Options for how tests should be sorted',
argument: '<options>',
Expand Down
8 changes: 8 additions & 0 deletions packages/vitest/src/node/config/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,14 @@ export function resolveConfig(
resolved.passWithNoTests ??= true
}

if (resolved.stale) {
resolved.passWithNoTests ??= true
}

if (resolved.stale && resolved.changed) {
throw new Error('Cannot use both --stale and --changed options at the same time')
}

resolved.css ??= {}
if (typeof resolved.css === 'object') {
resolved.css.modules ??= {}
Expand Down
11 changes: 10 additions & 1 deletion packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,12 @@ export class Vitest {
}
catch { }

this.cache.stale.setConfig(resolved.root, resolved.cache)
try {
await this.cache.stale.readFromCache()
}
catch { }

const projects = await this.resolveProjects(this._cliOptions)
this.projects = projects

Expand Down Expand Up @@ -769,7 +775,7 @@ export class Vitest {
await this.reportCoverage(coverage, true)
})

if (!this.config.watch || !(this.config.changed || this.config.related?.length)) {
if (!this.config.watch || !(this.config.changed || this.config.related?.length || this.config.stale)) {
throw new FilesNotFoundError(this.mode)
}
}
Expand Down Expand Up @@ -931,6 +937,7 @@ export class Vitest {
this.cache.results.updateResults(files)
try {
await this.cache.results.writeToCache()
await this.cache.stale.writeToCache()
}
catch {}

Expand All @@ -954,6 +961,7 @@ export class Vitest {

// all subsequent runs will treat this as a fresh run
this.config.changed = false
this.config.stale = false
this.config.related = undefined
})

Expand Down Expand Up @@ -1082,6 +1090,7 @@ export class Vitest {

// all subsequent runs will treat this as a fresh run
this.config.changed = false
this.config.stale = false
this.config.related = undefined
})

Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export class Logger {
printNoTestFound(filters?: string[]): void {
const config = this.ctx.config

if (config.watch && (config.changed || config.related?.length)) {
if (config.watch && (config.changed || config.related?.length || config.stale)) {
this.log(`No affected ${config.mode} files found\n`)
}
else if (config.watch) {
Expand Down
68 changes: 67 additions & 1 deletion packages/vitest/src/node/specifications.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Vitest } from './core'
import type { TestProject } from './project'
import type { TestSpecification } from './test-specification'
import { existsSync } from 'node:fs'
import { existsSync, statSync } from 'node:fs'
import { join, relative, resolve } from 'pathe'
import pm from 'picomatch'
import { isWindows } from '../utils/env'
Expand Down Expand Up @@ -120,6 +120,72 @@ export class VitestSpecifications {
}

private async filterTestsBySource(specs: TestSpecification[]): Promise<TestSpecification[]> {
if (this.vitest.config.stale) {
const root = this.vitest.config.root
const testGraphs = await Promise.all(
specs.map(async (spec) => {
const deps = await this.getTestDependencies(spec)
return [spec, deps] as const
}),
)

const allScannedFiles = new Set<string>()
const staleSpecs: TestSpecification[] = []
const staleManifest = this.vitest.cache.stale
const shouldRunAllByManifest = !staleManifest.hasManifest()

const forceRerunTriggers = this.vitest.config.forceRerunTriggers
const matcher = forceRerunTriggers.length ? pm(forceRerunTriggers) : undefined
let shouldRunAllByTrigger = false

const isFileStale = (filePath: string) => {
const relativePath = relative(root, filePath)
const manifestMtime = staleManifest.getFileMtime(relativePath)

if (manifestMtime === undefined) {
return true
}

if (!existsSync(filePath)) {
return true
}

const currentMtime = statSync(filePath).mtimeMs
return currentMtime > manifestMtime
}

for (const [spec, deps] of testGraphs) {
const files = [spec.moduleId, ...deps]
let isSpecStale = false

for (const filePath of files) {
allScannedFiles.add(filePath)

const fileChanged = isFileStale(filePath)

if (!shouldRunAllByTrigger && matcher && fileChanged && matcher(filePath)) {
shouldRunAllByTrigger = true
}

if (!isSpecStale && fileChanged) {
isSpecStale = true
}
}

if (isSpecStale) {
staleSpecs.push(spec)
}
}

await staleManifest.updateFiles(root, Array.from(allScannedFiles))

if (shouldRunAllByManifest || shouldRunAllByTrigger) {
return specs
}

return staleSpecs
}

if (this.vitest.config.changed && !this.vitest.config.related) {
const { VitestGit } = await import('./git')
const vitestGit = new VitestGit(this.vitest.config.root)
Expand Down
8 changes: 8 additions & 0 deletions packages/vitest/src/node/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,14 @@ export interface UserConfig extends InlineConfig {
*/
changed?: boolean | string

/**
* Run only tests that are stale. A test is stale when it or any of its dependencies have changed since the last run with --stale.
* Uses filesystem mtime-based manifest stored in the cache directory.
* Mutually exclusive with --changed option.
* @default false
*/
stale?: boolean

/**
* Test suite shard to execute in a format of <index>/<count>.
* Will divide tests into a `count` numbers, and run only the `indexed` part.
Expand Down
1 change: 1 addition & 0 deletions test/cli/fixtures/stale/dep-of-a.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const dep = 42
3 changes: 3 additions & 0 deletions test/cli/fixtures/stale/source-a.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { dep } from './dep-of-a'

export const a = 'hello' + dep
1 change: 1 addition & 0 deletions test/cli/fixtures/stale/source-b.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const b = 'world'
6 changes: 6 additions & 0 deletions test/cli/fixtures/stale/test-a.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { expect, test } from 'vitest'
import { a } from './source-a'

test('a', () => {
expect(a).toBeDefined()
})
Loading
Loading