@useblacksmith/vitest 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,107 +1,302 @@
1
1
  import { createRequire, syncBuiltinESMExports } from 'node:module';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { promisify } from 'node:util';
6
+ import { afterAll } from 'vitest';
7
+ import { BRAND, CHILD_RECORD_ENV, copyProperties, isIgnored, wrapReads, } from './fs-recorder.js';
4
8
  /**
5
- * Setup file the plugin puts first in every worker: each `fs` read lands in
6
- * the dependency set Vitest keeps for the test file being run, beside its
7
- * imports, so a fixture, a listed directory or a probed-but-missing file
8
- * reruns the test when it changes. The wrapped functions stay on the shared
9
- * `fs` object across the files a worker runs; the set is looked up per call,
10
- * so a read is charged to whichever file is current.
9
+ * Runs as the first setup file of every test file. Vitest records what a
10
+ * test imports; this records the files it reads through `node:fs` and the
11
+ * node processes and worker threads it starts (their modules and reads, via
12
+ * `record-child`), adding each to the dependency set vitest keeps per test
13
+ * file.
14
+ *
15
+ * The functions on the builtin are replaced, so every specifier that reaches
16
+ * them (`fs`, `node:fs`, `fs/promises`, `require`) is covered.
11
17
  */
12
- // Loaded with `require`, not `import`: under `vmThreads`/`vmForks` an import
13
- // would build this context's `node:fs` module namespace now, from the
14
- // functions as they are before wrapping, and a test file's own
15
- // `import { readFileSync } from 'node:fs'` would get that snapshot.
16
- const fs = createRequire(import.meta.url)('node:fs');
17
- const BRAND = Symbol.for('blacksmith.recordReads');
18
+ const require = createRequire(import.meta.url);
19
+ const fs = require('node:fs');
20
+ const childProcess = require('node:child_process');
21
+ const workerThreads = require('node:worker_threads');
18
22
  const CONTEXT = Symbol.for('blacksmith.recordReads.context');
19
- // `vmThreads`/`vmForks` evaluate this file once per test context while the
20
- // builtin `fs` object is shared, so the wrappers read the worker state through
21
- // a slot the latest evaluation points at the current context's global.
23
+ // The `vm` pools load this file in one context and run tests in another; the
24
+ // builtin is shared, so it carries the context that holds the worker state.
22
25
  const shared = fs;
23
26
  shared[CONTEXT] = globalThis;
