Skip to content

Commit c06012a

Browse files
authored
feat(experimental): add an option to cache dependencies instead of statically parsing them (#11515)
1 parent 4c468aa commit c06012a

28 files changed

Lines changed: 1756 additions & 32 deletions

‎docs/config/experimental.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,49 @@ export default {
375375
}
376376
```
377377

378+
## experimental.recordDependencies <Version type="experimental">5.0.4</Version> {#experimental-recorddependencies}
379+
380+
- **Type:** `boolean`
381+
- **Default:** `false`
382+
383+
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.
384+
385+
```ts [vitest.config.ts]
386+
import { defineConfig } from 'vitest/config'
387+
388+
export default defineConfig({
389+
test: {
390+
experimental: {
391+
recordDependencies: true,
392+
},
393+
},
394+
})
395+
```
396+
397+
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.
398+
399+
A record contains:
400+
401+
- the test file and every module it imported, including dynamic imports that ran
402+
- [setup files](/config/setupfiles), the [custom environment](/config/environment), the [custom runner](/config/runner), [snapshot serializers](/config/snapshotserializers) and their imports
403+
- mock files from the `__mocks__` folder and modules imported by a mock factory
404+
- files that plugins watch with `this.addWatchFile`
405+
- the snapshot file and every file used by [`toMatchFileSnapshot`](/api/expect#tomatchfilesnapshot)
406+
407+
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.
408+
409+
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.
410+
411+
::: warning
412+
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.
413+
414+
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.
415+
416+
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.
417+
418+
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.
419+
:::
420+
378421
## experimental.nodeLoader <Version type="experimental">4.1.0</Version> {#experimental-nodeloader}
379422

380423
- **Type:** `boolean`

‎docs/guide/cli-generated.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,13 @@ Controls whether Vitest will use Node.js Loader API to process in-source or mock
10001000

10011001
Custom provider for detecting changed files. (default: `git`)
10021002

1003+
### experimental.recordDependencies
1004+
1005+
- **CLI:** `--experimental.recordDependencies`
1006+
- **Config:** [experimental.recordDependencies](/config/experimental#experimental-recorddependencies)
1007+
1008+
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`)
1009+
10031010
### experimental.preParse
10041011

10051012
- **CLI:** `--experimental.preParse`

