@useblacksmith/vitest 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,11 +59,11 @@ The plugin uses Vitest's dependency tracking: each run records which modules and
59
59
  |---|---|---|
60
60
  | `runAllTests` | `false` | Run every test instead of the affected selection. |
61
61
  | `shadow` | `false` | Run every test, but log what the affected selection would have run and which failing test files it would have skipped, i.e. failures the selection would have missed. Use it to evaluate the plugin on a repo before relying on the selection. |
62
- | `dependencies` | `{}` | Dependencies the tracking cannot see, as globs of test files mapped to the glob(s) of the files they depend on, relative to the config root: `{ 'tests/**/*.spec.ts': 'fixtures/**' }`. A change to a matching file reruns the mapped tests. |
62
+ | `dependencies` | `{}` | Dependencies the tracking cannot see, as globs of test files mapped to the glob(s) of the files they depend on, relative to the config root (`../shared/**` reaches outside a package): `{ 'tests/**/*.spec.ts': 'fixtures/**' }`. A change to a matching file reruns the mapped tests; a glob that matches no file in the repository warns. |
63
63
 
64
64
  ## Caveats
65
65
 
66
- - **Only dependencies in the test process are tracked.** `import`, `require`, and `fs` are automatically tracked. Files read from a child process, `globalSetup`, or outside of node are not; declare them with the `dependencies` option.
66
+ - **Only dependencies the test reaches through node are tracked.** `import`, `require`, and `fs` are tracked automatically, in the test process, in every node process it starts through `child_process` and in every `worker_threads` worker (each gets a preloaded recorder; their own children inherit it). A child or worker records what it has loaded as it goes, so one the test kills or terminates still counts, up to the end of its test file. For any child, the command and the arguments that name files are recorded too. A child started with `env: { ..., NODE_OPTIONS: undefined }`, or a worker with `env: SHARE_ENV`, gets no recorder, only its command line. Files read by `globalSetup`, by non-node programs, or build outputs a child runs in place of their sources are not; declare them with the `dependencies` option. In browser mode, vitest itself reports nothing, so the plugin records the modules the browser loaded from the project's Vite module graph instead; files a browser test fetches at run time are not tracked.
67
67
  - **Shards must be stable for maximal efficacy.** The plugin assigns test files to shards by a hash of their path, so a file stays in its shard as others are added or removed. A custom `test.sequence.sequencer` is honoured, but every test that moves to another shard reruns there.
68
68
 
69
69
  ## Development
@@ -1,14 +1,15 @@
1
- import { BloomFilter } from './bloom.js';
2
1
  import type { Reporter, TestModule, TestRunEndReason, TestSpecification, VCSProvider, VCSProviderOptions, Vitest } from 'vitest/node';
3
2
  import { type Tree } from './git.js';
4
3
  export declare const SNAPSHOT_FILE = "blacksmith.json";
5
- declare const SNAPSHOT_VERSION = 5;
4
+ declare const SNAPSHOT_VERSION = 6;
6
5
  /** A `dependencies` entry, compiled: test files matching `tests` depend on files matching `deps`; both root-relative. */
7
6
  export interface DependencyRule {
7
+ /** The `tests` glob as written, for messages. */
8
+ source: string;
8
9
  tests: (file: string) => boolean;
9
10
  deps: (file: string) => boolean;
10
11
  }
