@useblacksmith/vitest 0.3.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 +27 -9
- package/dist/affected.js +294 -46
- package/dist/browser.d.ts +11 -0
- package/dist/browser.js +57 -0
- package/dist/cache.d.ts +9 -0
- package/dist/cache.js +12 -3
- 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 +6 -2
- 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,17 +20,22 @@ 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;
|
|
26
|
+
/** Vitest's root relative to the repository, so one package's snapshot never vouches for another's. */
|
|
27
|
+
root: string;
|
|
23
28
|
shard: Shard | null;
|
|
24
29
|
/** Names of the projects the run covered (`--project` narrows them). */
|
|
25
30
|
projects: string[];
|
|
26
31
|
tree: Tree;
|
|
27
|
-
/** Repo-relative test files the run
|
|
28
|
-
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[];
|
|
29
34
|
}
|
|
30
35
|
/**
|
|
31
|
-
* `blacksmith.json`, or `blacksmith.<hash
|
|
32
|
-
* 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.
|
|
33
39
|
*/
|
|
34
40
|
export declare function snapshotPath(vitest: Vitest, dir: string): string;
|
|
35
41
|
/**
|
|
@@ -52,6 +58,13 @@ export declare class TreeSnapshotProvider implements VCSProvider {
|
|
|
52
58
|
deselected: Set<string>;
|
|
53
59
|
/** The plugin's `dependencies` option, compiled. */
|
|
54
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;
|
|
55
68
|
findChangedFiles(options: VCSProviderOptions): Promise<string[]>;
|
|
56
69
|
/**
|
|
57
70
|
* Changed files to hand vitest, or `null` when every test of the shard has
|
|
@@ -107,10 +120,15 @@ export declare class SnapshotRecorder implements Reporter {
|
|
|
107
120
|
private activeProvider;
|
|
108
121
|
onInit(vitest: Vitest): void;
|
|
109
122
|
onTestRunStart(specifications: ReadonlyArray<TestSpecification>): void;
|
|
110
|
-
|
|
123
|
+
onTestModuleEnd(module: TestModule): void;
|
|
124
|
+
onTestRunEnd(modules: ReadonlyArray<TestModule>, errors: ReadonlyArray<unknown>, reason: TestRunEndReason): Promise<void>;
|
|
111
125
|
/** Puts the record and this anchor in the Actions cache when a run can reach it. */
|
|
112
126
|
private publish;
|
|
113
|
-
/**
|
|
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
|
+
*/
|
|
114
132
|
private coveredTests;
|
|
115
133
|
}
|
|
116
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,24 +274,28 @@ async function readSnapshot(file) {
|
|
|
138
274
|
}
|
|
139
275
|
if (typeof parsed !== 'object' || parsed === null)
|
|
140
276
|
return null;
|
|
141
|
-
const { version, head, 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' ||
|
|
281
|
+
typeof root !== 'string' ||
|
|
145
282
|
!isShard(shard) ||
|
|
146
283
|
!Array.isArray(projects) ||
|
|
147
284
|
!projects.every((name) => typeof name === 'string') ||
|
|
148
285
|
!isTree(tree) ||
|
|
149
|
-
|
|
286
|
+
!Array.isArray(tests) ||
|
|
287
|
+
!tests.every((file) => typeof file === 'string')) {
|
|
150
288
|
return null;
|
|
151
289
|
}
|
|
152
290
|
return {
|
|
153
291
|
version: SNAPSHOT_VERSION,
|
|
292
|
+
runtime,
|
|
154
293
|
head,
|
|
294
|
+
root,
|
|
155
295
|
shard,
|
|
156
296
|
projects: projects,
|
|
157
297
|
tree,
|
|
158
|
-
tests:
|
|
298
|
+
tests: tests,
|
|
159
299
|
};
|
|
160
300
|
}
|
|
161
301
|
/**
|
|
@@ -183,12 +323,36 @@ export class TreeSnapshotProvider {
|
|
|
183
323
|
deselected = new Set();
|
|
184
324
|
/** The plugin's `dependencies` option, compiled. */
|
|
185
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
|
+
}
|
|
186
346
|
async findChangedFiles(options) {
|
|
187
347
|
this.deselected = new Set();
|
|
188
348
|
const dependencies = await options.resolver.getDependencies();
|
|
189
349
|
const cacheDir = this.vitest.vite.config.cacheDir;
|
|
190
350
|
const selected = await this.select(options, dependencies, cacheDir, (message) => this.vitest.logger.log(`[blacksmith] ${message}`));
|
|
191
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;
|
|
192
356
|
return dependencies.map((dependency) => dependency.file);
|
|
193
357
|
}
|
|
194
358
|
const changed = new Set(selected);
|
|
@@ -213,6 +377,12 @@ export class TreeSnapshotProvider {
|
|
|
213
377
|
log(`no dependency snapshot, running every test of the ${describeShard(shard)}`);
|
|
214
378
|
return null;
|
|
215
379
|
}
|
|
380
|
+
const repo = await repoRoot(root);
|
|
381
|
+
const here = repoRelative(repo, this.vitest.config.root);
|
|
382
|
+
if (snapshot.root !== here) {
|
|
383
|
+
log(`dependency snapshot is for ${snapshot.root || 'the repository root'}, this run is for ${here || 'the repository root'}, running every test of the ${describeShard(shard)}`);
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
216
386
|
if (!sameShard(snapshot.shard, shard)) {
|
|
217
387
|
log(`dependency snapshot is for ${describeShard(snapshot.shard)}, running every test of the ${describeShard(shard)}`);
|
|
218
388
|
return null;
|
|
@@ -222,9 +392,12 @@ export class TreeSnapshotProvider {
|
|
|
222
392
|
log(`dependency snapshot covers projects [${snapshot.projects.join(', ')}], this run covers [${projects.join(', ')}], running every test of the ${describeShard(shard)}`);
|
|
223
393
|
return null;
|
|
224
394
|
}
|
|
225
|
-
|
|
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
|
+
}
|
|
226
399
|
const tree = await anchorTree(repo, await anchoredPaths(this.vitest, repo, dir, this.dependencies));
|
|
227
|
-
const changedPaths =
|
|
400
|
+
const changedPaths = await this.changedSince(repo, snapshot, tree);
|
|
228
401
|
const owns = await shardOwner(this.vitest);
|
|
229
402
|
// Entries without a record are test files vitest has never seen; a test
|
|
230
403
|
// the snapshot does not vouch for has no trustworthy record either,
|
|
@@ -232,20 +405,43 @@ export class TreeSnapshotProvider {
|
|
|
232
405
|
const uncovered = new Set(dependencies
|
|
233
406
|
.filter((dependency) => dependency.recordedAt === undefined && owns(dependency.file))
|
|
234
407
|
.map((dependency) => dependency.file));
|
|
408
|
+
const vouched = new Set(snapshot.tests);
|
|
235
409
|
for (const specification of await this.vitest.globTestSpecifications()) {
|
|
236
410
|
const file = specification.moduleId;
|
|
237
|
-
if (owns(file) && !
|
|
411
|
+
if (owns(file) && !vouched.has(repoRelative(repo, file))) {
|
|
238
412
|
uncovered.add(file);
|
|
239
413
|
}
|
|
240
414
|
}
|
|
241
415
|
const changed = new Set(uncovered);
|
|
416
|
+
const workflow = workflowPath();
|
|
417
|
+
const manifestDirs = [];
|
|
242
418
|
for (const relative of changedPaths) {
|
|
243
|
-
if (LOCKFILES.has(path.basename(relative))) {
|
|
419
|
+
if (LOCKFILES.has(path.basename(relative)) || relative === workflow) {
|
|
244
420
|
log(`${relative} changed, running every test of the ${describeShard(shard)}`);
|
|
245
421
|
return null;
|
|
246
422
|
}
|
|
423
|
+
if (isManifest(relative))
|
|
424
|
+
manifestDirs.push(repoDir(relative));
|
|
247
425
|
changed.add(path.join(repo, relative));
|
|
248
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
|
+
}
|
|
249
445
|
const triggered = this.dependencies.filter((rule) => changedPaths.some((relative) => rule.deps(rootRelative(root, path.join(repo, relative)))));
|
|
250
446
|
let mapped = 0;
|
|
251
447
|
if (triggered.length > 0) {
|
|
@@ -261,10 +457,13 @@ export class TreeSnapshotProvider {
|
|
|
261
457
|
}
|
|
262
458
|
}
|
|
263
459
|
}
|
|
264
|
-
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` : ''}`);
|
|
265
461
|
return [...changed];
|
|
266
462
|
}
|
|
267
463
|
}
|
|
464
|
+
function containsOnly(module) {
|
|
465
|
+
return module.task?.containsOnly === true;
|
|
466
|
+
}
|
|
268
467
|
function isPartialSpecification(specification) {
|
|
269
468
|
return (specification.testLines !== undefined ||
|
|
270
469
|
specification.testIds !== undefined ||
|
|
@@ -307,6 +506,8 @@ export class ShadowAnalysis {
|
|
|
307
506
|
const repo = await repoRoot(vitest.config.root);
|
|
308
507
|
const key = cacheKey({
|
|
309
508
|
snapshotVersion: SNAPSHOT_VERSION,
|
|
509
|
+
runtime: runtimeId(),
|
|
510
|
+
root: repoRelative(repo, vitest.config.root),
|
|
310
511
|
projects: projectNames(vitest),
|
|
311
512
|
shard: vitest.config.shard ?? null,
|
|
312
513
|
head: process.env.GITHUB_SHA || (await headSha(repo)),
|
|
@@ -465,25 +666,42 @@ export class SnapshotRecorder {
|
|
|
465
666
|
}
|
|
466
667
|
onTestRunStart(specifications) {
|
|
467
668
|
this.started = new Set(specifications.map((specification) => specification.moduleId));
|
|
468
|
-
this.partial =
|
|
669
|
+
this.partial =
|
|
670
|
+
specifications.some(isPartialSpecification) ||
|
|
671
|
+
(this.vitest.config.tagsFilter?.length ?? 0) > 0;
|
|
469
672
|
}
|
|
470
|
-
|
|
673
|
+
onTestModuleEnd(module) {
|
|
674
|
+
recordBrowserDependencies(this.vitest, module);
|
|
675
|
+
}
|
|
676
|
+
async onTestRunEnd(modules, errors, reason) {
|
|
471
677
|
await this.reportShadow(modules);
|
|
472
|
-
|
|
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)))
|
|
473
685
|
return;
|
|
474
|
-
const covered = await this.coveredTests();
|
|
686
|
+
const covered = await this.coveredTests(modules);
|
|
475
687
|
if (covered === null)
|
|
476
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
|
+
}
|
|
477
692
|
const cacheDir = this.vitest.vite.config.cacheDir;
|
|
478
693
|
const { root, shard } = this.vitest.config;
|
|
479
694
|
const repo = await repoRoot(root);
|
|
695
|
+
await pruneRecords(this.vitest, repo, cacheDir);
|
|
480
696
|
const snapshot = {
|
|
481
697
|
version: SNAPSHOT_VERSION,
|
|
698
|
+
runtime: runtimeId(),
|
|
482
699
|
head: await headSha(repo),
|
|
700
|
+
root: repoRelative(repo, root),
|
|
483
701
|
shard: shard ?? null,
|
|
484
702
|
projects: projectNames(this.vitest),
|
|
485
703
|
tree: await anchorTree(repo, await anchoredPaths(this.vitest, repo, cacheDir, this.provider.dependencies)),
|
|
486
|
-
tests:
|
|
704
|
+
tests: covered.vouched.map((file) => repoRelative(repo, file)).sort(),
|
|
487
705
|
};
|
|
488
706
|
const file = snapshotPath(this.vitest, cacheDir);
|
|
489
707
|
await mkdir(path.dirname(file), { recursive: true });
|
|
@@ -499,6 +717,8 @@ export class SnapshotRecorder {
|
|
|
499
717
|
return;
|
|
500
718
|
const key = cacheKey({
|
|
501
719
|
snapshotVersion: snapshot.version,
|
|
720
|
+
runtime: snapshot.runtime,
|
|
721
|
+
root: snapshot.root,
|
|
502
722
|
projects: snapshot.projects,
|
|
503
723
|
shard: snapshot.shard,
|
|
504
724
|
head: snapshot.head,
|
|
@@ -513,8 +733,12 @@ export class SnapshotRecorder {
|
|
|
513
733
|
this.vitest.logger.log(`[blacksmith] dependency cache not saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
514
734
|
}
|
|
515
735
|
}
|
|
516
|
-
/**
|
|
517
|
-
|
|
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) {
|
|
518
742
|
if (this.partial || this.vitest.getGlobalTestNamePattern() !== undefined) {
|
|
519
743
|
return null;
|
|
520
744
|
}
|
|
@@ -525,9 +749,31 @@ export class SnapshotRecorder {
|
|
|
525
749
|
.map((specification) => specification.moduleId)
|
|
526
750
|
.filter(owns)),
|
|
527
751
|
];
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 };
|
|
531
777
|
}
|
|
532
778
|
}
|
|
533
779
|
/**
|
|
@@ -554,6 +800,8 @@ export async function restoreDependencyCache(vitest) {
|
|
|
554
800
|
const repo = await repoRoot(vitest.config.root);
|
|
555
801
|
const key = cacheKey({
|
|
556
802
|
snapshotVersion: SNAPSHOT_VERSION,
|
|
803
|
+
runtime: runtimeId(),
|
|
804
|
+
root: repoRelative(repo, vitest.config.root),
|
|
557
805
|
projects: projectNames(vitest),
|
|
558
806
|
shard: vitest.config.shard ?? null,
|
|
559
807
|
head: process.env.GITHUB_SHA || (await headSha(repo)),
|
|
@@ -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;
|