‎packages/snapshot/src/port/state.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export default class SnapshotState {
7070
private _inlineSnapshotStacks: Array<ParsedStack & { testId: string; snapshot: string }>
7171
private _testIdToKeys = new DefaultMap<string, string[]>(() => [])
7272
private _rawSnapshots: Array<RawSnapshot>
73+
private _rawSnapshotFiles = new Set<string>()
7374
private _uncheckedKeys: Set<string>
7475
private _snapshotFormat: PrettyFormatOptions
7576
private _environment: SnapshotEnvironment
@@ -508,6 +509,7 @@ export default class SnapshotState {
508509
}
509510

510511
if (rawSnapshot) {
512+
this._rawSnapshotFiles.add(rawSnapshot.file)
511513
// normalize EOL when snapshot contains CRLF but received is LF
512514
if (
513515
rawSnapshot.content &&
@@ -604,6 +606,17 @@ export default class SnapshotState {
604606
})
605607
}
606608

609+
/**
610+
* The snapshot file, if the test file stores snapshots in it, and every asserted file snapshot.
611+
*/
612+
getSnapshotFiles(): string[] {
613+
const files = Array.from(this._rawSnapshotFiles)
614+
if (this._fileExists || Object.keys(this._snapshotData).length) {
615+
files.unshift(this.snapshotPath)
616+
}
617+
return files
618+
}
619+
607620
async pack(): Promise<SnapshotResult> {
608621
const snapshot: SnapshotResult = {
609622
filepath: this.testFilePath,

‎packages/vitest/src/node/affected-modules.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ export class AffectedModulesResolver {
267267
}
268268

269269
// files loaded into `import.meta.env`, mirrors Vite's `loadEnv`
270-
function getEnvFiles(project: TestProject): string[] {
270+
export function getEnvFiles(project: TestProject): string[] {
271271
const { envDir, mode } = project.vite.config
272272
if (typeof envDir !== 'string') {
273273
return []
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import type { Vitest } from '../core'
2+
import type { TestProject } from '../project'
3+
import type { TestSpecification } from '../test-specification'
4+
import { existsSync } from 'node:fs'
5+
import { mkdir, readFile, rm } from 'node:fs/promises'
6+
import { cleanUrl } from '@vitest/utils/helpers'
7+
import { dirname, isAbsolute, resolve } from 'pathe'
8+
import { createDebugger } from '../../utils/debugger'
9+
import { tagCacheDir } from './cachedirTag'
10+
import { atomicWriteFile } from './fsModuleCache'
11+
import { getFileStamp, getRootRelativePath, isPartialRun } from './results'
12+
13+
const debug = createDebugger('vitest:cache:dependencies')
14+
15+
const DEPENDENCIES_VERSION = 1
16+
17+
export interface ProjectDependencies {
18+
/** Absolute paths without a query, referenced by index. */
19+
modules: string[]
20+
moduleIndex: Map<string, number>
21+
globalSetup?: number[]
22+
/** Root-relative test file path to the indices of every module it loaded. */
23+
files: Map<string, number[]>
24+
}
25+
26+
interface SerializedDependencies {
27+
version: number
28+
projects: Record<
29+
string,
30+
{
31+
modules: string[]
32+
globalSetup?: number[]
33+
files: Record<string, number[]>
34+
}
35+
>
36+
}
37+
38+
/**
39+
* Stores the modules that every test file loaded during its last run.
40+
*/
41+
export class DependenciesCache {
42+
private projects = new Map<string, ProjectDependencies>()
43+
// recorded during the current run, merged into `projects` by `update`
44+
private recordedFiles = new Map<string, Map<string, string[]>>()
45+
private recordedGlobalSetup = new Map<string, string[]>()
46+
private path: string | null
47+
private fileStamp: string | undefined
48+
49+
constructor(private vitest: Vitest) {
50+
this.path =
51+
vitest.config.cache && vitest.config.experimental.recordDependencies
52+
? resolve(vitest.viteConfig.cacheDir, 'dependencies.json')
53+
: null
54+
}
55+
56+
get(project: TestProject): ProjectDependencies | undefined {
57+
return this.projects.get(project.name)
58+
}
59+
60+
record(project: TestProject, filepath: string, dependencies: string[]): void {
61+
if (!this.path) {
62+
return
63+
}
64+
let files = this.recordedFiles.get(project.name)
65+
if (!files) {
66+
this.recordedFiles.set(project.name, (files = new Map()))
67+
}
68+
files.set(filepath, this.resolveFiles(project, dependencies))
69+
}
70+
71+
recordGlobalSetup(project: TestProject, dependencies: string[]): void {
72+
if (this.path && dependencies.length) {
73+
this.recordedGlobalSetup.set(project.name, this.resolveFiles(project, dependencies))
74+
}
75+
}
76+
77+
async read(): Promise<void> {
78+
const path = this.path
79+
const stamp = path ? await getFileStamp(path) : undefined
80+
if (!path || !stamp || stamp === this.fileStamp) {
81+
return
82+
}
83+
this.fileStamp = stamp
84+
try {
85+
const { version, projects }: SerializedDependencies = JSON.parse(
86+
await readFile(path, 'utf-8'),
87+
)
88+
if (version !== DEPENDENCIES_VERSION) {
89+
debug?.(`ignored ${path}, the version ${version} is not supported`)
90+
return
91+
}
92+
const root = this.vitest.config.root
93+
const result = new Map<string, ProjectDependencies>()
94+
for (const name in projects) {
95+
const { modules, globalSetup, files } = projects[name]
96+
const absoluteModules = modules.map((file) => resolve(root, file))
97+
result.set(name, {
98+
modules: absoluteModules,
99+
moduleIndex: new Map(absoluteModules.map((file, index) => [file, index])),
100+
globalSetup,
101+
files: new Map(Object.entries(files)),
102+
})
103+
}
104+
this.projects = result
105+
} catch (error) {
106+
debug?.(`failed to read ${path}: ${error}`)
107+
}
108+
}
109+
110+
async update(specifications: TestSpecification[]): Promise<void> {
111+
if (!this.recordedFiles.size && !this.recordedGlobalSetup.size) {
112+
return
113+
}
114+
await this.read()
115+
116+
for (const [name, files] of this.recordedGlobalSetup) {
117+
const project = this.getProjectDependencies(name)
118+
project.globalSetup = this.intern(project, files)
119+
}
120+
121+
for (const specification of specifications) {
122+
const files = this.recordedFiles.get(specification.project.name)?.get(specification.moduleId)
123+
const task = specification.testModule?.task
124+
if (!files || !task) {
125+
continue
126+
}
127+
const project = this.getProjectDependencies(specification.project.name)
128+
const file = getRootRelativePath(this.vitest.config.root, specification.moduleId)
129+
const previous = project.files.get(file)
130+
// a partial run skips tests, so it can miss their dynamic imports,
131+
// without a previous record the file keeps having none and always runs
132+
if (!isPartialRun(this.vitest, specification, task)) {
133+
project.files.set(file, this.intern(project, files))
134+
} else if (previous) {
135+
project.files.set(file, Array.from(new Set([...previous, ...this.intern(project, files)])))
136+
}
137+
}
138+
139+
this.recordedFiles.clear()
140+
this.recordedGlobalSetup.clear()
141+
await this.write()
142+
}
143+
144+
async clear(): Promise<void> {
145+
this.projects.clear()
146+
this.fileStamp = undefined
147+
if (this.path && existsSync(this.path)) {
148+
await rm(this.path, { force: true })
149+
this.vitest.logger.log('[cache] cleared dependencies cache at', this.path)
150+
}
151+
}
152+
153+
// the files of the modules, with the files that plugins watch for them
154+
private resolveFiles(project: TestProject, dependencies: string[]): string[] {
155+
const files = new Set<string>()
156+
const add = (file: string) => {
157+
if (isAbsolute(file) && !file.includes('node_modules')) {
158+
files.add(file)
159+
}
160+
}
161+
// without the module runner, plugins do not transform the modules and cannot watch files
162+
const watchedByPlugins = project.config.experimental.viteModuleRunner !== false
163+
for (const id of dependencies) {
164+
add(cleanUrl(id))
165+
if (!watchedByPlugins) {
166+
continue
167+
}
168+
for (const environment of Object.values(project.vite.environments)) {
169+
const node = environment.moduleGraph.getModuleById(id)
170+
const transformed = node?.transformResult
171+
if (!transformed) {
172+
continue
173+
}
174+
const imports = new Set([...(transformed.deps || []), ...(transformed.dynamicDeps || [])])
175+
node.importedModules.forEach((imported) => {
176+
if (imported.file && !imports.has(imported.url)) {
177+
add(imported.file)
178+
}
179+
})
180+
break
181+
}
182+
}
183+
return Array.from(files)
184+
}
185+
186+
private intern(project: ProjectDependencies, files: string[]): number[] {
187+
return files.map((file) => {
188+
let index = project.moduleIndex.get(file)
189+
if (index === undefined) {
190+
index = project.modules.push(file) - 1
191+
project.moduleIndex.set(file, index)
192+
}
193+
return index
194+
})
195+
}
196+
197+
private getProjectDependencies(name: string): ProjectDependencies {
198+
let project = this.projects.get(name)
199+
if (!project) {
200+
project = { modules: [], moduleIndex: new Map(), files: new Map() }
201+
this.projects.set(name, project)
202+
}
203+
return project
204+
}
205+
206+
private async write(): Promise<void> {
207+
if (!this.path) {
208+
return
209+
}
210+
const root = this.vitest.config.root
211+
const projects: SerializedDependencies['projects'] = Object.create(null)
212+
for (const [name, { modules, globalSetup, files }] of this.projects) {
213+
projects[name] = {
214+
modules: modules.map((file) => getRootRelativePath(root, file)),
215+
globalSetup,
216+
files: Object.fromEntries(files),
217+
}
218+
}
219+
const dependencies: SerializedDependencies = { version: DEPENDENCIES_VERSION, projects }
220+
try {
221+
const cacheDir = dirname(this.path)
222+
await mkdir(cacheDir, { recursive: true })
223+
tagCacheDir(dirname(cacheDir))
224+
await atomicWriteFile(this.path, JSON.stringify(dependencies))
225+
this.fileStamp = await getFileStamp(this.path)
226+
} catch (error) {
227+
debug?.(`failed to write ${this.path}: ${error}`)
228+
}
229+
}
230+
}

‎packages/vitest/src/node/cache/index.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { TestSpecification } from '../test-specification'
33
import type { CachedTestFileResult } from './results'
44
import { statSync } from 'node:fs'
55
import { resolve } from 'pathe'
6+
import { DependenciesCache } from './dependencies'
67
import { FileSystemModuleCache } from './fsModuleCache'
78
import { ResultsCache } from './results'
89

@@ -11,6 +12,8 @@ export class VitestCache {
1112
_results: ResultsCache
1213
/** @internal */
1314
_modules: FileSystemModuleCache
15+
/** @internal */
16+
_dependencies: DependenciesCache
1417

1518
private fileStats = new Map<string, { size: number } | undefined>()
1619
private warnedMethods = new Set<string>()
@@ -19,6 +22,7 @@ export class VitestCache {
1922
constructor(private vitest: Vitest) {
2023
this._results = new ResultsCache(vitest)
2124
this._modules = new FileSystemModuleCache(vitest)
25+
this._dependencies = new DependenciesCache(vitest)
2226
}
2327

2428
/**
@@ -52,10 +56,21 @@ export class VitestCache {
5256
await Promise.all([this._results.read(), this._modules.ensureCacheIntegrity()])
5357
}
5458

59+
/** @internal */
60+
async _update(specifications: TestSpecification[], startTime: number): Promise<void> {
61+
await Promise.all([
62+
this._results.update(specifications, startTime),
63+
this._dependencies.update(specifications),
64+
])
65+
}
66+
5567
/** @internal */
5668
async _clear(): Promise<void> {
57-
await this._results.clear()
58-
await this._modules.clearCache()
69+
await Promise.all([
70+
this._results.clear(),
71+
this._dependencies.clear(),
72+
this._modules.clearCache(),
73+
])
5974
}
6075

6176
private readFileStats(key: string): { size: number } | undefined {

0 commit comments

Comments
 (0)