diff --git a/docs/config/stale.md b/docs/config/stale.md
new file mode 100644
index 000000000000..6f2156fbb51a
--- /dev/null
+++ b/docs/config/stale.md
@@ -0,0 +1,22 @@
+---
+title: stale | Config
+outline: deep
+---
+
+# stale
+
+- **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.
+:::
diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md
index 6b82b3926181..29b79b8c87d6 100644
--- a/docs/guide/cli-generated.md
+++ b/docs/guide/cli-generated.md
@@ -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`
diff --git a/docs/guide/cli.md b/docs/guide/cli.md
index e1a16c45193b..7670dc297576 100644
--- a/docs/guide/cli.md
+++ b/docs/guide/cli.md
@@ -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`
diff --git a/eslint.config.js b/eslint.config.js
index 1eeed690841d..e7b1a6f177ca 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -33,6 +33,7 @@ export default antfu(
// uses invalid js example
'docs/api/advanced/import-example.md',
'docs/guide/examples/*.md',
+ '.sisyphus/**',
],
},
{
diff --git a/packages/vitest/src/node/cache/index.ts b/packages/vitest/src/node/cache/index.ts
index 296a0d0d1853..28fe0932d3d0 100644
--- a/packages/vitest/src/node/cache/index.ts
+++ b/packages/vitest/src/node/cache/index.ts
@@ -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 {
diff --git a/packages/vitest/src/node/cache/stale.ts b/packages/vitest/src/node/cache/stale.ts
new file mode 100644
index 000000000000..968d9ff57bb3
--- /dev/null
+++ b/packages/vitest/src/node/cache/stale.ts
@@ -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
+}
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+ }
+}
diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts
index ddf7641096b2..ffcc4faabfac 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -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: '',
diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts
index 33cb2b54531d..1d072e8ad933 100644
--- a/packages/vitest/src/node/config/resolveConfig.ts
+++ b/packages/vitest/src/node/config/resolveConfig.ts
@@ -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 ??= {}
diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts
index c753835c9e0a..b4f5e54a4695 100644
--- a/packages/vitest/src/node/core.ts
+++ b/packages/vitest/src/node/core.ts
@@ -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
@@ -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)
}
}
@@ -931,6 +937,7 @@ export class Vitest {
this.cache.results.updateResults(files)
try {
await this.cache.results.writeToCache()
+ await this.cache.stale.writeToCache()
}
catch {}
@@ -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
})
@@ -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
})
diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts
index c96cb73dc921..d81890c630bc 100644
--- a/packages/vitest/src/node/logger.ts
+++ b/packages/vitest/src/node/logger.ts
@@ -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) {
diff --git a/packages/vitest/src/node/specifications.ts b/packages/vitest/src/node/specifications.ts
index 54fe67e7544b..bdba9f15b3c4 100644
--- a/packages/vitest/src/node/specifications.ts
+++ b/packages/vitest/src/node/specifications.ts
@@ -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'
@@ -120,6 +120,72 @@ export class VitestSpecifications {
}
private async filterTestsBySource(specs: TestSpecification[]): Promise {
+ 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()
+ 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)
diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts
index e530e30e3ad3..7d3d4c757680 100644
--- a/packages/vitest/src/node/types/config.ts
+++ b/packages/vitest/src/node/types/config.ts
@@ -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 /.
* Will divide tests into a `count` numbers, and run only the `indexed` part.
diff --git a/test/cli/fixtures/stale/dep-of-a.ts b/test/cli/fixtures/stale/dep-of-a.ts
new file mode 100644
index 000000000000..983a4cd23cbf
--- /dev/null
+++ b/test/cli/fixtures/stale/dep-of-a.ts
@@ -0,0 +1 @@
+export const dep = 42
diff --git a/test/cli/fixtures/stale/source-a.ts b/test/cli/fixtures/stale/source-a.ts
new file mode 100644
index 000000000000..b1613d1170e5
--- /dev/null
+++ b/test/cli/fixtures/stale/source-a.ts
@@ -0,0 +1,3 @@
+import { dep } from './dep-of-a'
+
+export const a = 'hello' + dep
diff --git a/test/cli/fixtures/stale/source-b.ts b/test/cli/fixtures/stale/source-b.ts
new file mode 100644
index 000000000000..919a1bb9eeeb
--- /dev/null
+++ b/test/cli/fixtures/stale/source-b.ts
@@ -0,0 +1 @@
+export const b = 'world'
diff --git a/test/cli/fixtures/stale/test-a.test.ts b/test/cli/fixtures/stale/test-a.test.ts
new file mode 100644
index 000000000000..e2b601051174
--- /dev/null
+++ b/test/cli/fixtures/stale/test-a.test.ts
@@ -0,0 +1,6 @@
+import { expect, test } from 'vitest'
+import { a } from './source-a'
+
+test('a', () => {
+ expect(a).toBeDefined()
+})
diff --git a/test/cli/fixtures/stale/test-b.test.ts b/test/cli/fixtures/stale/test-b.test.ts
new file mode 100644
index 000000000000..540ad2a14b0c
--- /dev/null
+++ b/test/cli/fixtures/stale/test-b.test.ts
@@ -0,0 +1,6 @@
+import { expect, test } from 'vitest'
+import { b } from './source-b'
+
+test('b', () => {
+ expect(b).toBeDefined()
+})
diff --git a/test/cli/fixtures/stale/test-standalone.test.ts b/test/cli/fixtures/stale/test-standalone.test.ts
new file mode 100644
index 000000000000..568f82ab24ba
--- /dev/null
+++ b/test/cli/fixtures/stale/test-standalone.test.ts
@@ -0,0 +1,5 @@
+import { expect, test } from 'vitest'
+
+test('standalone', () => {
+ expect(true).toBe(true)
+})
diff --git a/test/cli/fixtures/stale/vitest.config.js b/test/cli/fixtures/stale/vitest.config.js
new file mode 100644
index 000000000000..57927f3a4d6b
--- /dev/null
+++ b/test/cli/fixtures/stale/vitest.config.js
@@ -0,0 +1,5 @@
+export default {
+ test: {
+ include: ['*.test.ts'],
+ },
+}
diff --git a/test/cli/test/stale.test.ts b/test/cli/test/stale.test.ts
new file mode 100644
index 000000000000..bc654915ddc6
--- /dev/null
+++ b/test/cli/test/stale.test.ts
@@ -0,0 +1,139 @@
+import { existsSync, rmSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { beforeEach, describe, expect, it } from 'vitest'
+import { editFile, resolvePath, runVitest } from '../../test-utils'
+
+const fixtureRoot = resolvePath(import.meta.url, '../fixtures/stale')
+
+function clearStaleCache() {
+ const cacheDir = resolve(fixtureRoot, 'node_modules')
+ if (existsSync(cacheDir)) {
+ rmSync(cacheDir, { recursive: true, force: true })
+ }
+}
+
+function normalizeOutput(stdout: string) {
+ const rows = stdout.replace(/\d?\.?\d+m?s/g, '[...]ms').split('\n').map((row) => {
+ if (row.includes('RUN v')) {
+ return `${row.split('RUN v')[0]}RUN v[...]`
+ }
+
+ if (row.includes('Start at')) {
+ return row.replace(/\d+:\d+:\d+/, '[...]')
+ }
+ return row
+ })
+
+ return rows.join('\n').trim()
+}
+
+async function runStale() {
+ return runVitest({
+ root: './fixtures/stale',
+ stale: true,
+ cache: undefined,
+ })
+}
+
+describe.skipIf(process.env.ECOSYSTEM_CI)('--stale', () => {
+ beforeEach(() => {
+ clearStaleCache()
+ })
+
+ it('runs all tests on first run when no manifest exists', async () => {
+ const { stdout, stderr } = await runStale()
+ expect(stderr).toBe('')
+ expect(normalizeOutput(stdout)).toMatchInlineSnapshot(`
+ "RUN v[...]
+
+ ✓ test-a.test.ts > a [...]ms
+ ✓ test-b.test.ts > b [...]ms
+ ✓ test-standalone.test.ts > standalone [...]ms
+
+ Test Files 3 passed (3)
+ Tests 3 passed (3)
+ Start at [...]
+ Duration [...]ms (transform [...]ms, setup [...]ms, import [...]ms, tests [...]ms, environment [...]ms)"
+ `)
+ })
+
+ it('runs no tests on second run with no changes', async () => {
+ await runStale()
+ const { stdout } = await runStale()
+ expect(normalizeOutput(stdout)).toMatchInlineSnapshot(`
+ "RUN v[...]
+
+ No test files found, exiting with code 0"
+ `)
+ })
+
+ it('runs only affected test when source dependency changes', async () => {
+ await runStale()
+ editFile(
+ resolvePath(import.meta.url, '../fixtures/stale/source-a.ts'),
+ content => `${content}\n`,
+ )
+ const { stdout, stderr } = await runStale()
+ expect(stderr).toBe('')
+ expect(normalizeOutput(stdout)).toMatchInlineSnapshot(`
+ "RUN v[...]
+
+ ✓ test-a.test.ts > a [...]ms
+
+ Test Files 1 passed (1)
+ Tests 1 passed (1)
+ Start at [...]
+ Duration [...]ms (transform [...]ms, setup [...]ms, import [...]ms, tests [...]ms, environment [...]ms)"
+ `)
+ })
+
+ it('runs only affected test when transitive dependency changes', async () => {
+ await runStale()
+ editFile(
+ resolvePath(import.meta.url, '../fixtures/stale/dep-of-a.ts'),
+ content => `${content}\n`,
+ )
+ const { stdout, stderr } = await runStale()
+ expect(stderr).toBe('')
+ expect(normalizeOutput(stdout)).toMatchInlineSnapshot(`
+ "RUN v[...]
+
+ ✓ test-a.test.ts > a [...]ms
+
+ Test Files 1 passed (1)
+ Tests 1 passed (1)
+ Start at [...]
+ Duration [...]ms (transform [...]ms, setup [...]ms, import [...]ms, tests [...]ms, environment [...]ms)"
+ `)
+ })
+
+ it('errors when both --stale and --changed are used', async () => {
+ const { stderr } = await runVitest({
+ root: './fixtures/stale',
+ stale: true,
+ changed: true,
+ cache: undefined,
+ })
+ expect(stderr.split('\n')[0]).toMatchInlineSnapshot(`"Error: Cannot use both --stale and --changed options at the same time"`)
+ })
+
+ it('runs only changed test when test file itself changes', async () => {
+ await runStale()
+ editFile(
+ resolvePath(import.meta.url, '../fixtures/stale/test-standalone.test.ts'),
+ content => `${content}\n`,
+ )
+ const { stdout, stderr } = await runStale()
+ expect(stderr).toBe('')
+ expect(normalizeOutput(stdout)).toMatchInlineSnapshot(`
+ "RUN v[...]
+
+ ✓ test-standalone.test.ts > standalone [...]ms
+
+ Test Files 1 passed (1)
+ Tests 1 passed (1)
+ Start at [...]
+ Duration [...]ms (transform [...]ms, setup [...]ms, import [...]ms, tests [...]ms, environment [...]ms)"
+ `)
+ })
+})
diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts
index 967c7bafc10a..b0f554c69c35 100644
--- a/test/test-utils/index.ts
+++ b/test/test-utils/index.ts
@@ -125,6 +125,7 @@ export async function runVitest(
related,
mode,
changed,
+ stale,
shard,
project,
cliExclude,
@@ -154,6 +155,7 @@ export async function runVitest(
related,
mode,
changed,
+ stale,
shard,
project,
cliExclude,