@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 +2 -2
- package/dist/affected.d.ts +25 -9
- package/dist/affected.js +282 -45
- package/dist/browser.d.ts +11 -0
- package/dist/browser.js +57 -0
- package/dist/cache.d.ts +7 -0
- package/dist/cache.js +10 -1
- package/dist/fs-recorder.d.ts +18 -0
- package/dist/fs-recorder.js +104 -0
- package/dist/git.d.ts +7 -0
- package/dist/git.js +55 -9
- package/dist/index.js +12 -1
- package/dist/record-child.d.ts +1 -0
- package/dist/record-child.js +60 -0
- package/dist/record-reads.js +278 -83
- package/package.json +4 -1
- package/dist/bloom.d.ts +0 -23
- package/dist/bloom.js +0 -70
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
|
|
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
|
package/dist/affected.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
30
|
-
tests:
|
|
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
|
|
34
|
-
* run: invocations covering different
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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 =
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
41
|
-
* run: invocations covering different
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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) && !
|
|
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 =
|
|
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,
|
|
676
|
+
async onTestRunEnd(modules, errors, reason) {
|
|
479
677
|
await this.reportShadow(modules);
|
|
480
|
-
|
|
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:
|
|
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
|
-
/**
|
|
527
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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;
|
package/dist/browser.js
ADDED
|
@@ -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
|
+
}
|