@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.
@@ -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
+ }
@@ -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
+ }