@useblacksmith/vitest 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/affected.d.ts +124 -0
- package/dist/affected.js +570 -0
- package/dist/bloom.d.ts +23 -0
- package/dist/bloom.js +70 -0
- package/dist/cache.d.ts +49 -0
- package/dist/cache.js +125 -0
- package/dist/git.d.ts +21 -0
- package/dist/git.js +225 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +92 -0
- package/dist/payload.d.ts +56 -0
- package/dist/payload.js +40 -0
- package/dist/record-reads.d.ts +1 -0
- package/dist/record-reads.js +109 -0
- package/dist/reporter.d.ts +29 -0
- package/dist/reporter.js +149 -0
- package/dist/sequencer.d.ts +40 -0
- package/dist/sequencer.js +79 -0
- package/dist/upload.d.ts +17 -0
- package/dist/upload.js +47 -0
- package/package.json +38 -5
package/dist/affected.js
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
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';
|
|
10
|
+
import { shardOwner } from './sequencer.js';
|
|
11
|
+
export const SNAPSHOT_FILE = 'blacksmith.json';
|
|
12
|
+
// Bumped whenever the snapshot's shape changes or the records an anchor
|
|
13
|
+
// vouches for gain a kind of dependency, so an older snapshot is not trusted.
|
|
14
|
+
const SNAPSHOT_VERSION = 4;
|
|
15
|
+
export function compileDependencies(dependencies) {
|
|
16
|
+
return Object.entries(dependencies ?? {}).map(([tests, deps]) => ({
|
|
17
|
+
tests: picomatch(tests, { dot: true }),
|
|
18
|
+
deps: picomatch(typeof deps === 'string' ? deps : [...deps], { dot: true }),
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
/** `file` relative to the config root with `/` separators, the form the `dependencies` globs match. */
|
|
22
|
+
function rootRelative(root, file) {
|
|
23
|
+
return path.relative(root, file).split(path.sep).join('/');
|
|
24
|
+
}
|
|
25
|
+
/** `file` relative to the repository with `/` separators (git's form, whatever path.sep is); `tree` and `tests` are keyed by it. */
|
|
26
|
+
function repoRelative(repo, file) {
|
|
27
|
+
return path.relative(repo, file).split(path.sep).join('/');
|
|
28
|
+
}
|
|
29
|
+
const LOCKFILES = new Set([
|
|
30
|
+
'package-lock.json',
|
|
31
|
+
'pnpm-lock.yaml',
|
|
32
|
+
'yarn.lock',
|
|
33
|
+
'bun.lock',
|
|
34
|
+
'bun.lockb',
|
|
35
|
+
]);
|
|
36
|
+
function projectNames(vitest) {
|
|
37
|
+
return vitest.projects.map((project) => project.name).sort();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `blacksmith.json`, or `blacksmith.<hash>.json` when `--project` narrowed the
|
|
41
|
+
* run: invocations covering different project sets keep separate anchors.
|
|
42
|
+
*/
|
|
43
|
+
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);
|
|
49
|
+
}
|
|
50
|
+
/** Vitest's `dependencies.json`, or `null` before the first recorded run. */
|
|
51
|
+
async function readRecords(vitest, dir) {
|
|
52
|
+
const file = path.join(dir, 'dependencies.json');
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Recorded test file → the files it was seen importing, both absolute; empty when vitest has no records yet. */
|
|
61
|
+
async function recordedDependencies(vitest, dir) {
|
|
62
|
+
const records = await readRecords(vitest, dir);
|
|
63
|
+
if (!records)
|
|
64
|
+
return new Map();
|
|
65
|
+
const result = new Map();
|
|
66
|
+
for (const project of Object.values(records.projects ?? {})) {
|
|
67
|
+
// Vitest records every project's paths relative to the root config's root.
|
|
68
|
+
const root = vitest.config.root;
|
|
69
|
+
for (const [test, record] of Object.entries(project.files)) {
|
|
70
|
+
// A test file two projects share keeps the dependencies of both.
|
|
71
|
+
const file = path.resolve(root, test);
|
|
72
|
+
const deps = result.get(file) ?? new Set();
|
|
73
|
+
for (const index of record.deps) {
|
|
74
|
+
deps.add(path.resolve(root, project.modules[index] ?? ''));
|
|
75
|
+
}
|
|
76
|
+
result.set(file, deps);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Repo-relative paths the anchor covers: every module vitest's records name
|
|
83
|
+
* (imports, fs reads, config and globalSetup files), every recorded test
|
|
84
|
+
* file, lockfiles, and every file a `dependencies` rule names (nothing reads
|
|
85
|
+
* those, so the anchor is the only way a change to them is seen).
|
|
86
|
+
*/
|
|
87
|
+
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
|
+
}
|
|
96
|
+
const records = await readRecords(vitest, dir);
|
|
97
|
+
for (const project of Object.values(records?.projects ?? {})) {
|
|
98
|
+
// Vitest records every project's paths relative to the root config's root.
|
|
99
|
+
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)));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...paths];
|
|
108
|
+
}
|
|
109
|
+
function sameShard(a, b) {
|
|
110
|
+
if (a === null)
|
|
111
|
+
return b === undefined;
|
|
112
|
+
return b !== undefined && a.index === b.index && a.count === b.count;
|
|
113
|
+
}
|
|
114
|
+
function describeShard(shard) {
|
|
115
|
+
return shard ? `shard ${shard.index}/${shard.count}` : 'unsharded run';
|
|
116
|
+
}
|
|
117
|
+
function isShard(value) {
|
|
118
|
+
if (value === null)
|
|
119
|
+
return true;
|
|
120
|
+
if (typeof value !== 'object')
|
|
121
|
+
return false;
|
|
122
|
+
const { index, count } = value;
|
|
123
|
+
return typeof index === 'number' && typeof count === 'number';
|
|
124
|
+
}
|
|
125
|
+
function isTree(value) {
|
|
126
|
+
return (typeof value === 'object' &&
|
|
127
|
+
value !== null &&
|
|
128
|
+
Object.values(value).every((sha) => typeof sha === 'string'));
|
|
129
|
+
}
|
|
130
|
+
/** `null` for a missing, truncated or otherwise unusable file, which callers treat as "run everything". */
|
|
131
|
+
async function readSnapshot(file) {
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
140
|
+
return null;
|
|
141
|
+
const { version, head, shard, projects, tree, tests } = parsed;
|
|
142
|
+
const covered = BloomFilter.parse(tests);
|
|
143
|
+
if (version !== SNAPSHOT_VERSION ||
|
|
144
|
+
typeof head !== 'string' ||
|
|
145
|
+
!isShard(shard) ||
|
|
146
|
+
!Array.isArray(projects) ||
|
|
147
|
+
!projects.every((name) => typeof name === 'string') ||
|
|
148
|
+
!isTree(tree) ||
|
|
149
|
+
covered === null) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
version: SNAPSHOT_VERSION,
|
|
154
|
+
head,
|
|
155
|
+
shard,
|
|
156
|
+
projects: projects,
|
|
157
|
+
tree,
|
|
158
|
+
tests: covered,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Decides which files changed since the snapshot was written by diffing the
|
|
163
|
+
* stored anchor against the recorded paths as they are now. Falls back
|
|
164
|
+
* to "everything" whenever the snapshot cannot vouch for this shard.
|
|
165
|
+
*/
|
|
166
|
+
const PROVIDER_BRAND = Symbol.for('blacksmith.treeSnapshotProvider');
|
|
167
|
+
/** True for a `TreeSnapshotProvider` from any copy of this module (a project with its own root re-evaluates the config file, so it gets its own copy). */
|
|
168
|
+
export function isTreeSnapshotProvider(value) {
|
|
169
|
+
return typeof value === 'object' && value !== null && PROVIDER_BRAND in value;
|
|
170
|
+
}
|
|
171
|
+
/** The provider vitest loaded: the root config's `experimental.vcsProvider`, when it is ours. */
|
|
172
|
+
export function configuredProvider(vitest) {
|
|
173
|
+
const configured = vitest.config.experimental.vcsProvider;
|
|
174
|
+
return isTreeSnapshotProvider(configured) ? configured : null;
|
|
175
|
+
}
|
|
176
|
+
export class TreeSnapshotProvider {
|
|
177
|
+
[PROVIDER_BRAND] = true;
|
|
178
|
+
vitest;
|
|
179
|
+
/**
|
|
180
|
+
* Recorded test files none of whose recorded imports changed: the provider
|
|
181
|
+
* vouches that they need not run, whatever filters the run also applies.
|
|
182
|
+
*/
|
|
183
|
+
deselected = new Set();
|
|
184
|
+
/** The plugin's `dependencies` option, compiled. */
|
|
185
|
+
dependencies = [];
|
|
186
|
+
async findChangedFiles(options) {
|
|
187
|
+
this.deselected = new Set();
|
|
188
|
+
const dependencies = await options.resolver.getDependencies();
|
|
189
|
+
const cacheDir = this.vitest.vite.config.cacheDir;
|
|
190
|
+
const selected = await this.select(options, dependencies, cacheDir, (message) => this.vitest.logger.log(`[blacksmith] ${message}`));
|
|
191
|
+
if (selected === null) {
|
|
192
|
+
return dependencies.map((dependency) => dependency.file);
|
|
193
|
+
}
|
|
194
|
+
const changed = new Set(selected);
|
|
195
|
+
for (const [test, deps] of await recordedDependencies(this.vitest, cacheDir)) {
|
|
196
|
+
if (changed.has(test))
|
|
197
|
+
continue;
|
|
198
|
+
if ([...deps].some((dep) => changed.has(dep)))
|
|
199
|
+
continue;
|
|
200
|
+
this.deselected.add(test);
|
|
201
|
+
}
|
|
202
|
+
return selected;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Changed files to hand vitest, or `null` when every test of the shard has
|
|
206
|
+
* to run; `dir` is where the record and anchor live (vitest's `cacheDir`,
|
|
207
|
+
* or a shadow run's copy of it).
|
|
208
|
+
*/
|
|
209
|
+
async select({ root }, dependencies, dir, log) {
|
|
210
|
+
const shard = this.vitest.config.shard;
|
|
211
|
+
const snapshot = await readSnapshot(snapshotPath(this.vitest, dir));
|
|
212
|
+
if (!snapshot) {
|
|
213
|
+
log(`no dependency snapshot, running every test of the ${describeShard(shard)}`);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (!sameShard(snapshot.shard, shard)) {
|
|
217
|
+
log(`dependency snapshot is for ${describeShard(snapshot.shard)}, running every test of the ${describeShard(shard)}`);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const projects = projectNames(this.vitest);
|
|
221
|
+
if (snapshot.projects.join('\n') !== projects.join('\n')) {
|
|
222
|
+
log(`dependency snapshot covers projects [${snapshot.projects.join(', ')}], this run covers [${projects.join(', ')}], running every test of the ${describeShard(shard)}`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const repo = await repoRoot(root);
|
|
226
|
+
const tree = await anchorTree(repo, await anchoredPaths(this.vitest, repo, dir, this.dependencies));
|
|
227
|
+
const changedPaths = diffTrees(snapshot.tree, tree);
|
|
228
|
+
const owns = await shardOwner(this.vitest);
|
|
229
|
+
// Entries without a record are test files vitest has never seen; a test
|
|
230
|
+
// the snapshot does not vouch for has no trustworthy record either,
|
|
231
|
+
// whatever vitest's dependencies.json holds about it.
|
|
232
|
+
const uncovered = new Set(dependencies
|
|
233
|
+
.filter((dependency) => dependency.recordedAt === undefined && owns(dependency.file))
|
|
234
|
+
.map((dependency) => dependency.file));
|
|
235
|
+
for (const specification of await this.vitest.globTestSpecifications()) {
|
|
236
|
+
const file = specification.moduleId;
|
|
237
|
+
if (owns(file) && !snapshot.tests.has(repoRelative(repo, file))) {
|
|
238
|
+
uncovered.add(file);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const changed = new Set(uncovered);
|
|
242
|
+
for (const relative of changedPaths) {
|
|
243
|
+
if (LOCKFILES.has(path.basename(relative))) {
|
|
244
|
+
log(`${relative} changed, running every test of the ${describeShard(shard)}`);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
changed.add(path.join(repo, relative));
|
|
248
|
+
}
|
|
249
|
+
const triggered = this.dependencies.filter((rule) => changedPaths.some((relative) => rule.deps(rootRelative(root, path.join(repo, relative)))));
|
|
250
|
+
let mapped = 0;
|
|
251
|
+
if (triggered.length > 0) {
|
|
252
|
+
for (const dependency of dependencies) {
|
|
253
|
+
if (changed.has(dependency.file))
|
|
254
|
+
continue;
|
|
255
|
+
if (!owns(dependency.file))
|
|
256
|
+
continue;
|
|
257
|
+
const fromRoot = rootRelative(root, dependency.file);
|
|
258
|
+
if (triggered.some((rule) => rule.tests(fromRoot))) {
|
|
259
|
+
changed.add(dependency.file);
|
|
260
|
+
mapped++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
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` : ''}`);
|
|
265
|
+
return [...changed];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function isPartialSpecification(specification) {
|
|
269
|
+
return (specification.testLines !== undefined ||
|
|
270
|
+
specification.testIds !== undefined ||
|
|
271
|
+
specification.testNames !== undefined ||
|
|
272
|
+
specification.testNamePattern !== undefined ||
|
|
273
|
+
specification.testTagsFilter !== undefined);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Shadow mode: fetches the shard's last record and anchor into a temporary
|
|
277
|
+
* directory while the full run executes, and works out what the affected
|
|
278
|
+
* selection would have run, without touching vitest's `cacheDir` or the run.
|
|
279
|
+
*/
|
|
280
|
+
export class ShadowAnalysis {
|
|
281
|
+
vitest;
|
|
282
|
+
dependencies;
|
|
283
|
+
result;
|
|
284
|
+
constructor(vitest, dependencies) {
|
|
285
|
+
this.vitest = vitest;
|
|
286
|
+
this.dependencies = dependencies;
|
|
287
|
+
this.result = this.run().catch((error) => ({
|
|
288
|
+
wouldRun: null,
|
|
289
|
+
changed: 0,
|
|
290
|
+
head: null,
|
|
291
|
+
key: null,
|
|
292
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
async run() {
|
|
296
|
+
const none = (reason) => ({
|
|
297
|
+
wouldRun: null,
|
|
298
|
+
changed: 0,
|
|
299
|
+
head: null,
|
|
300
|
+
key: null,
|
|
301
|
+
reason,
|
|
302
|
+
});
|
|
303
|
+
const base = process.env[ENV_CACHE_URL];
|
|
304
|
+
if (!base)
|
|
305
|
+
return none(`${ENV_CACHE_URL} is not set`);
|
|
306
|
+
const vitest = this.vitest;
|
|
307
|
+
const repo = await repoRoot(vitest.config.root);
|
|
308
|
+
const key = cacheKey({
|
|
309
|
+
snapshotVersion: SNAPSHOT_VERSION,
|
|
310
|
+
projects: projectNames(vitest),
|
|
311
|
+
shard: vitest.config.shard ?? null,
|
|
312
|
+
head: process.env.GITHUB_SHA || (await headSha(repo)),
|
|
313
|
+
});
|
|
314
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'blacksmith-shadow-'));
|
|
315
|
+
const outcome = await restoreBundle(base, dir, key);
|
|
316
|
+
if (!outcome.restored)
|
|
317
|
+
return none(outcome.reason);
|
|
318
|
+
const snapshot = await readSnapshot(snapshotPath(vitest, dir));
|
|
319
|
+
const recorded = await recordedDependencies(vitest, dir);
|
|
320
|
+
const records = await readRecords(vitest, dir);
|
|
321
|
+
const { root } = vitest.config;
|
|
322
|
+
const owns = await shardOwner(vitest);
|
|
323
|
+
const specifications = (await vitest.globTestSpecifications()).filter((specification) => owns(specification.moduleId));
|
|
324
|
+
const owned = [
|
|
325
|
+
...new Set(specifications.map((specification) => specification.moduleId)),
|
|
326
|
+
];
|
|
327
|
+
const provider = new TreeSnapshotProvider();
|
|
328
|
+
provider.vitest = vitest;
|
|
329
|
+
provider.dependencies = this.dependencies;
|
|
330
|
+
let changed = 0;
|
|
331
|
+
let lastMessage = '';
|
|
332
|
+
const selected = await provider.select({ root }, owned.map((file) => ({
|
|
333
|
+
file,
|
|
334
|
+
recordedAt: recorded.has(file) ? 1 : undefined,
|
|
335
|
+
})), dir, (message) => {
|
|
336
|
+
lastMessage = message;
|
|
337
|
+
const match = /^(\d+) files changed/.exec(message);
|
|
338
|
+
if (match)
|
|
339
|
+
changed = Number(match[1]);
|
|
340
|
+
});
|
|
341
|
+
const head = snapshot?.head ?? null;
|
|
342
|
+
const all = (reason) => ({
|
|
343
|
+
wouldRun: 'all',
|
|
344
|
+
changed,
|
|
345
|
+
head,
|
|
346
|
+
key: outcome.key,
|
|
347
|
+
reason,
|
|
348
|
+
});
|
|
349
|
+
if (selected === null)
|
|
350
|
+
return all(lastMessage);
|
|
351
|
+
const changedFiles = new Set(selected);
|
|
352
|
+
const dependsOnChange = (files) => files?.some((file) => changedFiles.has(file)) ?? false;
|
|
353
|
+
// What vitest does with the changed files: `forceRerunTriggers` and the
|
|
354
|
+
// root config rerun everything; a project's config, env files, missing
|
|
355
|
+
// record or globalSetup rerun the project; otherwise a test reruns when
|
|
356
|
+
// it has no record or one of its recorded dependencies changed.
|
|
357
|
+
const triggers = vitest.config.forceRerunTriggers;
|
|
358
|
+
if (selected.some((file) => picomatch.isMatch(file, triggers, { dot: true }))) {
|
|
359
|
+
return all('a changed file matches forceRerunTriggers');
|
|
360
|
+
}
|
|
361
|
+
if (dependsOnChange(vitest.vite.config.configFileDependencies)) {
|
|
362
|
+
return all('a dependency of the config changed');
|
|
363
|
+
}
|
|
364
|
+
const wouldRun = new Set();
|
|
365
|
+
for (const project of vitest.projects) {
|
|
366
|
+
const specs = specifications.filter((specification) => specification.project === project);
|
|
367
|
+
if (specs.length === 0)
|
|
368
|
+
continue;
|
|
369
|
+
const record = records?.projects[project.name];
|
|
370
|
+
// Record paths are relative to the root config's root, whatever the project's.
|
|
371
|
+
const projectRoot = vitest.config.root;
|
|
372
|
+
const modules = (indexes) => indexes?.map((index) => path.resolve(projectRoot, record?.modules[index] ?? ''));
|
|
373
|
+
if (!record ||
|
|
374
|
+
dependsOnChange(project.vite.config.configFileDependencies) ||
|
|
375
|
+
dependsOnChange(envFiles(project)) ||
|
|
376
|
+
(project.config.globalSetup.length > 0 &&
|
|
377
|
+
(!record.globalSetup ||
|
|
378
|
+
dependsOnChange(modules(record.globalSetup.deps))))) {
|
|
379
|
+
for (const spec of specs)
|
|
380
|
+
wouldRun.add(spec.moduleId);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
for (const spec of specs) {
|
|
384
|
+
const test = record.files[path.relative(projectRoot, spec.moduleId).split(path.sep).join('/')];
|
|
385
|
+
if (!test ||
|
|
386
|
+
changedFiles.has(spec.moduleId) ||
|
|
387
|
+
dependsOnChange(modules(test.deps))) {
|
|
388
|
+
wouldRun.add(spec.moduleId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { wouldRun, changed, head, key: outcome.key, reason: null };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** The env files vitest reloads a project for, as `getEnvFiles` lists them. */
|
|
396
|
+
function envFiles(project) {
|
|
397
|
+
const { envDir, mode } = project.vite.config;
|
|
398
|
+
if (typeof envDir !== 'string')
|
|
399
|
+
return [];
|
|
400
|
+
return ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`].map((file) => path.resolve(envDir, file));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Writes the snapshot once a passing run has covered every test the shard owns,
|
|
404
|
+
* so the next `--changed` run can trust the dependency records for all of them.
|
|
405
|
+
*/
|
|
406
|
+
const RECORDER_BRAND = Symbol.for('blacksmith.snapshotRecorder');
|
|
407
|
+
/** True for a `SnapshotRecorder` from any copy of this module (a project's config bundles its own). */
|
|
408
|
+
export function isSnapshotRecorder(reporter) {
|
|
409
|
+
return (typeof reporter === 'object' &&
|
|
410
|
+
reporter !== null &&
|
|
411
|
+
RECORDER_BRAND in reporter);
|
|
412
|
+
}
|
|
413
|
+
export class SnapshotRecorder {
|
|
414
|
+
provider;
|
|
415
|
+
options;
|
|
416
|
+
[RECORDER_BRAND] = true;
|
|
417
|
+
vitest;
|
|
418
|
+
started = new Set();
|
|
419
|
+
partial = false;
|
|
420
|
+
constructor(provider, options = {}) {
|
|
421
|
+
this.provider = provider;
|
|
422
|
+
this.options = options;
|
|
423
|
+
}
|
|
424
|
+
/** Shadow mode's verdict for this run, logged once the run has finished. */
|
|
425
|
+
async reportShadow(modules) {
|
|
426
|
+
const shadow = this.options.shadow;
|
|
427
|
+
if (!shadow)
|
|
428
|
+
return;
|
|
429
|
+
const log = (message) => this.vitest.logger.log(`[blacksmith] shadow: ${message}`);
|
|
430
|
+
const result = await shadow.result;
|
|
431
|
+
if (result.wouldRun === null) {
|
|
432
|
+
log(`nothing to compare against (${result.reason ?? 'unknown'})`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const ran = this.started;
|
|
436
|
+
const would = result.wouldRun === 'all'
|
|
437
|
+
? ran
|
|
438
|
+
: new Set([...result.wouldRun].filter((file) => ran.has(file)));
|
|
439
|
+
const since = result.head ? ` since ${result.head.slice(0, 12)}` : '';
|
|
440
|
+
const why = result.wouldRun === 'all'
|
|
441
|
+
? `everything: ${result.reason}`
|
|
442
|
+
: `${result.changed} files changed${since}`;
|
|
443
|
+
log(`would have run ${would.size} of ${ran.size} test files (${why}, from ${result.key ?? 'the cache'})`);
|
|
444
|
+
const failed = modules
|
|
445
|
+
.filter((module) => module.state() === 'failed' || module.errors().length > 0)
|
|
446
|
+
.map((module) => module.moduleId);
|
|
447
|
+
if (failed.length === 0)
|
|
448
|
+
return;
|
|
449
|
+
const outside = failed.filter((file) => !would.has(file));
|
|
450
|
+
if (outside.length === 0) {
|
|
451
|
+
log(`all ${failed.length} failed test files were in the selection`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const root = this.vitest.config.root;
|
|
455
|
+
log(`${outside.length} failed test files outside the selection: ${outside
|
|
456
|
+
.map((file) => path.relative(root, file))
|
|
457
|
+
.join(', ')}`);
|
|
458
|
+
}
|
|
459
|
+
/** The provider vitest is using: the root config's, which may come from another copy of this module. */
|
|
460
|
+
activeProvider() {
|
|
461
|
+
return configuredProvider(this.vitest) ?? this.provider;
|
|
462
|
+
}
|
|
463
|
+
onInit(vitest) {
|
|
464
|
+
this.vitest = vitest;
|
|
465
|
+
}
|
|
466
|
+
onTestRunStart(specifications) {
|
|
467
|
+
this.started = new Set(specifications.map((specification) => specification.moduleId));
|
|
468
|
+
this.partial = specifications.some(isPartialSpecification);
|
|
469
|
+
}
|
|
470
|
+
async onTestRunEnd(modules, _errors, reason) {
|
|
471
|
+
await this.reportShadow(modules);
|
|
472
|
+
if (reason !== 'passed')
|
|
473
|
+
return;
|
|
474
|
+
const covered = await this.coveredTests();
|
|
475
|
+
if (covered === null)
|
|
476
|
+
return;
|
|
477
|
+
const cacheDir = this.vitest.vite.config.cacheDir;
|
|
478
|
+
const { root, shard } = this.vitest.config;
|
|
479
|
+
const repo = await repoRoot(root);
|
|
480
|
+
const snapshot = {
|
|
481
|
+
version: SNAPSHOT_VERSION,
|
|
482
|
+
head: await headSha(repo),
|
|
483
|
+
shard: shard ?? null,
|
|
484
|
+
projects: projectNames(this.vitest),
|
|
485
|
+
tree: await anchorTree(repo, await anchoredPaths(this.vitest, repo, cacheDir, this.provider.dependencies)),
|
|
486
|
+
tests: BloomFilter.from(covered.map((file) => repoRelative(repo, file))),
|
|
487
|
+
};
|
|
488
|
+
const file = snapshotPath(this.vitest, cacheDir);
|
|
489
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
490
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
491
|
+
await writeFile(tmp, JSON.stringify(snapshot));
|
|
492
|
+
await rename(tmp, file);
|
|
493
|
+
await this.publish(snapshot);
|
|
494
|
+
}
|
|
495
|
+
/** Puts the record and this anchor in the Actions cache when a run can reach it. */
|
|
496
|
+
async publish(snapshot) {
|
|
497
|
+
const base = process.env[ENV_CACHE_URL];
|
|
498
|
+
if (!base)
|
|
499
|
+
return;
|
|
500
|
+
const key = cacheKey({
|
|
501
|
+
snapshotVersion: snapshot.version,
|
|
502
|
+
projects: snapshot.projects,
|
|
503
|
+
shard: snapshot.shard,
|
|
504
|
+
head: snapshot.head,
|
|
505
|
+
});
|
|
506
|
+
try {
|
|
507
|
+
const outcome = await saveBundle(base, this.vitest.vite.config.cacheDir, key);
|
|
508
|
+
this.vitest.logger.log(outcome.saved
|
|
509
|
+
? `[blacksmith] saved dependency cache ${key.key} (${outcome.bytes} bytes)`
|
|
510
|
+
: `[blacksmith] dependency cache not saved: ${outcome.reason}`);
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
this.vitest.logger.log(`[blacksmith] dependency cache not saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** The tests this shard owns, when every one of them ran or was deselected by our own provider; `null` when the run covered less. */
|
|
517
|
+
async coveredTests() {
|
|
518
|
+
if (this.partial || this.vitest.getGlobalTestNamePattern() !== undefined) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const owns = await shardOwner(this.vitest);
|
|
522
|
+
const { deselected } = this.activeProvider();
|
|
523
|
+
const owned = [
|
|
524
|
+
...new Set((await this.vitest.globTestSpecifications())
|
|
525
|
+
.map((specification) => specification.moduleId)
|
|
526
|
+
.filter(owns)),
|
|
527
|
+
];
|
|
528
|
+
return owned.every((file) => this.started.has(file) || deselected.has(file))
|
|
529
|
+
? owned
|
|
530
|
+
: null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Before selection: when the Actions cache is reachable and Vitest's cache
|
|
535
|
+
* directory (`cacheDir`, wherever the config puts it) holds no record yet,
|
|
536
|
+
* pulls the newest record for this shard and project set into it. The
|
|
537
|
+
* directory itself may exist: every `vitest run` on one config shares it
|
|
538
|
+
* (its hash is the root `test.name`, not the `--project` filter).
|
|
539
|
+
*/
|
|
540
|
+
export async function restoreDependencyCache(vitest) {
|
|
541
|
+
const base = process.env[ENV_CACHE_URL];
|
|
542
|
+
if (!base) {
|
|
543
|
+
if (process.env.TURBO_HASH)
|
|
544
|
+
vitest.logger.warn(`[blacksmith] running under turbo, which hides ${ENV_CACHE_URL} in strict env mode; add it to passThroughEnv to use the dependency cache`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
// Only this run's own anchor blocks a restore: every `vitest run` on one config
|
|
548
|
+
// shares `cacheDir`, so another project set's anchor may already be there.
|
|
549
|
+
const anchor = snapshotPath(vitest, vitest.vite.config.cacheDir);
|
|
550
|
+
if (existsSync(anchor)) {
|
|
551
|
+
vitest.logger.warn(`[blacksmith] ${path.relative(vitest.config.root, anchor)} already exists, not restoring the dependency cache over it`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const repo = await repoRoot(vitest.config.root);
|
|
555
|
+
const key = cacheKey({
|
|
556
|
+
snapshotVersion: SNAPSHOT_VERSION,
|
|
557
|
+
projects: projectNames(vitest),
|
|
558
|
+
shard: vitest.config.shard ?? null,
|
|
559
|
+
head: process.env.GITHUB_SHA || (await headSha(repo)),
|
|
560
|
+
});
|
|
561
|
+
try {
|
|
562
|
+
const outcome = await restoreBundle(base, vitest.vite.config.cacheDir, key);
|
|
563
|
+
vitest.logger.log(outcome.restored
|
|
564
|
+
? `[blacksmith] restored dependency cache ${outcome.key}`
|
|
565
|
+
: `[blacksmith] no dependency cache: ${outcome.reason}`);
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
vitest.logger.log(`[blacksmith] dependency cache not restored: ${error instanceof Error ? error.message : String(error)}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
package/dist/bloom.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Serialized form stored in the snapshot: `m` bits, `k` hashes, bits base64-encoded. */
|
|
2
|
+
export interface BloomJSON {
|
|
3
|
+
m: number;
|
|
4
|
+
k: number;
|
|
5
|
+
bits: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Set membership for the test files a snapshot vouches for. A false positive
|
|
9
|
+
* would skip a test the shard never covered, so the default sizing keeps the
|
|
10
|
+
* rate at one in a billion: ~43 bits per entry.
|
|
11
|
+
*/
|
|
12
|
+
export declare class BloomFilter {
|
|
13
|
+
readonly m: number;
|
|
14
|
+
readonly k: number;
|
|
15
|
+
private readonly bits;
|
|
16
|
+
private constructor();
|
|
17
|
+
static from(keys: Iterable<string>, falsePositiveRate?: number): BloomFilter;
|
|
18
|
+
static parse(value: unknown): BloomFilter | null;
|
|
19
|
+
has(key: string): boolean;
|
|
20
|
+
toJSON(): BloomJSON;
|
|
21
|
+
/** Kirsch–Mitzenmacher double hashing over the two 64-bit halves of sha256. */
|
|
22
|
+
private indexes;
|
|
23
|
+
}
|
package/dist/bloom.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Set membership for the test files a snapshot vouches for. A false positive
|
|
4
|
+
* would skip a test the shard never covered, so the default sizing keeps the
|
|
5
|
+
* rate at one in a billion: ~43 bits per entry.
|
|
6
|
+
*/
|
|
7
|
+
export class BloomFilter {
|
|
8
|
+
m;
|
|
9
|
+
k;
|
|
10
|
+
bits;
|
|
11
|
+
constructor(m, k, bits) {
|
|
12
|
+
this.m = m;
|
|
13
|
+
this.k = k;
|
|
14
|
+
this.bits = bits;
|
|
15
|
+
}
|
|
16
|
+
static from(keys, falsePositiveRate = 1e-9) {
|
|
17
|
+
const entries = [...keys];
|
|
18
|
+
const n = Math.max(entries.length, 1);
|
|
19
|
+
const m = Math.ceil((-n * Math.log(falsePositiveRate)) / Math.LN2 ** 2);
|
|
20
|
+
const k = Math.max(1, Math.round((m / n) * Math.LN2));
|
|
21
|
+
const filter = new BloomFilter(m, k, new Uint8Array(Math.ceil(m / 8)));
|
|
22
|
+
for (const key of entries) {
|
|
23
|
+
for (const index of filter.indexes(key)) {
|
|
24
|
+
const byte = index >> 3;
|
|
25
|
+
filter.bits[byte] = (filter.bits[byte] ?? 0) | (1 << (index & 7));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return filter;
|
|
29
|
+
}
|
|
30
|
+
static parse(value) {
|
|
31
|
+
if (typeof value !== 'object' || value === null)
|
|
32
|
+
return null;
|
|
33
|
+
const { m, k, bits } = value;
|
|
34
|
+
if (!Number.isInteger(m) ||
|
|
35
|
+
!Number.isInteger(k) ||
|
|
36
|
+
m < 1 ||
|
|
37
|
+
k < 1 ||
|
|
38
|
+
typeof bits !== 'string') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const bytes = Buffer.from(bits, 'base64');
|
|
42
|
+
if (bytes.length !== Math.ceil(m / 8))
|
|
43
|
+
return null;
|
|
44
|
+
return new BloomFilter(m, k, new Uint8Array(bytes));
|
|
45
|
+
}
|
|
46
|
+
has(key) {
|
|
47
|
+
for (const index of this.indexes(key)) {
|
|
48
|
+
if (((this.bits[index >> 3] ?? 0) & (1 << (index & 7))) === 0)
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
toJSON() {
|
|
54
|
+
return {
|
|
55
|
+
m: this.m,
|
|
56
|
+
k: this.k,
|
|
57
|
+
bits: Buffer.from(this.bits).toString('base64'),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Kirsch–Mitzenmacher double hashing over the two 64-bit halves of sha256. */
|
|
61
|
+
*indexes(key) {
|
|
62
|
+
const digest = createHash('sha256').update(key).digest();
|
|
63
|
+
const h1 = digest.readBigUInt64BE(0);
|
|
64
|
+
const h2 = digest.readBigUInt64BE(8) | 1n;
|
|
65
|
+
const m = BigInt(this.m);
|
|
66
|
+
for (let i = 0n; i < BigInt(this.k); i++) {
|
|
67
|
+
yield Number((h1 + i * h2) % m);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|