diff --git a/docs/config/experimental.md b/docs/config/experimental.md
index 76d550487..b2f7010eb 100644
--- a/docs/config/experimental.md
+++ b/docs/config/experimental.md
@@ -375,6 +375,49 @@ export default {
}
```
+## experimental.recordDependencies 5.0.4 {#experimental-recorddependencies}
+
+- **Type:** `boolean`
+- **Default:** `false`
+
+Records every file that a test file loads while it runs and uses these records to select tests for [`--changed`](/guide/cli#changed) and [`vitest related`](/guide/cli#vitest-related). Without this option, Vitest transforms the module graph of every test file before the run to find the affected tests. With it, Vitest reads the records from the previous run instead, so the selection does not transform anything.
+
+```ts [vitest.config.ts]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ experimental: {
+ recordDependencies: true,
+ },
+ },
+})
+```
+
+The records are stored in `dependencies.json` next to the [results cache](/config/cache) in Vite's `cacheDir`, so this option has no effect if `cache` is disabled. Every `vitest run` with the option enabled updates the records of the test files it ran. A run that skips some tests of a file, for example with [`-t`](/config/testnamepattern) or `.only`, can miss their dynamic imports, so it only adds files to an existing record and never creates one. A test file without a record, for example a new test file or a file that ran in a browser or [typecheck](/guide/testing-types) pool, always runs.
+
+A record contains:
+
+- the test file and every module it imported, including dynamic imports that ran
+- [setup files](/config/setupfiles), the [custom environment](/config/environment), the [custom runner](/config/runner), [snapshot serializers](/config/snapshotserializers) and their imports
+- mock files from the `__mocks__` folder and modules imported by a mock factory
+- files that plugins watch with `this.addWatchFile`
+- the snapshot file and every file used by [`toMatchFileSnapshot`](/api/expect#tomatchfilesnapshot)
+
+A change in a [global setup](/config/globalsetup) file or its imports, in the config file and its imports, or in an `.env` file runs every test of the project.
+
+Because the records come from a real run, they are exact where the static module graph is not. A module replaced with `vi.mock` and a factory does not load its imports, so they are not recorded. A dynamic import inside a function that the test never calls is not recorded either.
+
+::: warning
+The records describe the files that a test loaded during its last run. If a change that adds an import is committed before the test runs again, a later change in the newly imported file does not select the test, because the commit is no longer part of the `--changed` diff. Run the tests once after you change imports to update the records.
+
+Vitest records only the files it loads itself. Files read with `fs` (for example, fixtures) and the imports of [externalized](/config/server#server-deps-external) modules are not recorded.
+
+If [`isolate`](/config/isolate) is disabled, test files that run in the same worker share the module graph. A record can then contain modules that a previous test file loaded dynamically, so `--changed` can select more tests than necessary, but never fewer.
+
+If [`experimental.viteModuleRunner`](#experimental-vitemodulerunner) is disabled, Vitest records the imports with a resolve hook of [`module.registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions). Node.js versions without it do not write records, so every test runs.
+:::
+
## experimental.nodeLoader 4.1.0 {#experimental-nodeloader}
- **Type:** `boolean`
diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md
index 39e8bfdd1..9ec6f1d97 100644
--- a/docs/guide/cli-generated.md
+++ b/docs/guide/cli-generated.md
@@ -1000,6 +1000,13 @@ Controls whether Vitest will use Node.js Loader API to process in-source or mock
Custom provider for detecting changed files. (default: `git`)
+### experimental.recordDependencies
+
+- **CLI:** `--experimental.recordDependencies`
+- **Config:** [experimental.recordDependencies](/config/experimental#experimental-recorddependencies)
+
+Record the modules loaded by every test file in the cache and use the records with `--changed` and `--related` instead of the static module graph. A test file without a record always runs. (default: `false`)
+
### experimental.preParse
- **CLI:** `--experimental.preParse`
diff --git a/packages/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts
index 32120d3fb..79cafb70a 100644
--- a/packages/snapshot/src/port/state.ts
+++ b/packages/snapshot/src/port/state.ts
@@ -70,6 +70,7 @@ export default class SnapshotState {
private _inlineSnapshotStacks: Array
private _testIdToKeys = new DefaultMap(() => [])
private _rawSnapshots: Array
+ private _rawSnapshotFiles = new Set()
private _uncheckedKeys: Set
private _snapshotFormat: PrettyFormatOptions
private _environment: SnapshotEnvironment
@@ -508,6 +509,7 @@ export default class SnapshotState {
}
if (rawSnapshot) {
+ this._rawSnapshotFiles.add(rawSnapshot.file)
// normalize EOL when snapshot contains CRLF but received is LF
if (
rawSnapshot.content &&
@@ -604,6 +606,17 @@ export default class SnapshotState {
})
}
+ /**
+ * The snapshot file, if the test file stores snapshots in it, and every asserted file snapshot.
+ */
+ getSnapshotFiles(): string[] {
+ const files = Array.from(this._rawSnapshotFiles)
+ if (this._fileExists || Object.keys(this._snapshotData).length) {
+ files.unshift(this.snapshotPath)
+ }
+ return files
+ }
+
async pack(): Promise {
const snapshot: SnapshotResult = {
filepath: this.testFilePath,
diff --git a/packages/vitest/src/node/affected-modules.ts b/packages/vitest/src/node/affected-modules.ts
index 7e11a10b3..5c337a850 100644
--- a/packages/vitest/src/node/affected-modules.ts
+++ b/packages/vitest/src/node/affected-modules.ts
@@ -267,7 +267,7 @@ export class AffectedModulesResolver {
}
// files loaded into `import.meta.env`, mirrors Vite's `loadEnv`
-function getEnvFiles(project: TestProject): string[] {
+export function getEnvFiles(project: TestProject): string[] {
const { envDir, mode } = project.vite.config
if (typeof envDir !== 'string') {
return []
diff --git a/packages/vitest/src/node/cache/dependencies.ts b/packages/vitest/src/node/cache/dependencies.ts
new file mode 100644
index 000000000..94c69955b
--- /dev/null
+++ b/packages/vitest/src/node/cache/dependencies.ts
@@ -0,0 +1,230 @@
+import type { Vitest } from '../core'
+import type { TestProject } from '../project'
+import type { TestSpecification } from '../test-specification'
+import { existsSync } from 'node:fs'
+import { mkdir, readFile, rm } from 'node:fs/promises'
+import { cleanUrl } from '@vitest/utils/helpers'
+import { dirname, isAbsolute, resolve } from 'pathe'
+import { createDebugger } from '../../utils/debugger'
+import { tagCacheDir } from './cachedirTag'
+import { atomicWriteFile } from './fsModuleCache'
+import { getFileStamp, getRootRelativePath, isPartialRun } from './results'
+
+const debug = createDebugger('vitest:cache:dependencies')
+
+const DEPENDENCIES_VERSION = 1
+
+export interface ProjectDependencies {
+ /** Absolute paths without a query, referenced by index. */
+ modules: string[]
+ moduleIndex: Map
+ globalSetup?: number[]
+ /** Root-relative test file path to the indices of every module it loaded. */
+ files: Map
+}
+
+interface SerializedDependencies {
+ version: number
+ projects: Record<
+ string,
+ {
+ modules: string[]
+ globalSetup?: number[]
+ files: Record
+ }
+ >
+}
+
+/**
+ * Stores the modules that every test file loaded during its last run.
+ */
+export class DependenciesCache {
+ private projects = new Map()
+ // recorded during the current run, merged into `projects` by `update`
+ private recordedFiles = new Map>()
+ private recordedGlobalSetup = new Map()
+ private path: string | null
+ private fileStamp: string | undefined
+
+ constructor(private vitest: Vitest) {
+ this.path =
+ vitest.config.cache && vitest.config.experimental.recordDependencies
+ ? resolve(vitest.viteConfig.cacheDir, 'dependencies.json')
+ : null
+ }
+
+ get(project: TestProject): ProjectDependencies | undefined {
+ return this.projects.get(project.name)
+ }
+
+ record(project: TestProject, filepath: string, dependencies: string[]): void {
+ if (!this.path) {
+ return
+ }
+ let files = this.recordedFiles.get(project.name)
+ if (!files) {
+ this.recordedFiles.set(project.name, (files = new Map()))
+ }
+ files.set(filepath, this.resolveFiles(project, dependencies))
+ }
+
+ recordGlobalSetup(project: TestProject, dependencies: string[]): void {
+ if (this.path && dependencies.length) {
+ this.recordedGlobalSetup.set(project.name, this.resolveFiles(project, dependencies))
+ }
+ }
+
+ async read(): Promise {
+ const path = this.path
+ const stamp = path ? await getFileStamp(path) : undefined
+ if (!path || !stamp || stamp === this.fileStamp) {
+ return
+ }
+ this.fileStamp = stamp
+ try {
+ const { version, projects }: SerializedDependencies = JSON.parse(
+ await readFile(path, 'utf-8'),
+ )
+ if (version !== DEPENDENCIES_VERSION) {
+ debug?.(`ignored ${path}, the version ${version} is not supported`)
+ return
+ }
+ const root = this.vitest.config.root
+ const result = new Map()
+ for (const name in projects) {
+ const { modules, globalSetup, files } = projects[name]
+ const absoluteModules = modules.map((file) => resolve(root, file))
+ result.set(name, {
+ modules: absoluteModules,
+ moduleIndex: new Map(absoluteModules.map((file, index) => [file, index])),
+ globalSetup,
+ files: new Map(Object.entries(files)),
+ })
+ }
+ this.projects = result
+ } catch (error) {
+ debug?.(`failed to read ${path}: ${error}`)
+ }
+ }
+
+ async update(specifications: TestSpecification[]): Promise {
+ if (!this.recordedFiles.size && !this.recordedGlobalSetup.size) {
+ return
+ }
+ await this.read()
+
+ for (const [name, files] of this.recordedGlobalSetup) {
+ const project = this.getProjectDependencies(name)
+ project.globalSetup = this.intern(project, files)
+ }
+
+ for (const specification of specifications) {
+ const files = this.recordedFiles.get(specification.project.name)?.get(specification.moduleId)
+ const task = specification.testModule?.task
+ if (!files || !task) {
+ continue
+ }
+ const project = this.getProjectDependencies(specification.project.name)
+ const file = getRootRelativePath(this.vitest.config.root, specification.moduleId)
+ const previous = project.files.get(file)
+ // a partial run skips tests, so it can miss their dynamic imports,
+ // without a previous record the file keeps having none and always runs
+ if (!isPartialRun(this.vitest, specification, task)) {
+ project.files.set(file, this.intern(project, files))
+ } else if (previous) {
+ project.files.set(file, Array.from(new Set([...previous, ...this.intern(project, files)])))
+ }
+ }
+
+ this.recordedFiles.clear()
+ this.recordedGlobalSetup.clear()
+ await this.write()
+ }
+
+ async clear(): Promise {
+ this.projects.clear()
+ this.fileStamp = undefined
+ if (this.path && existsSync(this.path)) {
+ await rm(this.path, { force: true })
+ this.vitest.logger.log('[cache] cleared dependencies cache at', this.path)
+ }
+ }
+
+ // the files of the modules, with the files that plugins watch for them
+ private resolveFiles(project: TestProject, dependencies: string[]): string[] {
+ const files = new Set()
+ const add = (file: string) => {
+ if (isAbsolute(file) && !file.includes('node_modules')) {
+ files.add(file)
+ }
+ }
+ // without the module runner, plugins do not transform the modules and cannot watch files
+ const watchedByPlugins = project.config.experimental.viteModuleRunner !== false
+ for (const id of dependencies) {
+ add(cleanUrl(id))
+ if (!watchedByPlugins) {
+ continue
+ }
+ for (const environment of Object.values(project.vite.environments)) {
+ const node = environment.moduleGraph.getModuleById(id)
+ const transformed = node?.transformResult
+ if (!transformed) {
+ continue
+ }
+ const imports = new Set([...(transformed.deps || []), ...(transformed.dynamicDeps || [])])
+ node.importedModules.forEach((imported) => {
+ if (imported.file && !imports.has(imported.url)) {
+ add(imported.file)
+ }
+ })
+ break
+ }
+ }
+ return Array.from(files)
+ }
+
+ private intern(project: ProjectDependencies, files: string[]): number[] {
+ return files.map((file) => {
+ let index = project.moduleIndex.get(file)
+ if (index === undefined) {
+ index = project.modules.push(file) - 1
+ project.moduleIndex.set(file, index)
+ }
+ return index
+ })
+ }
+
+ private getProjectDependencies(name: string): ProjectDependencies {
+ let project = this.projects.get(name)
+ if (!project) {
+ project = { modules: [], moduleIndex: new Map(), files: new Map() }
+ this.projects.set(name, project)
+ }
+ return project
+ }
+
+ private async write(): Promise {
+ if (!this.path) {
+ return
+ }
+ const root = this.vitest.config.root
+ const projects: SerializedDependencies['projects'] = Object.create(null)
+ for (const [name, { modules, globalSetup, files }] of this.projects) {
+ projects[name] = {
+ modules: modules.map((file) => getRootRelativePath(root, file)),
+ globalSetup,
+ files: Object.fromEntries(files),
+ }
+ }
+ const dependencies: SerializedDependencies = { version: DEPENDENCIES_VERSION, projects }
+ try {
+ const cacheDir = dirname(this.path)
+ await mkdir(cacheDir, { recursive: true })
+ tagCacheDir(dirname(cacheDir))
+ await atomicWriteFile(this.path, JSON.stringify(dependencies))
+ this.fileStamp = await getFileStamp(this.path)
+ } catch (error) {
+ debug?.(`failed to write ${this.path}: ${error}`)
+ }
+ }
+}
diff --git a/packages/vitest/src/node/cache/index.ts b/packages/vitest/src/node/cache/index.ts
index b7fc74712..7f8ba09e4 100644
--- a/packages/vitest/src/node/cache/index.ts
+++ b/packages/vitest/src/node/cache/index.ts
@@ -3,6 +3,7 @@ import type { TestSpecification } from '../test-specification'
import type { CachedTestFileResult } from './results'
import { statSync } from 'node:fs'
import { resolve } from 'pathe'
+import { DependenciesCache } from './dependencies'
import { FileSystemModuleCache } from './fsModuleCache'
import { ResultsCache } from './results'
@@ -11,6 +12,8 @@ export class VitestCache {
_results: ResultsCache
/** @internal */
_modules: FileSystemModuleCache
+ /** @internal */
+ _dependencies: DependenciesCache
private fileStats = new Map()
private warnedMethods = new Set()
@@ -19,6 +22,7 @@ export class VitestCache {
constructor(private vitest: Vitest) {
this._results = new ResultsCache(vitest)
this._modules = new FileSystemModuleCache(vitest)
+ this._dependencies = new DependenciesCache(vitest)
}
/**
@@ -52,10 +56,21 @@ export class VitestCache {
await Promise.all([this._results.read(), this._modules.ensureCacheIntegrity()])
}
+ /** @internal */
+ async _update(specifications: TestSpecification[], startTime: number): Promise {
+ await Promise.all([
+ this._results.update(specifications, startTime),
+ this._dependencies.update(specifications),
+ ])
+ }
+
/** @internal */
async _clear(): Promise {
- await this._results.clear()
- await this._modules.clearCache()
+ await Promise.all([
+ this._results.clear(),
+ this._dependencies.clear(),
+ this._modules.clearCache(),
+ ])
}
private readFileStats(key: string): { size: number } | undefined {
diff --git a/packages/vitest/src/node/cache/results.ts b/packages/vitest/src/node/cache/results.ts
index 99b1b656d..c6ac2eb2e 100644
--- a/packages/vitest/src/node/cache/results.ts
+++ b/packages/vitest/src/node/cache/results.ts
@@ -125,7 +125,7 @@ export class ResultsCache {
const failed = state === 'fail'
const duration = Math.max(task.result?.duration ?? 0, 0)
- if (!this.isPartialRun(specification, task)) {
+ if (!isPartialRun(this.vitest, specification, task)) {
results.set(file, { failed, duration, lastRun: startTime })
} else if (failed) {
results.set(file, { duration, ...previous, failed })
@@ -153,29 +153,8 @@ export class ResultsCache {
}
}
- // 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
- )
- }
-
- // 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)
+ return getRootRelativePath(this.vitest.config.root, specification.moduleId)
}
private getProjectResults(name: string): ProjectResults {
@@ -216,8 +195,35 @@ function isFinalState(state: string | undefined): boolean {
return state === 'pass' || state === 'fail' || state === 'skip' || state === 'todo'
}
+// a partial run does not prove that the whole file passes
+export function isPartialRun(
+ vitest: Vitest,
+ specification: TestSpecification,
+ task: File,
+): boolean {
+ return !!(
+ vitest.isCancelling ||
+ task.containsOnly ||
+ specification.testLines?.length ||
+ specification.testIds?.length ||
+ specification.testNamePattern ||
+ specification.testTagsFilter?.length ||
+ vitest.getGlobalTestNamePattern() ||
+ vitest.config.tagsFilter?.length
+ )
+}
+
+// relative to the root, so the cache is the same in CI and locally
+export function getRootRelativePath(root: string, file: string): string {
+ // "relative" normalizes both paths, which is slow for thousands of files
+ if (file.startsWith(root) && file[root.length] === '/') {
+ return file.slice(root.length + 1)
+ }
+ return relative(root, file)
+}
+
// every write replaces the file, so a write by another process changes the inode
-async function getFileStamp(path: string): Promise {
+export 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 c2d5e77c5..0218263e1 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -936,6 +936,10 @@ export const cliOptionsConfig: VitestCLIOptions = {
description: 'Custom provider for detecting changed files. (default: `git`)',
subcommands: null,
},
+ recordDependencies: {
+ description:
+ 'Record the modules loaded by every test file in the cache and use the records with `--changed` and `--related` instead of the static module graph. A test file without a record always runs. (default: `false`)',
+ },
preParse: {
description:
'Parse test specifications before running them. This will apply `.only` flag and test name pattern across all files without running them. (default: `false`)',
diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts
index 09b0cae25..318e679cc 100644
--- a/packages/vitest/src/node/config/serializeConfig.ts
+++ b/packages/vitest/src/node/config/serializeConfig.ts
@@ -146,6 +146,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
importDurations: config.experimental.importDurations,
viteModuleRunner: config.experimental.viteModuleRunner ?? true,
nodeLoader: config.experimental.nodeLoader ?? true,
+ recordDependencies: !!globalConfig.experimental.recordDependencies && !!globalConfig.cache,
openTelemetry: config.experimental.openTelemetry,
},
tags: config.tags || [],
diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts
index aea7a7518..294ae1513 100644
--- a/packages/vitest/src/node/core.ts
+++ b/packages/vitest/src/node/core.ts
@@ -1068,7 +1068,7 @@ export class Vitest {
this.state.catchError(err, 'Unhandled Error')
}
- await this.cache._results.update(specs, startTime)
+ await this.cache._update(specs, startTime)
return {
testModules: this.state.getTestModules(),
diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts
index 7f4390e60..2f949c82b 100644
--- a/packages/vitest/src/node/pools/rpc.ts
+++ b/packages/vitest/src/node/pools/rpc.ts
@@ -391,6 +391,9 @@ export function createMethodsRPC(
onAsyncLeaks(leaks) {
vitest.state.catchLeaks(leaks)
},
+ onTestModuleDependencies(filepath, dependencies) {
+ vitest.cache._dependencies.record(project, filepath, dependencies)
+ },
onCancel(reason) {
vitest.cancelCurrentRun(reason)
},
diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts
index d9f60ba23..9c1555d71 100644
--- a/packages/vitest/src/node/project.ts
+++ b/packages/vitest/src/node/project.ts
@@ -15,6 +15,12 @@ import { deepMerge, nanoid, slash } from '@vitest/utils/helpers'
import { isAbsolute, join, relative } from 'pathe'
import pm from 'picomatch'
import { createDefinesScript } from '../utils/config-helpers'
+import {
+ collectDependencies,
+ getEvaluatedImports,
+ nativeImports,
+ trackNativeImports,
+} from '../utils/module-dependencies'
import { NativeModuleRunner } from '../utils/nativeModuleRunner'
import { BenchmarkManager } from './benchmark'
import { serializeConfig } from './config/serializeConfig'
@@ -215,7 +221,24 @@ export class TestProject {
return
}
+ const native = this.runner instanceof NativeModuleRunner
+ // global setup is imported by this process, so its imports are only known to a resolve hook
+ const canRecord =
+ !native ||
+ !this.vitest.config.experimental.recordDependencies ||
+ !this.config.globalSetup.length ||
+ trackNativeImports()
+
this._globalSetups = await loadGlobalSetupFiles(this.runner, this.config.globalSetup)
+ if (canRecord) {
+ this.vitest.cache._dependencies.recordGlobalSetup(
+ this,
+ collectDependencies(
+ native ? nativeImports.getImports : getEvaluatedImports(this.runner.evaluatedModules),
+ this.config.globalSetup,
+ ),
+ )
+ }
for (const globalSetupFile of this._globalSetups) {
const teardown = await globalSetupFile.setup?.(this)
diff --git a/packages/vitest/src/node/recorded-modules.ts b/packages/vitest/src/node/recorded-modules.ts
new file mode 100644
index 000000000..40884a510
--- /dev/null
+++ b/packages/vitest/src/node/recorded-modules.ts
@@ -0,0 +1,79 @@
+import type { Vitest } from './core'
+import type { TestProject } from './project'
+import type { TestSpecification } from './test-specification'
+import { getEnvFiles } from './affected-modules'
+import { getRootRelativePath } from './cache/results'
+
+/**
+ * Finds test specifications that loaded any of the changed files during their last recorded run.
+ * A specification without a record is always affected.
+ */
+export class RecordedModulesResolver {
+ constructor(
+ private vitest: Vitest,
+ private related: string[],
+ ) {}
+
+ async resolve(specs: TestSpecification[]): Promise {
+ if (this.dependsOnConfig(this.vitest.vite.config.configFileDependencies)) {
+ return specs
+ }
+ await this.vitest.cache._dependencies.read()
+
+ const specsByProject = new Map()
+ for (const spec of specs) {
+ let projectSpecs = specsByProject.get(spec.project)
+ if (!projectSpecs) {
+ specsByProject.set(spec.project, (projectSpecs = []))
+ }
+ projectSpecs.push(spec)
+ }
+
+ const affected = new Set()
+ for (const [project, projectSpecs] of specsByProject) {
+ this.findAffectedInProject(project, projectSpecs).forEach((spec) => affected.add(spec))
+ }
+ return specs.filter((spec) => affected.has(spec))
+ }
+
+ private findAffectedInProject(
+ project: TestProject,
+ specs: TestSpecification[],
+ ): TestSpecification[] {
+ if (
+ this.dependsOnConfig(project.vite.config.configFileDependencies) ||
+ this.dependsOnConfig(getEnvFiles(project))
+ ) {
+ return specs
+ }
+
+ const dependencies = this.vitest.cache._dependencies.get(project)
+ if (!dependencies) {
+ return specs
+ }
+
+ const changed = new Set()
+ for (const file of this.related) {
+ const index = dependencies.moduleIndex.get(file)
+ if (index !== undefined) {
+ changed.add(index)
+ }
+ }
+ if (project.config.globalSetup.length) {
+ const globalSetup = dependencies.globalSetup
+ if (!globalSetup || globalSetup.some((index) => changed.has(index))) {
+ return specs
+ }
+ }
+
+ const root = this.vitest.config.root
+ return specs.filter((spec) => {
+ const loaded = dependencies.files.get(getRootRelativePath(root, spec.moduleId))
+ return !loaded || loaded.some((index) => changed.has(index))
+ })
+ }
+
+ private dependsOnConfig(configDependencies: string[] | undefined): boolean {
+ return !!configDependencies?.some((file) => this.related.includes(file))
+ }
+}
diff --git a/packages/vitest/src/node/specifications.ts b/packages/vitest/src/node/specifications.ts
index 701521cf4..72fe605ce 100644
--- a/packages/vitest/src/node/specifications.ts
+++ b/packages/vitest/src/node/specifications.ts
@@ -5,6 +5,7 @@ import pm from 'picomatch'
import { AffectedModulesResolver } from './affected-modules'
import { groupFilters, parseFilter } from './cli/filter'
import { IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors'
+import { RecordedModulesResolver } from './recorded-modules'
export class VitestSpecifications {
private readonly _cachedSpecs = new Map()
@@ -148,7 +149,10 @@ export class VitestSpecifications {
return []
}
- const affectedSpecs = await new AffectedModulesResolver(this.vitest, related).resolve(specs)
+ const resolver = this.vitest.config.experimental.recordDependencies
+ ? new RecordedModulesResolver(this.vitest, related)
+ : new AffectedModulesResolver(this.vitest, related)
+ const affectedSpecs = await resolver.resolve(specs)
this.vitest._sourceFilterResult = { affected: affectedSpecs.length, total: specs.length }
return affectedSpecs
}
diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts
index 310b23809..e71a7ca71 100644
--- a/packages/vitest/src/node/types/config.ts
+++ b/packages/vitest/src/node/types/config.ts
@@ -1034,6 +1034,15 @@ export interface InlineConfig {
*/
vcsProvider?: VCSProvider | string
+ /**
+ * Record the modules that every test file loads in the cache and use the records
+ * with `--changed` and `--related` instead of walking the module graph statically.
+ *
+ * A test file without a record always runs.
+ * @default false
+ */
+ recordDependencies?: boolean
+
/**
* Parse test specifications before running them.
* This will apply `.only` flag and test name pattern across all files without running them.
diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts
index 8842ded19..4ed880a37 100644
--- a/packages/vitest/src/runtime/config.ts
+++ b/packages/vitest/src/runtime/config.ts
@@ -163,6 +163,7 @@ export interface SerializedConfig {
}
viteModuleRunner: boolean
nodeLoader: boolean
+ recordDependencies: boolean
openTelemetry:
| {
enabled: boolean
diff --git a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
index 2605a89de..c2ea62b1d 100644
--- a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
+++ b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
@@ -149,6 +149,7 @@ export class VitestModuleRunner extends viteModuleRunner.ModuleRunner implements
callstack: string[] = [],
metadata?: SSRImportMetadata,
) {
+ this.vitestOptions.getWorkerState().dependencies?.add(module.id)
// @ts-expect-error "cachedRequest" is private
return super.cachedRequest(url, module, callstack, metadata)
}
diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts
index be707d61e..0e9d67a38 100644
--- a/packages/vitest/src/runtime/runBaseTests.ts
+++ b/packages/vitest/src/runtime/runBaseTests.ts
@@ -7,6 +7,11 @@ import { performance } from 'node:perf_hooks'
import { startCoverageInsideWorker, stopCoverageInsideWorker } from '../integrations/coverage'
import { resolveSnapshotEnvironment } from '../integrations/snapshot/environments/resolveSnapshotEnvironment'
import { vi } from '../integrations/vi'
+import {
+ collectDependencies,
+ getEvaluatedImports,
+ nativeImports,
+} from '../utils/module-dependencies'
import { detectAsyncLeaks } from './detect-async-leaks'
import { closeInspector } from './inspector'
import { collectTests, startTests } from './runner/run'
@@ -47,6 +52,13 @@ export async function run(
})
workerState.durations.prepare = performance.now() - workerState.durations.prepare
+ // modules loaded before the first file are shared by every file in this worker
+ const dependencies = workerState.dependencies
+ const preparedModules = new Set(dependencies)
+ const getImports =
+ config.experimental.viteModuleRunner === false
+ ? nativeImports.getImports
+ : getEvaluatedImports(workerState.evaluatedModules)
try {
await traces.$(`vitest.test.runner.${method}`, async () => {
for (const file of files) {
@@ -56,6 +68,7 @@ export async function run(
}
workerState.filepath = file.filepath
+ dependencies?.clear()
if (method === 'run') {
const collectAsyncLeaks = config.detectAsyncLeaks
@@ -68,6 +81,13 @@ export async function run(
() => startTests([file], testRunner),
)
+ if (dependencies) {
+ workerState.rpc.onTestModuleDependencies(
+ file.filepath,
+ collectDependencies(getImports, preparedModules, dependencies),
+ )
+ }
+
const leaks = await collectAsyncLeaks?.()
if (leaks?.length) {
diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts
index c194df49f..80b6ffe2a 100644
--- a/packages/vitest/src/runtime/runVmTests.ts
+++ b/packages/vitest/src/runtime/runVmTests.ts
@@ -12,6 +12,7 @@ import { setupChaiConfig } from '../integrations/chai/config'
import { startCoverageInsideWorker, stopCoverageInsideWorker } from '../integrations/coverage'
import { resolveSnapshotEnvironment } from '../integrations/snapshot/environments/resolveSnapshotEnvironment'
import * as VitestIndex from '../public/index'
+import { collectDependencies, getEvaluatedImports } from '../utils/module-dependencies'
import { detectAsyncLeaks } from './detect-async-leaks'
import { closeInspector } from './inspector'
import { collectTests, startTests } from './runner/run'
@@ -96,11 +97,16 @@ export async function run(
performance.now() - workerState.durations.prepare - workerState.durations.environment
const { vi } = VitestIndex
+ // modules loaded before the first file are shared by every file in this worker
+ const dependencies = workerState.dependencies
+ const preparedModules = new Set(dependencies)
+ const getImports = getEvaluatedImports(workerState.evaluatedModules)
try {
await traces.$(`vitest.test.runner.${method}`, async () => {
for (const file of files) {
workerState.filepath = file.filepath
+ dependencies?.clear()
if (method === 'run') {
const collectAsyncLeaks = config.detectAsyncLeaks
@@ -113,6 +119,13 @@ export async function run(
() => startTests([file], testRunner),
)
+ if (dependencies) {
+ workerState.rpc.onTestModuleDependencies(
+ file.filepath,
+ collectDependencies(getImports, preparedModules, dependencies),
+ )
+ }
+
const leaks = await collectAsyncLeaks?.()
if (leaks?.length) {
diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts
index ec44ac4c0..e80c6e79a 100644
--- a/packages/vitest/src/runtime/runners/test.ts
+++ b/packages/vitest/src/runtime/runners/test.ts
@@ -105,6 +105,9 @@ export class TestRunner implements VitestTestRunner {
}
}
+ const snapshotState = this.snapshotClient.getSnapshotState(suite.file.filepath)
+ snapshotState.getSnapshotFiles().forEach((file) => this.workerState.dependencies?.add(file))
+
const result = await this.snapshotClient.finish(suite.file.filepath)
if (this.workerState.config.snapshotOptions.updateSnapshot === 'none' && result.unchecked) {
let message = `Obsolete snapshots found when no snapshot update is expected.\n`
diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts
index 606757e8d..09bb36dcd 100644
--- a/packages/vitest/src/runtime/worker.ts
+++ b/packages/vitest/src/runtime/worker.ts
@@ -44,6 +44,7 @@ async function execute(
evaluatedModules: new VitestEvaluatedModules(),
resolvingModules,
moduleExecutionInfo: new Map(),
+ dependencies: ctx.config.experimental.recordDependencies ? new Set() : undefined,
config: ctx.config,
// this is set later by vm or base
environment: null!,
diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts
index 38a06cf1d..78e2ae2ac 100644
--- a/packages/vitest/src/runtime/workers/base.ts
+++ b/packages/vitest/src/runtime/workers/base.ts
@@ -7,6 +7,7 @@ import { runInThisContext } from 'node:vm'
import * as spyModule from '@vitest/spy'
import { setupChaiConfig } from '../../integrations/chai/config'
import { loadEnvironment } from '../../integrations/env/loader'
+import { nativeImports } from '../../utils/module-dependencies'
import { Traces } from '../../utils/traces'
import { emitModuleRunner } from '../listeners'
import { listenForErrors } from '../moduleRunner/errorCatcher'
@@ -70,7 +71,18 @@ async function startModuleRunner(options: ContextModuleRunnerOptions): Promise = []
let _environmentTime: number
+// the files reported by the resolve hook are added here when the module runner is disabled
+let _nativeDependencies: Set | undefined
+let _canRecordDependencies = true
+
+function recordNativeImport(url: string, parentURL: string): void {
+ const file = nativeImports.record(url, parentURL)
+ if (file) {
+ _nativeDependencies?.add(file)
+ }
+}
/** @experimental */
export async function setupBaseEnvironment(
@@ -78,7 +90,13 @@ export async function setupBaseEnvironment(
): Promise<() => Promise> {
if (context.config.experimental.viteModuleRunner === false) {
const { setupNodeLoaderHooks } = await import('./native')
- await setupNodeLoaderHooks(context)
+ const recordDependencies = context.config.experimental.recordDependencies
+ _canRecordDependencies = await setupNodeLoaderHooks(
+ context,
+ recordDependencies ? recordNativeImport : undefined,
+ )
+ // until the tests start, the imports belong to the environment
+ _nativeDependencies = recordDependencies ? new Set() : undefined
}
const startTime = performance.now()
@@ -100,6 +118,9 @@ export async function setupBaseEnvironment(
context.config.experimental.viteModuleRunner,
)
_currentEnvironment = environment
+ _environmentModules = loader
+ ? Array.from(loader.evaluatedModules.idToModuleMap.keys())
+ : (_nativeDependencies ?? [])
const env = await otel.$(
'vitest.runtime.environment.setup',
{
@@ -135,6 +156,16 @@ export async function runBaseTests(
// state has new context, but we want to reuse existing ones
state.evaluatedModules = evaluatedModules
state.moduleExecutionInfo = moduleExecutionInfo
+ if (!_canRecordDependencies) {
+ // a test file without a record always runs
+ state.dependencies = undefined
+ }
+ for (const id of _environmentModules) {
+ state.dependencies?.add(id)
+ }
+ if (_nativeDependencies) {
+ _nativeDependencies = state.dependencies
+ }
provideWorkerState(globalThis, state)
diff --git a/packages/vitest/src/runtime/workers/native.ts b/packages/vitest/src/runtime/workers/native.ts
index 7a727292f..b0ab16564 100644
--- a/packages/vitest/src/runtime/workers/native.ts
+++ b/packages/vitest/src/runtime/workers/native.ts
@@ -17,7 +17,14 @@ const NOW_LENGTH = Date.now().toString().length
const REGEXP_VITEST = new RegExp(`%3Fvitest=\\d{${NOW_LENGTH}}`)
const REGEXP_MOCK_ACTUAL = /\?mock=actual/
-export async function setupNodeLoaderHooks(worker: WorkerSetupContext): Promise {
+/**
+ * Returns `true` if `onImport` is called for every import, the hooks of `module.register` run in another thread
+ * and cannot report imports before the test file finishes.
+ */
+export async function setupNodeLoaderHooks(
+ worker: WorkerSetupContext,
+ onImport?: (url: string, parentURL: string) => void,
+): Promise {
if (module.setSourceMapsSupport) {
module.setSourceMapsSupport(true)
} else if (process.setSourceMapsEnabled) {
@@ -54,6 +61,7 @@ export async function setupNodeLoaderHooks(worker: WorkerSetupContext): Promise<
worker.rpc.ensureModuleGraphEntry(result.url, context.parentURL).catch(() => {
// ignore errors
})
+ onImport?.(result.url, context.parentURL)
}
// this is require for in-source tests to be invalidated if
@@ -76,6 +84,8 @@ export async function setupNodeLoaderHooks(worker: WorkerSetupContext): Promise<
const mocker = getNativeMocker()
const mockedResult = mocker?.resolveMockedModule(result.url, context.parentURL)
if (mockedResult != null) {
+ // a `__mocks__` file is loaded in place of the module
+ onImport?.(mockedResult.url, context.parentURL)
return mockedResult
}
@@ -83,6 +93,7 @@ export async function setupNodeLoaderHooks(worker: WorkerSetupContext): Promise<
},
load: worker.config.experimental.nodeLoader === false ? undefined : createLoadHook(worker),
})
+ return true
} else if (module.register) {
if (worker.config.experimental.nodeLoader !== false) {
console.warn(
@@ -118,6 +129,7 @@ export async function setupNodeLoaderHooks(worker: WorkerSetupContext): Promise<
'"module.registerHooks" and "module.register" are not supported. Some Vitest features may not work. Please, use Node.js 18.19.0 or higher.',
)
}
+ return false
}
function replaceInSourceMarker(url: string, source: string, ms: () => MagicString) {
diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts
index aa1fc3073..840acbdf7 100644
--- a/packages/vitest/src/runtime/workers/vm.ts
+++ b/packages/vitest/src/runtime/workers/vm.ts
@@ -40,7 +40,7 @@ export async function runVmTests(
const { ctx, rpc } = state
const beforeEnvironmentTime = performance.now()
- const { environment } = await loadEnvironment(
+ const { environment, loader } = await loadEnvironment(
ctx.environment.name,
ctx.config.root,
rpc,
@@ -48,6 +48,7 @@ export async function runVmTests(
true,
)
state.environment = environment
+ loader?.evaluatedModules.idToModuleMap.forEach((_, id) => state.dependencies?.add(id))
// let the server transform this file's import graph while this worker is
// busy setting up the environment (jsdom takes ~0.5s per worker) —
diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts
index 00c01be6e..c597882a8 100644
--- a/packages/vitest/src/types/rpc.ts
+++ b/packages/vitest/src/types/rpc.ts
@@ -52,6 +52,7 @@ export interface RuntimeRPC {
onUserConsoleLog: (log: UserConsoleLog) => void
onUnhandledError: (err: unknown, type: string) => void
onAsyncLeaks: (leak: AsyncLeak[]) => void
+ onTestModuleDependencies: (filepath: string, dependencies: string[]) => void
onQueued: (file: File) => void
onCollected: (files: File[]) => Promise
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void
diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts
index 4aa9ff858..175c921eb 100644
--- a/packages/vitest/src/types/worker.ts
+++ b/packages/vitest/src/types/worker.ts
@@ -81,6 +81,11 @@ export interface WorkerGlobalState {
evaluatedModules: EvaluatedModules
resolvingModules: Set
moduleExecutionInfo: Map
+ /**
+ * Module ids and file paths loaded by the current test file. Only set when
+ * `experimental.recordDependencies` is enabled.
+ */
+ dependencies?: Set
getterTracker?: GetterTracker
onCancel: (listener: (reason: CancelReason) => unknown) => () => void
onCleanup: (listener: () => unknown) => void
diff --git a/packages/vitest/src/utils/module-dependencies.ts b/packages/vitest/src/utils/module-dependencies.ts
new file mode 100644
index 000000000..9803e3e3b
--- /dev/null
+++ b/packages/vitest/src/utils/module-dependencies.ts
@@ -0,0 +1,99 @@
+import type { EvaluatedModules } from 'vite/module-runner'
+import module from 'node:module'
+import { fileURLToPath } from 'node:url'
+import { slash } from '@vitest/utils/helpers'
+import { distDir } from '../paths'
+
+type ImportsLookup = (id: string) => Iterable | undefined
+
+const vitestDistDir = `${slash(distDir)}/`
+
+/**
+ * Ids of the roots and of every module reachable from them through the runtime import edges.
+ */
+export function collectDependencies(
+ getImports: ImportsLookup,
+ ...roots: Iterable[]
+): string[] {
+ const visited = new Set()
+ const queue: string[] = []
+ for (const ids of roots) {
+ for (const id of ids) {
+ if (!visited.has(id)) {
+ visited.add(id)
+ queue.push(id)
+ }
+ }
+ }
+ while (queue.length) {
+ const imports = getImports(queue.pop()!)
+ if (!imports) {
+ continue
+ }
+ for (const id of imports) {
+ if (!visited.has(id)) {
+ visited.add(id)
+ queue.push(id)
+ }
+ }
+ }
+ return Array.from(visited)
+}
+
+export function getEvaluatedImports(evaluatedModules: EvaluatedModules): ImportsLookup {
+ return (id) => evaluatedModules.getModuleById(id)?.imports
+}
+
+class NativeImportGraph {
+ private imports = new Map>()
+
+ /**
+ * Returns the imported file, or `undefined` if the import is not tracked.
+ */
+ record(url: string, parentURL: string): string | undefined {
+ if (!url.startsWith('file:') || url.includes('/node_modules/')) {
+ return
+ }
+ const file = slash(fileURLToPath(url))
+ const importer = parentURL.startsWith('file:') ? slash(fileURLToPath(parentURL)) : parentURL
+ // Vitest imports every test file, its edges would connect unrelated test files
+ if (!importer.startsWith(vitestDistDir)) {
+ let imports = this.imports.get(importer)
+ if (!imports) {
+ this.imports.set(importer, (imports = new Set()))
+ }
+ imports.add(file)
+ }
+ return file
+ }
+
+ getImports: ImportsLookup = (id) => this.imports.get(id)
+}
+
+/**
+ * Import edges of the modules loaded with native `import`, reported by a Node.js resolve hook.
+ * Node.js never loads a module twice, so the edges are kept as long as the thread runs.
+ */
+export const nativeImports: NativeImportGraph = new NativeImportGraph()
+
+let trackingNativeImports = false
+
+/**
+ * Registers a resolve hook that records the native imports of this thread.
+ * Returns `false` if the runtime has no synchronous module hooks.
+ */
+export function trackNativeImports(): boolean {
+ if (!trackingNativeImports && typeof module.registerHooks === 'function') {
+ module.registerHooks({
+ resolve(specifier, context, nextResolve) {
+ const result = nextResolve(specifier, context)
+ if (context.parentURL) {
+ nativeImports.record(result.url, context.parentURL)
+ }
+ return result
+ },
+ })
+ trackingNativeImports = true
+ }
+ return trackingNativeImports
+}
diff --git a/test/e2e/test/recorded-deps.test.ts b/test/e2e/test/recorded-deps.test.ts
new file mode 100644
index 000000000..1d60b9702
--- /dev/null
+++ b/test/e2e/test/recorded-deps.test.ts
@@ -0,0 +1,1099 @@
+import type { Vitest } from 'vitest/node'
+import { existsSync, readFileSync, rmSync } from 'node:fs'
+import { resolve } from 'pathe'
+import { describe, expect, test } from 'vitest'
+import { runInlineTests, runVitest } from '../../test-utils'
+
+const config = { cache: true, experimental: { recordDependencies: true } }
+
+function testFile(name: string, imports = '') {
+ return `
+ ${imports}
+ import { test } from 'vitest'
+ test('${name}', () => {})
+ `
+}
+
+function getDependenciesPath(ctx: Vitest | undefined) {
+ return resolve(ctx!.viteConfig.cacheDir, 'dependencies.json')
+}
+
+// the files every test file loaded, sorted; the workspace `vitest` package is outside of the root
+function readDependencies(ctx: Vitest | undefined) {
+ const { projects } = JSON.parse(readFileSync(getDependenciesPath(ctx), 'utf-8'))
+ const result: Record> = {}
+ for (const name in projects) {
+ const { modules, files, globalSetup } = projects[name]
+ const project: Record = (result[name] = {})
+ const toFiles = (indices: number[]) =>
+ indices
+ .map((index: number) => modules[index])
+ .filter((file: string) => !file.startsWith('../'))
+ .sort()
+ if (globalSetup) {
+ project[''] = toFiles(globalSetup)
+ }
+ for (const file in files) {
+ project[file] = toFiles(files[file])
+ }
+ }
+ return result
+}
+
+test('only the tests that loaded a changed file run', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/helper.js': 'export const helper = 1',
+ 'src/other.js': 'export const other = 1',
+ 'a.test.js': testFile('a', `import './src/helper.js'`),
+ 'b.test.js': testFile('b', `import './src/other.js'`),
+ 'c.test.js': `
+ import { test } from 'vitest'
+ test('c', async () => {
+ await import('./src/helper.js')
+ })
+ `,
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/helper.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "src/other.js",
+ ],
+ "c.test.js": [
+ "c.test.js",
+ "src/helper.js",
+ ],
+ },
+ }
+ `)
+
+ fs.createFile('d.test.js', testFile('d'))
+ const changed = await runVitest({ root: fs.root, ...config, related: ['src/helper.js'] })
+
+ expect(changed.stderr).toBe('')
+ // "d" has no record, so it runs
+ expect(changed.testTree()).toMatchInlineSnapshot(`
+ {
+ "a.test.js": {
+ "a": "passed",
+ },
+ "c.test.js": {
+ "c": "passed",
+ },
+ "d.test.js": {
+ "d": "passed",
+ },
+ }
+ `)
+})
+
+test.for([
+ { pool: 'vmForks', isolate: true },
+ { pool: 'forks', isolate: false },
+])('a shared module graph records every test file ($pool, isolate: $isolate)', async (options) => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/shared.js': `import './inner.js'`,
+ 'src/inner.js': 'export const inner = 1',
+ 'a.test.js': testFile('a', `import './src/shared.js'`),
+ 'b.test.js': testFile('b', `import './src/shared.js'`),
+ 'c.test.js': testFile('c'),
+ },
+ { ...config, ...options, fileParallelism: false },
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/inner.js",
+ "src/shared.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "src/inner.js",
+ "src/shared.js",
+ ],
+ "c.test.js": [
+ "c.test.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({ root: fs.root, ...config, related: ['src/inner.js'] })
+
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+})
+
+test('a mocked module does not load its own imports', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/dep.js': `
+ import './inner.js'
+ export const dep = 1
+ `,
+ 'src/inner.js': 'export const inner = 1',
+ 'a.test.js': `
+ import { test, vi } from 'vitest'
+ import './src/dep.js'
+ vi.mock('./src/dep.js', () => ({ dep: 2 }))
+ test('a', () => {})
+ `,
+ 'b.test.js': testFile('b', `import './src/dep.js'`),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/dep.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "src/dep.js",
+ "src/inner.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({ root: fs.root, ...config, related: ['src/inner.js'] })
+
+ expect(changed.stderr).toBe('')
+ expect(changed.testTree()).toMatchInlineSnapshot(`
+ {
+ "b.test.js": {
+ "b": "passed",
+ },
+ }
+ `)
+})
+
+test('a file loaded by a setup file or a global setup runs every test', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'vitest.config.js': {
+ test: { ...config, setupFiles: ['./setup.js'], globalSetup: ['./global-setup.js'] },
+ },
+ 'setup.js': `import './src/setup-helper.js'`,
+ 'src/setup-helper.js': 'export {}',
+ 'global-setup.js': `
+ import './src/global-helper.js'
+ export default () => {}
+ `,
+ 'src/global-helper.js': 'export {}',
+ 'a.test.js': testFile('a'),
+ 'b.test.js': testFile('b'),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "": [
+ "global-setup.js",
+ "src/global-helper.js",
+ ],
+ "a.test.js": [
+ "a.test.js",
+ "setup.js",
+ "src/setup-helper.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "setup.js",
+ "src/setup-helper.js",
+ ],
+ },
+ }
+ `)
+
+ const setupChanged = await runVitest({
+ root: fs.root,
+ ...config,
+ related: ['src/setup-helper.js'],
+ })
+ expect(setupChanged.stderr).toBe('')
+ expect(Object.keys(setupChanged.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+
+ const globalSetupChanged = await runVitest({
+ root: fs.root,
+ ...config,
+ related: ['src/global-helper.js'],
+ })
+ expect(globalSetupChanged.stderr).toBe('')
+ expect(Object.keys(globalSetupChanged.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+
+ const unrelated = await runVitest({ root: fs.root, ...config, related: ['src/none.js'] })
+ expect(unrelated.stderr).toMatchInlineSnapshot(`
+ "No affected test files found, exiting with code 1
+
+ "
+ `)
+ expect(unrelated.testTree()).toMatchInlineSnapshot(`{}`)
+})
+
+test('a partial run keeps the modules of the previous complete run', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/first.js': 'export const first = 1',
+ 'src/second.js': 'export const second = 1',
+ 'a.test.js': `
+ import { test } from 'vitest'
+ test('first', async () => {
+ await import('./src/first.js')
+ })
+ test('second', async () => {
+ await import('./src/second.js')
+ })
+ `,
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)['']['a.test.js']).toEqual([
+ 'a.test.js',
+ 'src/first.js',
+ 'src/second.js',
+ ])
+
+ const partial = await runVitest({ root: fs.root, ...config, testNamePattern: 'first' })
+ expect(partial.stderr).toBe('')
+ expect(readDependencies(partial.ctx)['']['a.test.js']).toEqual([
+ 'a.test.js',
+ 'src/first.js',
+ 'src/second.js',
+ ])
+})
+
+test('a partial run without a previous record does not create one', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/dynamic.js': 'export {}',
+ 'a.test.js': `
+ import { test } from 'vitest'
+ test('static', () => {})
+ test('dynamic', async () => {
+ await import('./src/dynamic.js')
+ })
+ `,
+ 'b.test.js': testFile('b'),
+ },
+ { ...config, testNamePattern: 'static' },
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {},
+ }
+ `)
+
+ // files without a record always run, and the complete run records them
+ const first = await runVitest({ root: fs.root, ...config, related: ['src/dynamic.js'] })
+ expect(first.stderr).toBe('')
+ expect(first.testTree()).toMatchInlineSnapshot(`
+ {
+ "a.test.js": {
+ "dynamic": "passed",
+ "static": "passed",
+ },
+ "b.test.js": {
+ "b": "passed",
+ },
+ }
+ `)
+ expect(readDependencies(first.ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/dynamic.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ ],
+ },
+ }
+ `)
+
+ const second = await runVitest({ root: fs.root, ...config, related: ['src/dynamic.js'] })
+ expect(second.stderr).toBe('')
+ expect(Object.keys(second.testTree())).toEqual(['a.test.js'])
+})
+
+test('a mock loaded from a file is recorded', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/dep.js': `
+ import './inner.js'
+ export const dep = 1
+ `,
+ 'src/inner.js': 'export const inner = 1',
+ 'src/__mocks__/dep.js': 'export const dep = 2',
+ 'src/dep.mock.js': 'export const dep = 3',
+ 'automock.test.js': `
+ import { test, vi } from 'vitest'
+ import './src/dep.js'
+ vi.mock('./src/dep.js')
+ test('automock', () => {})
+ `,
+ 'factory.test.js': `
+ import { test, vi } from 'vitest'
+ import './src/dep.js'
+ vi.mock('./src/dep.js', () => import('./src/dep.mock.js'))
+ test('factory', () => {})
+ `,
+ 'original.test.js': `
+ import { test, vi } from 'vitest'
+ import './src/dep.js'
+ vi.mock('./src/dep.js', async (importOriginal) => ({ ...(await importOriginal()), dep: 4 }))
+ test('original', () => {})
+ `,
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "automock.test.js": [
+ "automock.test.js",
+ "src/__mocks__/dep.js",
+ "src/dep.js",
+ ],
+ "factory.test.js": [
+ "factory.test.js",
+ "src/dep.js",
+ "src/dep.mock.js",
+ ],
+ "original.test.js": [
+ "original.test.js",
+ "src/dep.js",
+ "src/inner.js",
+ ],
+ },
+ }
+ `)
+
+ const mockChanged = await runVitest({
+ root: fs.root,
+ ...config,
+ related: ['src/__mocks__/dep.js', 'src/dep.mock.js'],
+ })
+ expect(mockChanged.stderr).toBe('')
+ expect(Object.keys(mockChanged.testTree()).sort()).toEqual([
+ 'automock.test.js',
+ 'factory.test.js',
+ ])
+
+ const innerChanged = await runVitest({ root: fs.root, ...config, related: ['src/inner.js'] })
+ expect(innerChanged.stderr).toBe('')
+ expect(Object.keys(innerChanged.testTree())).toEqual(['original.test.js'])
+})
+
+test('a file imported with a query and a file added by a plugin are recorded', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'vitest.config.js': `
+ import { readFileSync } from 'node:fs'
+ export default {
+ plugins: [{
+ name: 'template',
+ load(id) {
+ if (id.endsWith('/src/template.js')) {
+ const html = id.replace(/\\.js$/, '.html')
+ this.addWatchFile(html)
+ return \`export default \${JSON.stringify(readFileSync(html, 'utf-8'))}\`
+ }
+ },
+ }],
+ test: ${JSON.stringify(config)},
+ }
+ `,
+ 'src/data.txt': 'data',
+ 'src/template.html': '',
+ 'src/template.js': 'export default ""',
+ 'raw.test.js': testFile('raw', `import './src/data.txt?raw'`),
+ 'template.test.js': testFile('template', `import './src/template.js'`),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "raw.test.js": [
+ "raw.test.js",
+ "src/data.txt",
+ ],
+ "template.test.js": [
+ "src/template.html",
+ "src/template.js",
+ "template.test.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({
+ root: fs.root,
+ ...config,
+ related: ['src/data.txt', 'src/template.html'],
+ })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['raw.test.js', 'template.test.js'])
+})
+
+test.for([{ pool: 'forks' }, { pool: 'vmForks' }])(
+ 'a file loaded by a custom environment runs every test ($pool)',
+ async ({ pool }) => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'environment.js': `
+ import { builtinEnvironments } from 'vitest/runtime'
+ import './src/environment-helper.js'
+ export default {
+ name: 'custom',
+ viteEnvironment: 'ssr',
+ setup() {
+ return { teardown() {} }
+ },
+ setupVM: (options) => builtinEnvironments.node.setupVM(options),
+ }
+ `,
+ 'src/environment-helper.js': 'export {}',
+ 'a.test.js': testFile('a'),
+ 'b.test.js': testFile('b'),
+ },
+ { ...config, pool, environment: './environment.js' },
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "environment.js",
+ "src/environment-helper.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "environment.js",
+ "src/environment-helper.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({
+ root: fs.root,
+ ...config,
+ pool,
+ environment: './environment.js',
+ related: ['src/environment-helper.js'],
+ })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+ },
+)
+
+test('a file loaded by a custom runner runs every test', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'runner.js': `
+ import './src/runner-helper.js'
+ export { TestRunner as default } from 'vitest'
+ `,
+ 'src/runner-helper.js': 'export {}',
+ 'a.test.js': testFile('a'),
+ 'b.test.js': testFile('b'),
+ },
+ { ...config, runner: './runner.js' },
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "runner.js",
+ "src/runner-helper.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "runner.js",
+ "src/runner-helper.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({
+ root: fs.root,
+ ...config,
+ runner: './runner.js',
+ related: ['src/runner-helper.js'],
+ })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+})
+
+test.for(['vitest.config.js', '.env'])('a change in %s runs every test', async (file) => {
+ const { fs, stderr } = await runInlineTests(
+ {
+ 'vitest.config.js': { test: config },
+ 'a.test.js': testFile('a'),
+ 'b.test.js': testFile('b'),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+
+ const changed = await runVitest({ root: fs.root, ...config, related: [file] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+})
+
+test('snapshot files are recorded for the tests that use them', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ '__snapshots__/snap.test.js.snap': `
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[\`snap 1\`] = \`1\`;
+`,
+ 'fixtures/file.txt': 'content',
+ 'snap.test.js': `
+ import { expect, test } from 'vitest'
+ test('snap', () => {
+ expect(1).toMatchSnapshot()
+ })
+ `,
+ 'file.test.js': `
+ import { expect, test } from 'vitest'
+ test('file', async () => {
+ await expect('content').toMatchFileSnapshot('./fixtures/file.txt')
+ })
+ `,
+ 'plain.test.js': testFile('plain'),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "file.test.js": [
+ "file.test.js",
+ "fixtures/file.txt",
+ ],
+ "plain.test.js": [
+ "plain.test.js",
+ ],
+ "snap.test.js": [
+ "__snapshots__/snap.test.js.snap",
+ "snap.test.js",
+ ],
+ },
+ }
+ `)
+
+ const snapshotChanged = await runVitest({
+ root: fs.root,
+ ...config,
+ related: ['__snapshots__/snap.test.js.snap'],
+ })
+ expect(snapshotChanged.stderr).toBe('')
+ expect(Object.keys(snapshotChanged.testTree())).toEqual(['snap.test.js'])
+
+ const fileChanged = await runVitest({ root: fs.root, ...config, related: ['fixtures/file.txt'] })
+ expect(fileChanged.stderr).toBe('')
+ expect(Object.keys(fileChanged.testTree())).toEqual(['file.test.js'])
+})
+
+test('records are kept per project', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'vitest.config.js': {
+ test: {
+ ...config,
+ projects: [
+ { test: { name: 'first', include: ['first/*.test.js'] } },
+ { test: { name: 'second', include: ['second/*.test.js'] } },
+ ],
+ },
+ },
+ 'src/shared.js': 'export {}',
+ 'first/a.test.js': testFile('a', `import '../src/shared.js'`),
+ 'second/b.test.js': testFile('b'),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "first": {
+ "first/a.test.js": [
+ "first/a.test.js",
+ "src/shared.js",
+ ],
+ },
+ "second": {
+ "second/b.test.js": [
+ "second/b.test.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({ root: fs.root, ...config, related: ['src/shared.js'] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree())).toEqual(['first/a.test.js'])
+})
+
+test('a file that fails to load records the modules loaded before the error', async () => {
+ const { ctx, fs, errorTree } = await runInlineTests(
+ {
+ 'src/broken.js': `throw new Error('broken')`,
+ 'src/other.js': 'export {}',
+ 'a.test.js': testFile('a', `import './src/broken.js'`),
+ 'b.test.js': testFile('b', `import './src/other.js'`),
+ },
+ config,
+ )
+ expect(errorTree()).toMatchInlineSnapshot(`
+ {
+ "a.test.js": {
+ "__module_errors__": [
+ "broken",
+ ],
+ },
+ "b.test.js": {
+ "b": "passed",
+ },
+ }
+ `)
+ expect(readDependencies(ctx)['']['a.test.js']).toEqual(['a.test.js', 'src/broken.js'])
+
+ fs.editFile('src/broken.js', () => 'export {}')
+ const fixed = await runVitest({ root: fs.root, ...config, related: ['src/broken.js'] })
+ expect(fixed.stderr).toBe('')
+ expect(fixed.testTree()).toMatchInlineSnapshot(`
+ {
+ "a.test.js": {
+ "a": "passed",
+ },
+ }
+ `)
+})
+
+test('a deleted dependency runs the tests that loaded it', async () => {
+ const { fs, stderr } = await runInlineTests(
+ {
+ 'src/helper.js': 'export {}',
+ 'a.test.js': testFile('a', `import './src/helper.js'`),
+ 'b.test.js': testFile('b'),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+
+ rmSync(fs.resolveFile('src/helper.js'))
+ const changed = await runVitest({ root: fs.root, ...config, related: ['src/helper.js'] })
+ expect(changed.errorTree()).toMatchInlineSnapshot(`
+ {
+ "a.test.js": {
+ "__module_errors__": [
+ "Cannot find module './src/helper.js' imported from /a.test.js",
+ ],
+ },
+ }
+ `)
+})
+
+test('a complete run replaces the record of the file and keeps the others', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/helper.js': 'export {}',
+ 'src/other.js': 'export {}',
+ 'a.test.js': testFile('a', `import './src/helper.js'`),
+ 'b.test.js': testFile('b', `import './src/helper.js'`),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)['']['a.test.js']).toEqual(['a.test.js', 'src/helper.js'])
+
+ fs.editFile('a.test.js', (content) => content.replace('src/helper.js', 'src/other.js'))
+ const filtered = await runVitest({ root: fs.root, ...config, $cliFilters: ['a.test.js'] })
+ expect(filtered.stderr).toBe('')
+ expect(readDependencies(filtered.ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/other.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "src/helper.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({ root: fs.root, ...config, related: ['src/helper.js'] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree())).toEqual(['b.test.js'])
+})
+
+test('nothing is recorded when the option is disabled', async () => {
+ const { ctx, stderr } = await runInlineTests({ 'a.test.js': testFile('a') }, { cache: true })
+ expect(stderr).toBe('')
+ expect(existsSync(getDependenciesPath(ctx))).toBe(false)
+})
+
+test('every test runs when the cache is disabled', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/helper.js': 'export {}',
+ 'a.test.js': testFile('a', `import './src/helper.js'`),
+ 'b.test.js': testFile('b'),
+ },
+ { ...config, cache: false },
+ )
+ expect(stderr).toBe('')
+ expect(existsSync(getDependenciesPath(ctx))).toBe(false)
+
+ const changed = await runVitest({
+ root: fs.root,
+ ...config,
+ cache: false,
+ related: ['src/helper.js'],
+ })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+})
+
+test('clearing the cache removes the records', async () => {
+ const { ctx, stderr } = await runInlineTests(
+ { 'a.test.js': testFile('a') },
+ // the shared fs module cache is used by the other tests in parallel
+ { ...config, fsModuleCachePath: './node_modules/.vitest-fs-cache' },
+ )
+ expect(stderr).toBe('')
+ const path = getDependenciesPath(ctx)
+ expect(existsSync(path)).toBe(true)
+
+ await ctx!.clearCache()
+ expect(existsSync(path)).toBe(false)
+})
+
+test('a run of the affected tests keeps the records of the other tests', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/helper.js': 'export {}',
+ 'src/other.js': 'export {}',
+ 'a.test.js': testFile('a', `import './src/helper.js'`),
+ 'b.test.js': testFile('b', `import './src/other.js'`),
+ },
+ config,
+ )
+ expect(stderr).toBe('')
+ const before = readDependencies(ctx)
+
+ const changed = await runVitest({ root: fs.root, ...config, related: ['src/helper.js'] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree())).toEqual(['a.test.js'])
+ expect(readDependencies(changed.ctx)).toEqual(before)
+
+ const other = await runVitest({ root: fs.root, ...config, related: ['src/other.js'] })
+ expect(other.stderr).toBe('')
+ expect(Object.keys(other.testTree())).toEqual(['b.test.js'])
+})
+
+test('a rerun of one file in watch mode keeps the records of the other files', async () => {
+ const { ctx, fs, stderr, vitest } = await runInlineTests(
+ {
+ 'src/helper.js': 'export {}',
+ 'src/other.js': 'export {}',
+ 'a.test.js': testFile('a', `import './src/helper.js'`),
+ 'b.test.js': testFile('b', `import './src/other.js'`),
+ },
+ { ...config, watch: true },
+ )
+ expect(stderr).toBe('')
+ await vitest.waitForStdout('Waiting for file changes')
+ vitest.resetOutput()
+
+ fs.editFile('a.test.js', (content) => content.replace('src/helper.js', 'src/other.js'))
+ await vitest.waitForStdout('RERUN')
+ await vitest.waitForStdout('Waiting for file changes')
+
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/other.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "src/other.js",
+ ],
+ },
+ }
+ `)
+})
+
+test('a file that did not run because of bail keeps its record', async () => {
+ const { ctx, fs } = await runInlineTests(
+ {
+ 'src/helper.js': 'export {}',
+ 'a.test.js': `
+ import { expect, test } from 'vitest'
+ test('a', () => {
+ expect(1).toBe(2)
+ })
+ `,
+ 'b.test.js': testFile('b', `import './src/helper.js'`),
+ },
+ config,
+ )
+ const before = readDependencies(ctx)
+ expect(before['']['b.test.js']).toEqual(['b.test.js', 'src/helper.js'])
+
+ // the failed file runs first, so "b" never starts
+ fs.editFile('b.test.js', (content) => content.replace(`import './src/helper.js'`, ''))
+ const bailed = await runVitest({ root: fs.root, ...config, bail: 1, fileParallelism: false })
+ expect(bailed.testTree()).toMatchInlineSnapshot(`
+ {
+ "a.test.js": {
+ "a": "failed",
+ },
+ "b.test.js": {},
+ }
+ `)
+ expect(readDependencies(bailed.ctx)).toEqual(before)
+})
+
+describe('without the module runner', () => {
+ const native = { ...config, experimental: { ...config.experimental, viteModuleRunner: false } }
+
+ test('only the tests that loaded a changed file run', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/helper.js': `import './inner.js'`,
+ 'src/inner.js': 'export {}',
+ 'src/other.js': 'export {}',
+ 'a.test.js': testFile('a', `import './src/helper.js'`),
+ 'b.test.js': testFile('b', `import './src/other.js'`),
+ },
+ native,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/helper.js",
+ "src/inner.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "src/other.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({ root: fs.root, ...native, related: ['src/inner.js'] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree())).toEqual(['a.test.js'])
+ })
+
+ test('a module loaded by a previous file in the same worker is recorded', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/shared.js': `import './inner.js'`,
+ 'src/inner.js': 'export const inner = 1',
+ 'a.test.js': testFile('a', `import './src/shared.js'`),
+ 'b.test.js': testFile('b', `import './src/shared.js'`),
+ 'c.test.js': testFile('c'),
+ },
+ { ...native, isolate: false, fileParallelism: false },
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "a.test.js": [
+ "a.test.js",
+ "src/inner.js",
+ "src/shared.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "src/inner.js",
+ "src/shared.js",
+ ],
+ "c.test.js": [
+ "c.test.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({ root: fs.root, ...native, related: ['src/inner.js'] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+ })
+
+ test('a file loaded by a setup file, a global setup or an environment runs every test', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'vitest.config.js': {
+ test: {
+ ...native,
+ setupFiles: ['./setup.js'],
+ globalSetup: ['./global-setup.js'],
+ environment: './environment.js',
+ },
+ },
+ 'setup.js': `import './src/setup-helper.js'`,
+ 'src/setup-helper.js': 'export {}',
+ 'global-setup.js': `
+ import './src/global-helper.js'
+ export default () => {}
+ `,
+ 'src/global-helper.js': 'export {}',
+ 'environment.js': `
+ import './src/environment-helper.js'
+ export default {
+ name: 'custom',
+ viteEnvironment: 'ssr',
+ setup() {
+ return { teardown() {} }
+ },
+ }
+ `,
+ 'src/environment-helper.js': 'export {}',
+ 'a.test.js': testFile('a'),
+ 'b.test.js': testFile('b'),
+ },
+ native,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "": [
+ "global-setup.js",
+ "src/global-helper.js",
+ ],
+ "a.test.js": [
+ "a.test.js",
+ "environment.js",
+ "setup.js",
+ "src/environment-helper.js",
+ "src/setup-helper.js",
+ ],
+ "b.test.js": [
+ "b.test.js",
+ "environment.js",
+ "setup.js",
+ "src/environment-helper.js",
+ "src/setup-helper.js",
+ ],
+ },
+ }
+ `)
+
+ for (const file of [
+ 'src/setup-helper.js',
+ 'src/global-helper.js',
+ 'src/environment-helper.js',
+ ]) {
+ const changed = await runVitest({ root: fs.root, related: [file] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree()).sort()).toEqual(['a.test.js', 'b.test.js'])
+ }
+ })
+
+ test('a mocked module does not load its own imports', async () => {
+ const { ctx, fs, stderr } = await runInlineTests(
+ {
+ 'src/dep.js': `
+ import './inner.js'
+ export const dep = 1
+ `,
+ 'src/inner.js': 'export const inner = 1',
+ 'src/__mocks__/dep.js': 'export const dep = 2',
+ 'factory.test.js': `
+ import { test, vi } from 'vitest'
+ import './src/dep.js'
+ vi.mock('./src/dep.js', () => ({ dep: 2 }))
+ test('factory', () => {})
+ `,
+ 'automock.test.js': `
+ import { test, vi } from 'vitest'
+ import './src/dep.js'
+ vi.mock('./src/dep.js')
+ test('automock', () => {})
+ `,
+ 'plain.test.js': testFile('plain', `import './src/dep.js'`),
+ },
+ native,
+ )
+ expect(stderr).toBe('')
+ expect(readDependencies(ctx)).toMatchInlineSnapshot(`
+ {
+ "": {
+ "automock.test.js": [
+ "automock.test.js",
+ "src/__mocks__/dep.js",
+ "src/dep.js",
+ ],
+ "factory.test.js": [
+ "factory.test.js",
+ "src/dep.js",
+ ],
+ "plain.test.js": [
+ "plain.test.js",
+ "src/dep.js",
+ "src/inner.js",
+ ],
+ },
+ }
+ `)
+
+ const changed = await runVitest({ root: fs.root, ...native, related: ['src/inner.js'] })
+ expect(changed.stderr).toBe('')
+ expect(Object.keys(changed.testTree())).toEqual(['plain.test.js'])
+
+ const mockChanged = await runVitest({
+ root: fs.root,
+ ...native,
+ related: ['src/__mocks__/dep.js'],
+ })
+ expect(mockChanged.stderr).toBe('')
+ expect(Object.keys(mockChanged.testTree())).toEqual(['automock.test.js'])
+ })
+})