11
- export declare function compileDependencies(dependencies: Readonly<Record<string, string | readonly string[]>> | undefined): DependencyRule[];
12
+ export declare function compileDependencies(dependencies: Readonly<Record<string, string | readonly string[]>> | undefined, root: string): DependencyRule[];
12
13
  export interface Shard {
13
14
  index: number;
14
15
  count: number;
@@ -19,6 +20,8 @@ export interface Shard {
19
20
  */
20
21
  export interface Snapshot {
21
22
  version: typeof SNAPSHOT_VERSION;
23
+ /** Node major, platform and arch the run was made on; another one does not share its skips. */
24
+ runtime: string;
22
25
  head: string;
23
26
  /** Vitest's root relative to the repository, so one package's snapshot never vouches for another's. */
24
27
  root: string;
@@ -26,12 +29,13 @@ export interface Snapshot {
26
29
  /** Names of the projects the run covered (`--project` narrows them). */
27
30
  projects: string[];
28
31
  tree: Tree;
29
- /** Repo-relative test files the run covered: ran, or deselected by our provider as unchanged. A test outside this set reruns whatever its record says. */
30
- tests: BloomFilter;
32
+ /** Repo-relative test files the run vouches for, sorted: passed, or deselected by our provider as unchanged. A test outside this set (never run, failed, interrupted) reruns whatever its record says. */
33
+ tests: string[];
31
34
  }
32
35
  /**
33
- * `blacksmith.json`, or `blacksmith.<hash>.json` when `--project` narrowed the
34
- * run: invocations covering different project sets keep separate anchors.
36
+ * `blacksmith.json`, or `blacksmith[.<hash>][.<i>of<n>].json` when `--project`
37
+ * narrowed the run or `--shard` split it: invocations covering different
38
+ * project sets or shards keep separate anchors, as their cache keys do.
35
39
  */
36
40
  export declare function snapshotPath(vitest: Vitest, dir: string): string;
37
41
  /**
@@ -54,6 +58,13 @@ export declare class TreeSnapshotProvider implements VCSProvider {
54
58
  deselected: Set<string>;
55
59
  /** The plugin's `dependencies` option, compiled. */
56
60
  dependencies: DependencyRule[];
61
+ /**
62
+ * Paths whose content differs from the snapshot's. A path the snapshot did
63
+ * not anchor (a later run recorded it, or a file it imported was recorded
64
+ * after another shard's run) is a change only if it was not tracked with
65
+ * the same blob at the snapshot's commit.
66
+ */
67
+ private changedSince;
57
68
  findChangedFiles(options: VCSProviderOptions): Promise<string[]>;
58
69
  /**
59
70
  * Changed files to hand vitest, or `null` when every test of the shard has
@@ -109,10 +120,15 @@ export declare class SnapshotRecorder implements Reporter {
109
120
  private activeProvider;
110
121
  onInit(vitest: Vitest): void;
111
122
  onTestRunStart(specifications: ReadonlyArray<TestSpecification>): void;
112
- onTestRunEnd(modules: ReadonlyArray<TestModule>, _errors: ReadonlyArray<unknown>, reason: TestRunEndReason): Promise<void>;
123
+ onTestModuleEnd(module: TestModule): void;
124
+ onTestRunEnd(modules: ReadonlyArray<TestModule>, errors: ReadonlyArray<unknown>, reason: TestRunEndReason): Promise<void>;
113
125
  /** Puts the record and this anchor in the Actions cache when a run can reach it. */
114
126
  private publish;
115
- /** The tests this shard owns, when every one of them ran or was deselected by our own provider; `null` when the run covered less. */
127
+ /**
128
+ * The tests this shard owns, split into those the run vouches for (passed,
129
+ * or deselected by our own provider) and those `left` out (failed, or never
130
+ * reached); `null` when the run covered less than the shard.
131
+ */
116
132
  private coveredTests;
117
133
  }
118
134
  /**
package/dist/affected.js CHANGED
@@ -4,18 +4,29 @@ import { mkdir, mkdtemp, readFile, rename, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import picomatch from 'picomatch';
7
- import { BloomFilter } from './bloom.js';
8
- import { cacheKey, ENV_CACHE_URL, restoreBundle, saveBundle } from './cache.js';
9
- import { anchorTree, diffTrees, headSha, knownPaths, lockfilePaths, repoRoot, } from './git.js';
7
+ import { recordBrowserDependencies } from './browser.js';
8
+ import { cacheKey, ENV_CACHE_URL, restoreBundle, runtimeId, saveBundle, } from './cache.js';
9
+ import { anchorTree, blobsAt, diffTrees, headSha, knownPaths, lockfilePaths, repoRoot, } from './git.js';
10
10
  import { shardOwner } from './sequencer.js';
11
11
  export const SNAPSHOT_FILE = 'blacksmith.json';
12
12
  // Bumped whenever the snapshot's shape changes or the records an anchor
13
13
  // vouches for gain a kind of dependency, so an older snapshot is not trusted.
14
- const SNAPSHOT_VERSION = 5;
15
- export function compileDependencies(dependencies) {
14
+ const SNAPSHOT_VERSION = 6;
15
+ /**
16
+ * A glob written as a path from the root (`../../packages/*\/package.json`
17
+ * from `packages/a`) in the form candidates take, `../*\/package.json`:
18
+ * `path.relative` folds the `..` segments the same way for both.
19
+ */
20
+ function normalizeGlob(root, glob) {
21
+ if (!/^\.\.?\//.test(glob))
22
+ return glob;
23
+ return rootRelative(root, path.resolve(root, glob));
24
+ }
25
+ export function compileDependencies(dependencies, root) {
16
26
  return Object.entries(dependencies ?? {}).map(([tests, deps]) => ({
17
- tests: picomatch(tests, { dot: true }),
18
- deps: picomatch(typeof deps === 'string' ? deps : [...deps], { dot: true }),
27
+ source: tests,
28
+ tests: picomatch(normalizeGlob(root, tests), { dot: true }),
29
+ deps: picomatch((typeof deps === 'string' ? [deps] : [...deps]).map((glob) => normalizeGlob(root, glob)), { dot: true }),
19
30
  }));
20
31
  }
21
32
  /** `file` relative to the config root with `/` separators, the form the `dependencies` globs match. */
@@ -33,19 +44,66 @@ const LOCKFILES = new Set([
33
44
  'bun.lock',
34
45
  'bun.lockb',
35
46
  ]);
47
+ /**
48
+ * Package manifests and TypeScript configs on the way up from a recorded
49
+ * file to the repository root: `exports`, `imports`, `type`, `paths` and the
50
+ * like change what a module resolves to without any recorded file changing.
51
+ */
52
+ function isManifest(file) {
53
+ const base = path.basename(file);
54
+ return base === 'package.json' || /^tsconfig(\..+)?\.json$/.test(base);
55
+ }
56
+ /**
57
+ * The workflow file running this job, when it lives in this repository. Its
58
+ * `matrix`, `env` and steps shape every test, so a change to it runs them all.
59
+ */
60
+ function workflowPath() {
61
+ const ref = process.env.GITHUB_WORKFLOW_REF;
62
+ const repository = process.env.GITHUB_REPOSITORY;
63
+ if (!ref || !repository || !ref.startsWith(`${repository}/`))
64
+ return null;
65
+ const file = ref.slice(repository.length + 1).split('@')[0] ?? '';
66
+ return file.startsWith('.github/workflows/') ? file : null;
67
+ }
68
+ /** `dir` and every parent up to the repository root, repo-relative ('' for the root). */
69
+ function ancestors(dir) {
70
+ const result = [];
71
+ for (let current = dir;;) {
72
+ if (current === '.' || current === '') {
73
+ result.push('');
74
+ return result;
75
+ }
76
+ result.push(current);
77
+ const parent = path.dirname(current);
78
+ if (parent === current)
79
+ return result;
80
+ current = parent;
81
+ }
82
+ }
83
+ function repoDir(file) {
84
+ const dir = path.dirname(file);
85
+ return dir === '.' ? '' : dir;
86
+ }
36
87
  function projectNames(vitest) {
37
88
  return vitest.projects.map((project) => project.name).sort();
38
89
  }
39
90
  /**
40
- * `blacksmith.json`, or `blacksmith.<hash>.json` when `--project` narrowed the
41
- * run: invocations covering different project sets keep separate anchors.
91
+ * `blacksmith.json`, or `blacksmith[.<hash>][.<i>of<n>].json` when `--project`
92
+ * narrowed the run or `--shard` split it: invocations covering different
93
+ * project sets or shards keep separate anchors, as their cache keys do.
42
94
  */
43
95
  export function snapshotPath(vitest, dir) {
44
- const filtered = vitest.config.project.length > 0;
45
- const name = filtered
46
- ? SNAPSHOT_FILE.replace('.json', `.${createHash('sha1').update(projectNames(vitest).join('\n')).digest('hex').slice(0, 12)}.json`)
47
- : SNAPSHOT_FILE;
48
- return path.join(dir, name);
96
+ const parts = [SNAPSHOT_FILE.replace('.json', '')];
97
+ if (vitest.config.project.length > 0) {
98
+ parts.push(createHash('sha1')
99
+ .update(projectNames(vitest).join('\n'))
100
+ .digest('hex')
101
+ .slice(0, 12));
102
+ }
103
+ const { shard } = vitest.config;
104
+ if (shard)
105
+ parts.push(`${shard.index}of${shard.count}`);
106
+ return path.join(dir, `${parts.join('.')}.json`);
49
107
  }
50
108
  /** Vitest's `dependencies.json`, or `null` before the first recorded run. */
51
109
  async function readRecords(vitest, dir) {
@@ -85,27 +143,105 @@ async function recordedDependencies(vitest, dir) {
85
143
  * those, so the anchor is the only way a change to them is seen).
86
144
  */
87
145
  async function anchoredPaths(vitest, repo, dir, rules) {
88
- const paths = new Set(await lockfilePaths(repo, LOCKFILES));
89
- if (rules.length > 0) {
90
- for (const file of await knownPaths(repo)) {
91
- const fromRoot = rootRelative(vitest.config.root, path.join(repo, file));
92
- if (rules.some((rule) => rule.deps(fromRoot)))
93
- paths.add(file);
94
- }
95
- }
146
+ const paths = new Set();
147
+ const workflow = workflowPath();
148
+ if (workflow)
149
+ paths.add(workflow);
96
150
  const records = await readRecords(vitest, dir);
151
+ const dirs = new Set();
97
152
  for (const project of Object.values(records?.projects ?? {})) {
98
153
  // Vitest records every project's paths relative to the root config's root.
99
154
  const root = vitest.config.root;
100
- for (const module of project.modules) {
101
- paths.add(repoRelative(repo, path.resolve(root, module)));
102
- }
103
- for (const test of Object.keys(project.files)) {
104
- paths.add(repoRelative(repo, path.resolve(root, test)));
155
+ for (const file of [...project.modules, ...Object.keys(project.files)]) {
156
+ const relative = repoRelative(repo, path.resolve(root, file));
157
+ paths.add(relative);
158
+ // Outside the repository (`..`, or another drive on Windows): no manifest above it is ours.
159
+ if (relative.startsWith('..') || path.isAbsolute(relative))
160
+ continue;
161
+ for (const dir of ancestors(repoDir(relative)))
162
+ dirs.add(dir);
105
163
  }
106
164
  }
165
+ // A lockfile pins what the tests below it resolve; a vendored repo's or a
166
+ // fixture's lockfile pins nothing a test loads.
167
+ for (const file of await lockfilePaths(repo, LOCKFILES)) {
168
+ if (dirs.has(repoDir(file)))
169
+ paths.add(file);
170
+ }
171
+ const matched = rules.map(() => 0);
172
+ for (const file of await knownPaths(repo)) {
173
+ const fromRoot = rootRelative(vitest.config.root, path.join(repo, file));
174
+ let byRule = false;
175
+ rules.forEach((rule, index) => {
176
+ if (!rule.deps(fromRoot))
177
+ return;
178
+ matched[index] = (matched[index] ?? 0) + 1;
179
+ byRule = true;
180
+ });
181
+ if (byRule)
182
+ paths.add(file);
183
+ else if (isManifest(file) && dirs.has(repoDir(file)))
184
+ paths.add(file);
185
+ }
186
+ rules.forEach((rule, index) => {
187
+ if (matched[index] === 0) {
188
+ vitest.logger.warn(`[blacksmith] dependencies["${rule.source}"] matches no file in the repository; globs are relative to ${vitest.config.root}`);
189
+ }
190
+ });
107
191
  return [...paths];
108
192
  }
193
+ /**
194
+ * Rewrites vitest's `dependencies.json` without the modules outside the
195
+ * repository (the anchor cannot cover them) and without tests that no longer
196
+ * exist, renumbering what the remaining records point to. Vitest only ever
197
+ * appends, so a record otherwise grows by every temp file a run touched.
198
+ */
199
+ async function pruneRecords(vitest, repo, dir) {
200
+ const records = await readRecords(vitest, dir);
201
+ if (!records)
202
+ return;
203
+ const root = vitest.config.root;
204
+ for (const project of Object.values(records.projects ?? {})) {
205
+ const keep = new Map();
206
+ const modules = [];
207
+ const renumber = (record) => {
208
+ record.deps = [
209
+ ...new Set(record.deps.flatMap((index) => {
210
+ const module = project.modules[index];
211
+ if (module === undefined)
212
+ return [];
213
+ const absolute = path.resolve(root, module);
214
+ if (repoRelative(repo, absolute).startsWith('..') ||
215
+ path.isAbsolute(repoRelative(repo, absolute))) {
216
+ return [];
217
+ }
218
+ let next = keep.get(index);
219
+ if (next === undefined) {
220
+ next = modules.push(module) - 1;
221
+ keep.set(index, next);
222
+ }
223
+ return [next];
224
+ })),
225
+ ];
226
+ };
227
+ for (const [test, record] of Object.entries(project.files)) {
228
+ if (!existsSync(path.resolve(root, test))) {
229
+ delete project.files[test];
230
+ continue;
231
+ }
232
+ renumber(record);
233
+ }
234
+ if (project.config)
235
+ renumber(project.config);
236
+ if (project.globalSetup)
237
+ renumber(project.globalSetup);
238
+ project.modules = modules;
239
+ }
240
+ const file = path.join(dir, 'dependencies.json');
241
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
242
+ await writeFile(tmp, JSON.stringify(records));
243
+ await rename(tmp, file);
244
+ }
109
245
  function sameShard(a, b) {
110
246
  if (a === null)
111
247
  return b === undefined;
@@ -138,26 +274,28 @@ async function readSnapshot(file) {
138
274
  }
139
275
  if (typeof parsed !== 'object' || parsed === null)
140
276
  return null;
141
- const { version, head, root, shard, projects, tree, tests } = parsed;
142
- const covered = BloomFilter.parse(tests);
277
+ const { version, runtime, head, root, shard, projects, tree, tests } = parsed;
143
278
  if (version !== SNAPSHOT_VERSION ||
279
+ typeof runtime !== 'string' ||
144
280
  typeof head !== 'string' ||
145
281
  typeof root !== 'string' ||
146
282
  !isShard(shard) ||
147
283
  !Array.isArray(projects) ||
148
284
  !projects.every((name) => typeof name === 'string') ||
149
285
  !isTree(tree) ||
150
- covered === null) {
286
+ !Array.isArray(tests) ||
287
+ !tests.every((file) => typeof file === 'string')) {
151
288
  return null;
152
289
  }
153
290
  return {
154
291
  version: SNAPSHOT_VERSION,
292
+ runtime,
155
293
  head,
156
294
  root,
157
295
  shard,
158
296
  projects: projects,
159
297
  tree,
160
- tests: covered,
298
+ tests: tests,
161
299
  };
162
300
  }
163
301
  /**
@@ -185,12 +323,36 @@ export class TreeSnapshotProvider {
185
323
  deselected = new Set();
186
324
  /** The plugin's `dependencies` option, compiled. */
187
325
  dependencies = [];
326
+ /**
327
+ * Paths whose content differs from the snapshot's. A path the snapshot did
328
+ * not anchor (a later run recorded it, or a file it imported was recorded
329
+ * after another shard's run) is a change only if it was not tracked with
330
+ * the same blob at the snapshot's commit.
331
+ */
332
+ async changedSince(repo, snapshot, tree) {
333
+ const changed = diffTrees(snapshot.tree, tree);
334
+ const fresh = changed.filter((file) => !Object.hasOwn(snapshot.tree, file) && Object.hasOwn(tree, file));
335
+ if (fresh.length === 0)
336
+ return changed;
337
+ const blobs = await blobsAt(repo, snapshot.head, fresh);
338
+ if (!blobs)
339
+ return changed;
340
+ return changed.filter((file) => {
341
+ const entry = tree[file];
342
+ const sha = entry?.split(' ')[1];
343
+ return !(blobs.has(file) && blobs.get(file) === sha);
344
+ });
345
+ }
188
346
  async findChangedFiles(options) {
189
347
  this.deselected = new Set();
190
348
  const dependencies = await options.resolver.getDependencies();
191
349
  const cacheDir = this.vitest.vite.config.cacheDir;
192
350
  const selected = await this.select(options, dependencies, cacheDir, (message) => this.vitest.logger.log(`[blacksmith] ${message}`));
193
351
  if (selected === null) {
352
+ // Not a changed run any more: vitest's coverage provider narrows its
353
+ // report to `config.related`, which is about to be every test file,
354
+ // and no source file is a test file.
355
+ this.vitest.config.changed = false;
194
356
  return dependencies.map((dependency) => dependency.file);
195
357
  }
196
358
  const changed = new Set(selected);
@@ -230,8 +392,12 @@ export class TreeSnapshotProvider {
230
392
  log(`dependency snapshot covers projects [${snapshot.projects.join(', ')}], this run covers [${projects.join(', ')}], running every test of the ${describeShard(shard)}`);
231
393
  return null;
232
394
  }
395
+ if (snapshot.runtime !== runtimeId()) {
396
+ log(`dependency snapshot was made on ${snapshot.runtime}, this is ${runtimeId()}, running every test of the ${describeShard(shard)}`);
397
+ return null;
398
+ }
233
399
  const tree = await anchorTree(repo, await anchoredPaths(this.vitest, repo, dir, this.dependencies));
234
- const changedPaths = diffTrees(snapshot.tree, tree);
400
+ const changedPaths = await this.changedSince(repo, snapshot, tree);
235
401
  const owns = await shardOwner(this.vitest);
236
402
  // Entries without a record are test files vitest has never seen; a test
237
403
  // the snapshot does not vouch for has no trustworthy record either,
@@ -239,20 +405,43 @@ export class TreeSnapshotProvider {
239
405
  const uncovered = new Set(dependencies
240
406
  .filter((dependency) => dependency.recordedAt === undefined && owns(dependency.file))
241
407
  .map((dependency) => dependency.file));
408
+ const vouched = new Set(snapshot.tests);
242
409
  for (const specification of await this.vitest.globTestSpecifications()) {
243
410
  const file = specification.moduleId;
244
- if (owns(file) && !snapshot.tests.has(repoRelative(repo, file))) {
411
+ if (owns(file) && !vouched.has(repoRelative(repo, file))) {
245
412
  uncovered.add(file);
246
413
  }
247
414
  }
248
415
  const changed = new Set(uncovered);
416
+ const workflow = workflowPath();
417
+ const manifestDirs = [];
249
418
  for (const relative of changedPaths) {
250
- if (LOCKFILES.has(path.basename(relative))) {
419
+ if (LOCKFILES.has(path.basename(relative)) || relative === workflow) {
251
420
  log(`${relative} changed, running every test of the ${describeShard(shard)}`);
252
421
  return null;
253
422
  }
423
+ if (isManifest(relative))
424
+ manifestDirs.push(repoDir(relative));
254
425
  changed.add(path.join(repo, relative));
255
426
  }
427
+ // A manifest changes resolution for everything below it: every test
428
+ // with a recorded file under its directory runs.
429
+ let viaManifest = 0;
430
+ if (manifestDirs.length > 0) {
431
+ const under = (file) => {
432
+ const relative = repoRelative(repo, file);
433
+ return (!relative.startsWith('..') &&
434
+ manifestDirs.some((manifestDir) => manifestDir === '' || relative.startsWith(`${manifestDir}/`)));
435
+ };
436
+ for (const [test, deps] of await recordedDependencies(this.vitest, dir)) {
437
+ if (changed.has(test) || !owns(test))
438
+ continue;
439
+ if (under(test) || [...deps].some(under)) {
440
+ changed.add(test);
441
+ viaManifest++;
442
+ }
443
+ }
444
+ }
256
445
  const triggered = this.dependencies.filter((rule) => changedPaths.some((relative) => rule.deps(rootRelative(root, path.join(repo, relative)))));
257
446
  let mapped = 0;
258
447
  if (triggered.length > 0) {
@@ -268,10 +457,13 @@ export class TreeSnapshotProvider {
268
457
  }
269
458
  }
270
459
  }
271
- log(`${changedPaths.length} files changed since ${snapshot.head.slice(0, 12)}, ${uncovered.size} test files not covered by it${mapped > 0 ? `, ${mapped} test files via dependencies` : ''}`);
460
+ log(`${changedPaths.length} files changed since ${snapshot.head.slice(0, 12)}, ${uncovered.size} test files not covered by it${viaManifest > 0 ? `, ${viaManifest} test files under a changed manifest` : ''}${mapped > 0 ? `, ${mapped} test files via dependencies` : ''}`);
272
461
  return [...changed];
273
462
  }
274
463
  }
464
+ function containsOnly(module) {
465
+ return module.task?.containsOnly === true;
466
+ }
275
467
  function isPartialSpecification(specification) {
276
468
  return (specification.testLines !== undefined ||
277
469
  specification.testIds !== undefined ||
@@ -314,6 +506,7 @@ export class ShadowAnalysis {
314
506
  const repo = await repoRoot(vitest.config.root);
315
507
  const key = cacheKey({
316
508
  snapshotVersion: SNAPSHOT_VERSION,
509
+ runtime: runtimeId(),
317
510
  root: repoRelative(repo, vitest.config.root),
318
511
  projects: projectNames(vitest),
319
512
  shard: vitest.config.shard ?? null,
@@ -473,26 +666,42 @@ export class SnapshotRecorder {
473
666
  }
474
667
  onTestRunStart(specifications) {
475
668
  this.started = new Set(specifications.map((specification) => specification.moduleId));
476
- this.partial = specifications.some(isPartialSpecification);
669
+ this.partial =
670
+ specifications.some(isPartialSpecification) ||
671
+ (this.vitest.config.tagsFilter?.length ?? 0) > 0;
672
+ }
673
+ onTestModuleEnd(module) {
674
+ recordBrowserDependencies(this.vitest, module);
477
675
  }
478
- async onTestRunEnd(modules, _errors, reason) {
676
+ async onTestRunEnd(modules, errors, reason) {
479
677
  await this.reportShadow(modules);
480
- if (reason !== 'passed')
678
+ // A failed run still vouches for the files that passed; the failed ones
679
+ // are left out and rerun next time. An interrupted run left files unrun,
680
+ // an unhandled error fails the run outside any module (`reason` does not
681
+ // see it), and a `.only` skipped its siblings: none of those vouch.
682
+ if (reason === 'interrupted' || errors.length > 0)
683
+ return;
684
+ if (modules.some((module) => containsOnly(module)))
481
685
  return;
482
- const covered = await this.coveredTests();
686
+ const covered = await this.coveredTests(modules);
483
687
  if (covered === null)
484
688
  return;
689
+ if (covered.left.length > 0) {
690
+ this.vitest.logger.log(`[blacksmith] ${covered.left.length} failed test files left out of the dependency snapshot, they run again next time`);
691
+ }
485
692
  const cacheDir = this.vitest.vite.config.cacheDir;
486
693
  const { root, shard } = this.vitest.config;
487
694
  const repo = await repoRoot(root);
695
+ await pruneRecords(this.vitest, repo, cacheDir);
488
696
  const snapshot = {
489
697
  version: SNAPSHOT_VERSION,
698
+ runtime: runtimeId(),
490
699
  head: await headSha(repo),
491
700
  root: repoRelative(repo, root),
492
701
  shard: shard ?? null,
493
702
  projects: projectNames(this.vitest),
494
703
  tree: await anchorTree(repo, await anchoredPaths(this.vitest, repo, cacheDir, this.provider.dependencies)),
495
- tests: BloomFilter.from(covered.map((file) => repoRelative(repo, file))),
704
+ tests: covered.vouched.map((file) => repoRelative(repo, file)).sort(),
496
705
  };
497
706
  const file = snapshotPath(this.vitest, cacheDir);
498
707
  await mkdir(path.dirname(file), { recursive: true });
@@ -508,6 +717,7 @@ export class SnapshotRecorder {
508
717
  return;
509
718
  const key = cacheKey({
510
719
  snapshotVersion: snapshot.version,
720
+ runtime: snapshot.runtime,
511
721
  root: snapshot.root,
512
722
  projects: snapshot.projects,
513
723
  shard: snapshot.shard,
@@ -523,8 +733,12 @@ export class SnapshotRecorder {
523
733
  this.vitest.logger.log(`[blacksmith] dependency cache not saved: ${error instanceof Error ? error.message : String(error)}`);
524
734
  }
525
735
  }
526
- /** The tests this shard owns, when every one of them ran or was deselected by our own provider; `null` when the run covered less. */
527
- async coveredTests() {
736
+ /**
737
+ * The tests this shard owns, split into those the run vouches for (passed,
738
+ * or deselected by our own provider) and those `left` out (failed, or never
739
+ * reached); `null` when the run covered less than the shard.
740
+ */
741
+ async coveredTests(modules) {
528
742
  if (this.partial || this.vitest.getGlobalTestNamePattern() !== undefined) {
529
743
  return null;
530
744
  }
@@ -535,9 +749,31 @@ export class SnapshotRecorder {
535
749
  .map((specification) => specification.moduleId)
536
750
  .filter(owns)),
537
751
  ];
538
- return owned.every((file) => this.started.has(file) || deselected.has(file))
539
- ? owned
540
- : null;
752
+ // Nothing owned (an `include` matching no file) vouches for nothing.
753
+ if (owned.length === 0)
754
+ return null;
755
+ if (!owned.every((file) => this.started.has(file) || deselected.has(file)))
756
+ return null;
757
+ // A file may run in several projects; it passed when every one did.
758
+ const passed = new Map();
759
+ for (const module of modules) {
760
+ const state = module.state();
761
+ const fine = (state === 'passed' || state === 'skipped') &&
762
+ module.errors().length === 0;
763
+ passed.set(module.moduleId, (passed.get(module.moduleId) ?? true) && fine);
764
+ }
765
+ const vouched = [];
766
+ const left = [];
767
+ // Vitest reruns a deselected file when its config or globalSetup changed;
768
+ // one that ran is judged by its result, deselection vouches only for
769
+ // files that did not run.
770
+ for (const file of owned) {
771
+ const fine = this.started.has(file)
772
+ ? passed.get(file) === true
773
+ : deselected.has(file);
774
+ (fine ? vouched : left).push(file);
775
+ }
776
+ return { vouched, left };
541
777
  }
542
778
  }
543
779
  /**
@@ -564,6 +800,7 @@ export async function restoreDependencyCache(vitest) {
564
800
  const repo = await repoRoot(vitest.config.root);
565
801
  const key = cacheKey({
566
802
  snapshotVersion: SNAPSHOT_VERSION,
803
+ runtime: runtimeId(),
567
804
  root: repoRelative(repo, vitest.config.root),
568
805
  projects: projectNames(vitest),
569
806
  shard: vitest.config.shard ?? null,
@@ -0,0 +1,11 @@
1
+ import type { TestModule, Vitest } from 'vitest/node';
2
+ /**
3
+ * Vitest's browser tester never reports what a test file imported, so
4
+ * `dependencies.json` holds no record for a browser test and `--changed` runs
5
+ * it every time. The project's Vite client environment transformed every
6
+ * module the browser loaded; walking its graph from the test file and the
7
+ * setup files gives the same set the node worker reports, recorded through
8
+ * the same call its RPC makes. Virtual modules (`/@vitest/...`, `\0...`) have
9
+ * no file and are left out; vitest drops `node_modules` paths itself.
10
+ */
11
+ export declare function recordBrowserDependencies(vitest: Vitest, module: TestModule): void;
@@ -0,0 +1,57 @@
1
+ function dependenciesCache(vitest) {
2
+ const cache = vitest.cache;
3
+ const record = cache?._dependencies?.record;
4
+ return typeof record === 'function'
5
+ ? { record: record.bind(cache?._dependencies) }
6
+ : null;
7
+ }
8
+ const warned = new WeakSet();
9
+ /**
10
+ * Vitest's browser tester never reports what a test file imported, so
11
+ * `dependencies.json` holds no record for a browser test and `--changed` runs
12
+ * it every time. The project's Vite client environment transformed every
13
+ * module the browser loaded; walking its graph from the test file and the
14
+ * setup files gives the same set the node worker reports, recorded through
15
+ * the same call its RPC makes. Virtual modules (`/@vitest/...`, `\0...`) have
16
+ * no file and are left out; vitest drops `node_modules` paths itself.
17
+ */
18
+ export function recordBrowserDependencies(vitest, module) {
19
+ const project = module.project;
20
+ if (!project.isBrowserEnabled())
21
+ return;
22
+ const cache = dependenciesCache(vitest);
23
+ if (!cache) {
24
+ if (!warned.has(vitest)) {
25
+ warned.add(vitest);
26
+ vitest.logger.warn('[blacksmith] this vitest does not expose its dependency record; browser tests run on every --changed');
27
+ }
28
+ return;
29
+ }
30
+ const graph = project.vite.environments.client?.moduleGraph;
31
+ if (!graph)
32
+ return;
33
+ const roots = [...(graph.getModulesByFile(module.moduleId) ?? [])];
34
+ // A test the browser never fetched through this graph has no record to
35
+ // give; an empty one would let `--changed` skip it on every edit.
36
+ if (roots.length === 0)
37
+ return;
38
+ const files = new Set([module.moduleId]);
39
+ const seen = new Set();
40
+ const queue = [
41
+ ...roots,
42
+ ...project.config.setupFiles.flatMap((file) => [
43
+ ...(graph.getModulesByFile(file) ?? []),
44
+ ]),
45
+ ];
46
+ while (queue.length > 0) {
47
+ const node = queue.pop();
48
+ if (!node?.id || seen.has(node.id))
49
+ continue;
50
+ seen.add(node.id);
51
+ if (node.file)
52
+ files.add(node.file);
53
+ for (const imported of node.importedModules)
54
+ queue.push(imported);
55
+ }
56
+ cache.record(project, module.moduleId, [...files]);
57
+ }