24
- function toPath(target) {
25
- if (typeof target === 'string')
26
- return target;
27
- if (Buffer.isBuffer(target))
28
- return target.toString();
29
- if (target instanceof URL) {
30
- try {
31
- return fileURLToPath(target);
32
- }
33
- catch {
34
- return null;
35
- }
36
- }
37
- return null;
27
+ const ORIGINALS = Symbol.for('blacksmith.recordReads.originals');
28
+ // Unwrapped, so this module's own file handling records nothing; kept on the
29
+ // builtin because a later evaluation in the same process finds `fs` wrapped.
30
+ const { existsSync: exists, mkdtempSync: mkdtemp, readFileSync: read, statSync: stat, unlinkSync: unlink, } = (shared[ORIGINALS] ??= {
31
+ existsSync: fs.existsSync,
32
+ mkdtempSync: fs.mkdtempSync,
33
+ readFileSync: fs.readFileSync,
34
+ statSync: fs.statSync,
35
+ unlinkSync: fs.unlinkSync,
36
+ });
37
+ function workerState() {
38
+ return shared[CONTEXT]?.__vitest_worker__;
38
39
  }
39
- function record(target) {
40
- const dependencies = shared[CONTEXT]
41
- ?.__vitest_worker__?.dependencies;
42
- if (!dependencies)
43
- return;
44
- const file = toPath(target);
45
- if (file === null)
40
+ function dependencies() {
41
+ return workerState()?.dependencies;
42
+ }
43
+ function record(file, cwd = process.cwd()) {
44
+ const set = dependencies();
45
+ if (!set)
46
46
  return;
47
- const absolute = path.resolve(file);
48
- if (absolute.split(path.sep).includes('node_modules'))
47
+ const absolute = path.resolve(cwd, file);
48
+ if (isIgnored(absolute))
49
49
  return;
50
- dependencies.add(absolute);
50
+ set.add(absolute);
51
+ }
52
+ wrapReads(fs, (file) => record(file));
53
+ /** Beside this module; `.ts` when the plugin runs from source (its own test fixtures). */
54
+ const recordChild = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './record-child.ts' : './record-child.js', import.meta.url));
55
+ let recordDir;
56
+ let spawned = 0;
57
+ /** Where one child (and its descendants) appends what it loaded. */
58
+ function recordFile() {
59
+ recordDir ??= mkdtemp(path.join(os.tmpdir(), 'blacksmith-child-'));
60
+ return path.join(recordDir, `${process.pid}-${spawned++}.txt`);
61
+ }
62
+ /**
63
+ * `given` (the call's `env`, or the parent's when it had none) with the
64
+ * recorder preloaded and a file to report to; `null` when the caller asked
65
+ * for a child free of node options with `env: { ...process.env, NODE_OPTIONS:
66
+ * undefined }`, which is left alone.
67
+ */
68
+ function recordedEnv(given) {
69
+ const cleared = given !== undefined &&
70
+ Object.prototype.hasOwnProperty.call(given, 'NODE_OPTIONS') &&
71
+ given.NODE_OPTIONS === undefined;
72
+ if (cleared)
73
+ return null;
74
+ const env = { ...(given ?? process.env) };
75
+ const file = recordFile();
76
+ env[CHILD_RECORD_ENV] = file;
77
+ env.NODE_OPTIONS = [
78
+ env.NODE_OPTIONS,
79
+ `--import ${pathToFileURL(recordChild).href}`,
80
+ ]
81
+ .filter(Boolean)
82
+ .join(' ');
83
+ return { env, file };
84
+ }
85
+ const OUTSTANDING = Symbol.for('blacksmith.recordReads.outstanding');
86
+ // On the shared builtin, like the context: the `vm` pools evaluate this file
87
+ // once per test file while the wrapped `child_process` and `Worker` keep the
88
+ // first evaluation's closures, so every evaluation must see one set.
89
+ /** Children that have not exited yet, each still charged to the test file that started it. */
90
+ function outstanding() {
91
+ return (shared[OUTSTANDING] ??= new Set());
92
+ }
93
+ function startChild(file, cwd) {
94
+ const child = { file, cwd, test: workerState()?.filepath };
95
+ if (file !== null)
96
+ outstanding().add(child);
97
+ return child;
98
+ }
99
+ /** The options object of a `child_process` call, inserted when the call had none. */
100
+ function optionsOf(args) {
101
+ let index = args.findIndex((arg, i) => i > 0 && typeof arg === 'object' && arg !== null && !Array.isArray(arg));
102
+ if (index === -1) {
103
+ index = Array.isArray(args[1]) ? 2 : 1;
104
+ args.splice(index, 0, {});
105
+ }
106
+ const options = { ...args[index] };
107
+ args[index] = options;
108
+ return options;
109
+ }
110
+ /**
111
+ * Prepares a `child_process` call: the child gets the recorder preloaded and
112
+ * a file to report to, and the command plus every argument naming a file is
113
+ * recorded right away, which is all a non-node child (a shell script, a
114
+ * binary) can contribute.
115
+ */
116
+ function instrument(args) {
117
+ if (!dependencies())
118
+ return startChild(null, process.cwd());
119
+ const options = optionsOf(args);
120
+ const cwd = typeof options.cwd === 'string'
121
+ ? path.resolve(options.cwd)
122
+ : options.cwd instanceof URL
123
+ ? fileURLToPath(options.cwd)
124
+ : process.cwd();
125
+ const recorded = recordedEnv(options.env);
126
+ if (recorded)
127
+ options.env = recorded.env;
128
+ const words = [
129
+ ...(typeof args[0] === 'string' ? args[0].split(/\s+/) : []),
130
+ ...(Array.isArray(args[1]) ? args[1] : []),
131
+ ];
132
+ for (const word of words) {
133
+ if (typeof word !== 'string' || word === '')
134
+ continue;
135
+ const candidate = path.resolve(cwd, word);
136
+ try {
137
+ if (exists(candidate) && stat(candidate).isFile())
138
+ record(candidate);
139
+ }
140
+ catch { }
141
+ }
142
+ return startChild(recorded?.file ?? null, cwd);
51
143
  }
52
- /** A recursive copy reads every file below its source. */
53
- function recordTree(target) {
54
- record(target);
55
- const file = toPath(target);
56
- if (file === null)
144
+ /** What the child has reported so far, relative to its working directory, while its test file is the one running. */
145
+ function recordReported(child) {
146
+ if (child.file === null || child.test !== workerState()?.filepath)
57
147
  return;
58
- let entries;
148
+ let reported;
59
149
  try {
60
- entries = fs.readdirSync(file, { recursive: true, encoding: 'utf8' });
150
+ reported = read(child.file, 'utf8');
61
151
  }
62
152
  catch {
63
153
  return;
64
154
  }
65
- for (const entry of entries)
66
- record(path.join(file, entry));
155
+ for (const line of reported.split('\n'))
156
+ if (line !== '')
157
+ record(line, child.cwd);
67
158
  }
68
- function wrap(object, name, recorder = record) {
69
- const original = object[name];
159
+ /** Records what the exited child reported and forgets it. */
160
+ function collect(child) {
161
+ if (child.file === null)
162
+ return;
163
+ recordReported(child);
164
+ try {
165
+ unlink(child.file);
166
+ }
167
+ catch { }
168
+ outstanding().delete(child);
169
+ }
170
+ // Vitest ships the test file's dependency set as soon as its hooks are done,
171
+ // so a child still running then (a server the test tears down, a worker it
172
+ // never joins) reports what it has loaded so far here. Whether this hook
173
+ // runs before or after the file's own `afterAll` (`sequence.hooks`), the
174
+ // child's exit still reports to this file while it is the one running.
175
+ afterAll(() => {
176
+ const current = workerState()?.filepath;
177
+ for (const child of outstanding()) {
178
+ if (child.test !== current)
179
+ continue;
180
+ recordReported(child);
181
+ outstanding().delete(child);
182
+ }
183
+ });
184
+ function wrapChildProcess(name, sync) {
185
+ const target = childProcess;
186
+ const original = target[name];
70
187
  if (typeof original !== 'function' || BRAND in original)
71
188
  return;
72
- const wrapped = function (...args) {
73
- recorder(args[0]);
74
- return original.apply(this, args);
75
- };
76
- // `realpathSync.native` and friends live on the function.
77
- Object.assign(wrapped, original);
78
- wrap(wrapped, 'native', recorder);
189
+ const call = original;
190
+ const wrapped = sync
191
+ ? function (...args) {
192
+ const child = instrument(args);
193
+ try {
194
+ return call.apply(this, args);
195
+ }
196
+ finally {
197
+ collect(child);
198
+ }
199
+ }
200
+ : function (...args) {
201
+ const child = instrument(args);
202
+ const process = call.apply(this, args);
203
+ // Only `exit`: an `error` listener would keep a spawn failure from
204
+ // surfacing as the uncaught error it is. A child that never ran
205
+ // reported nothing and is forgotten with its test file.
206
+ if (typeof process?.once === 'function') {
207
+ process.once('exit', () => collect(child));
208
+ }
209
+ return process;
210
+ };
211
+ // Node's own promisified `exec`/`execFile` call the unwrapped function;
212
+ // `promisify(execFile)` must go through the wrapper to reach the child.
213
+ if (promisify.custom in original) {
214
+ Object.defineProperty(wrapped, promisify.custom, {
215
+ value: (...args) => {
216
+ let child;
217
+ const promise = new Promise((resolve, reject) => {
218
+ child = wrapped(...args, (error, stdout, stderr) => error
219
+ ? reject(Object.assign(error, { stdout, stderr }))
220
+ : resolve({ stdout, stderr }));
221
+ });
222
+ return Object.defineProperty(promise, 'child', {
223
+ value: child,
224
+ enumerable: true,
225
+ });
226
+ },
227
+ });
228
+ }
229
+ copyProperties(wrapped, original);
79
230
  Object.defineProperty(wrapped, BRAND, { value: true });
80
- object[name] = wrapped;
81
- }
82
- const READS = [
83
- 'access',
84
- 'copyFile',
85
- 'lstat',
86
- 'open',
87
- 'opendir',
88
- 'readdir',
89
- 'readFile',
90
- 'readlink',
91
- 'realpath',
92
- 'stat',
93
- ];
94
- for (const name of READS) {
95
- wrap(fs, name);
96
- wrap(fs, `${name}Sync`);
97
- wrap(fs.promises, name);
98
- }
99
- wrap(fs, 'existsSync');
100
- wrap(fs, 'createReadStream');
101
- for (const object of [fs, fs.promises]) {
102
- wrap(object, 'cp', recordTree);
103
- }
104
- wrap(fs, 'cpSync', recordTree);
231
+ target[name] = wrapped;
232
+ }
233
+ for (const name of ['spawn', 'exec', 'execFile', 'fork']) {
234
+ wrapChildProcess(name, false);
235
+ }
236
+ for (const name of ['spawnSync', 'execSync', 'execFileSync']) {
237
+ wrapChildProcess(name, true);
238
+ }
239
+ /** The script a worker runs, when it is a file; `null` for `eval`, `data:` and `node:` sources. */
240
+ function workerScript(filename, options) {
241
+ if (options?.eval === true)
242
+ return null;
243
+ if (filename instanceof URL) {
244
+ return filename.protocol === 'file:' ? fileURLToPath(filename) : null;
245
+ }
246
+ return filename.startsWith('data:') || filename.startsWith('node:')
247
+ ? null
248
+ : filename;
249
+ }
250
+ /**
251
+ * A worker thread has its own `fs` builtin and module registry, so it is
252
+ * recorded like a child: the preload comes through its `env` (a worker reads
253
+ * `NODE_OPTIONS` from there), and the script it runs is recorded right away.
254
+ * A worker sharing the parent's environment (`env: SHARE_ENV`) contributes
255
+ * only its script, and so does one whose `NODE_OPTIONS` node refuses to
256
+ * change (the parent's holds an option a worker's may not carry, `--title`
257
+ * say): it is then started exactly as asked.
258
+ */
259
+ function wrapWorker() {
260
+ const target = workerThreads;
261
+ const Original = workerThreads.Worker;
262
+ if (BRAND in Original)
263
+ return;
264
+ function RecordedWorker(filename, options) {
265
+ const construct = (given) => Reflect.construct(Original, [filename, given], new.target ?? RecordedWorker);
266
+ if (!dependencies())
267
+ return construct(options);
268
+ const script = workerScript(filename, options);
269
+ if (script !== null)
270
+ record(script);
271
+ const recorded = options?.env === workerThreads.SHARE_ENV
272
+ ? null
273
+ : recordedEnv(options?.env);
274
+ if (!recorded)
275
+ return construct(options);
276
+ const child = startChild(recorded.file, process.cwd());
277
+ let worker;
278
+ try {
279
+ worker = construct({ ...options, env: recorded.env });
280
+ }
281
+ catch (error) {
282
+ outstanding().delete(child);
283
+ if (error.code !== 'ERR_WORKER_INVALID_EXEC_ARGV') {
284
+ throw error;
285
+ }
286
+ return construct(options);
287
+ }
288
+ // `exit` follows an uncaught error in the worker too; an `error`
289
+ // listener here would swallow that error instead of letting Vitest report it.
290
+ worker.once('exit', () => collect(child));
291
+ return worker;
292
+ }
293
+ RecordedWorker.prototype = Original.prototype;
294
+ Object.setPrototypeOf(RecordedWorker, Original);
295
+ copyProperties(RecordedWorker, Original);
296
+ Object.defineProperty(RecordedWorker, BRAND, { value: true });
297
+ target.Worker = RecordedWorker;
298
+ }
299
+ wrapWorker();
105
300
  // `import { readFileSync } from 'node:fs'` binds the builtin's ESM namespace,
106
301
  // which only sees the wrapped functions once the namespace is resynced; the
107
302
  // vm pools keep their own per-context namespaces and expose their resync.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@useblacksmith/vitest",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Runs only the Vitest tests affected since the last successful run, with Blacksmith-backed caching",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,9 @@
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.6.6",
32
32
  "@types/picomatch": "^4.0.3",
33
+ "@vitest/browser-playwright": "^5.1.0",
34
+ "@vitest/coverage-v8": "^5.1.0",
35
+ "playwright": "^1.65.0",
33
36
  "typescript": "^7.0.2",
34
37
  "vite": "^8.3.5",
35
38
  "vitest": "^5.1.0"
package/dist/bloom.d.ts DELETED
@@ -1,23 +0,0 @@
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 DELETED
@@ -1,70 +0,0 @@
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
- }