@specific.dev/spectest 0.81.1 → 0.83.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/dist/coverage.js CHANGED
@@ -13,11 +13,11 @@
13
13
  // a report in the directory.
14
14
  //
15
15
  // After every adapter ran, the harness reads the directory the same way
16
- // it does for `coverage: true`: at least one well-formed report (lcov or
17
- // V8 JSON), shipped verbatim. Nothing is converted in the VM: `node()`
18
- // compacts its V8 dumps (the app's own scripts, each source map once per
19
- // branch) and ships them as they are. The control plane derives what it
20
- // needs from the stored bytes, off the test run.
16
+ // it does for `coverage: true`: at least one well-formed report (lcov,
17
+ // V8 JSON, or a node() script record), shipped verbatim. Nothing is
18
+ // converted, parsed or looked up in the VM: `node()`'s hook collects
19
+ // inside the process and writes what ships. The control plane derives
20
+ // what it needs from the stored bytes, off the test run.
21
21
  //
22
22
  // Imported from `@specific.dev/spectest/coverage`. The three shipped
23
23
  // adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
@@ -26,7 +26,6 @@
26
26
  //
27
27
  // import * as coverage from "@specific.dev/spectest/coverage";
28
28
  // coverage: { adapters: [coverage.node(), coverage.browser()] }
29
- import { createHash } from "node:crypto";
30
29
  import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
31
30
  import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
32
31
  export { COVERAGE_CONTAINER_DIR };
@@ -37,11 +36,6 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
37
36
  /** Prefix of the per-process sockets the node hook answers on, relative
38
37
  * to the coverage dir: `.ctl-<pid>`. */
39
38
  export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
40
- /** Key of the content hash (`sha256:<hex>`) a shipped `source-map-cache`
41
- * entry carries, and key of the stub that stands in for an entry that
42
- * shipped earlier on the same branch. See {@link compactV8Document}. */
43
- export const SOURCE_MAP_HASH_KEY = "spectestHash";
44
- export const SOURCE_MAP_REF_KEY = "spectestRef";
45
39
  /** The `reports` mode of a `coverage` value, for the reports no adapter
46
40
  * wrote (the program's own, a `command`'s). */
47
41
  export function coverageReportsMode(cov) {
@@ -119,89 +113,305 @@ export function appendEnvFlag(env, name, value) {
119
113
  return { ...env, [name]: prior ? `${prior} ${value}` : value };
120
114
  }
121
115
  // ── node ──────────────────────────────────────────────────────────
116
+ /** Env var the hook reads: the coverage directory inside the container. */
117
+ export const NODE_COVERAGE_DIR_ENV = "SPECTEST_COVERAGE_DIR";
118
+ /** Subdirectory of the coverage dir where the hook writes one record per
119
+ * script (`scripts/<id>.json`, {@link NODE_COVERAGE_HOOK}). Shipped
120
+ * once, like a dump. */
121
+ export const NODE_COVERAGE_SCRIPTS_SUBDIR = "scripts";
122
+ /** Hidden subdirectory holding one empty marker per script record ever
123
+ * written on this branch. The hook's "written already?" check, which
124
+ * must survive the harness shipping and removing the record; hidden,
125
+ * so the harness never reads it. */
126
+ export const NODE_COVERAGE_MARKERS_SUBDIR = ".spectest/scripts";
127
+ /** Name prefix of the dumps the hook writes: `node-<pid>-<thread>-<ms>-<n>.json`. */
128
+ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
122
129
  /**
123
- * The hook `node()` mounts and `--require`s into every node process of
124
- * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
125
- * when a process exits; a long-lived server never exits, so the hook
126
- * binds a Unix socket in the coverage directory and calls
127
- * `v8.takeCoverage()` on request. **Every** process binds its own socket
128
- * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
129
- * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
130
- * `npm run`) whose child is the real server; a single socket on the
131
- * wrapper dumped the wrapper and silently never the server (reported by
132
- * a user 2026-08-27). At capture spectest asks every live socket and
133
- * unlinks the stale ones. Unref'd, so a short-lived process still exits.
134
- * Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
135
- * ESM loader thread bound the shared per-pid path last, so every
136
- * `--import tsx` server answered with the loader's coverage — the app's
137
- * main thread was never dumped (found in the same user's first full run).
138
- * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
139
- * Temporal worker, restarts nodemon), a socket is nobody's.
130
+ * The hook `node()` mounts at {@link NODE_COVERAGE_HOOK_PATH} and
131
+ * `--require`s into every node process of the service. **It is the
132
+ * collector**: nothing outside the process reads, converts or looks
133
+ * anything up. Runs in every thread (a worker thread inherits
134
+ * `NODE_OPTIONS`); reads one env var, {@link NODE_COVERAGE_DIR_ENV}; uses
135
+ * node built-ins only, so it runs in any image that runs node.
136
+ *
137
+ * What it does:
138
+ *
139
+ * - Starts V8 precise (block, counted) coverage through the in-process
140
+ * inspector session. `NODE_V8_COVERAGE` is **not** set: Node would then
141
+ * write everything the process loaded at every exit — `node:`
142
+ * internals, every `node_modules` file, and the whole source map of
143
+ * every script that carries a `sourceMappingURL` comment, with or
144
+ * without `--enable-source-maps`. For a bundled CLI that was 12 MB per
145
+ * invocation, inside the test's own time, parsed again at capture.
146
+ * - **Takes** on request — the main thread binds a Unix socket
147
+ * `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
148
+ * process binds its own (the first node process is often a wrapper,
149
+ * `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
150
+ * handler, which is how a short-lived CLI and a worker thread report.
151
+ * The in-process session answers synchronously, so the exit handler
152
+ * has the result before the process is gone. A take resets V8's
153
+ * counters: every dump is the delta since the previous take. No
154
+ * signal is used: signals are claimed by frameworks (SIGUSR2 stops a
155
+ * Temporal worker, restarts nodemon), a socket is nobody's.
156
+ * - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
157
+ * scripts (`file://`, outside `node_modules` and `.cache`, not the
158
+ * hook) in which some range count is above zero, and of each only the
159
+ * functions that ran, each one flat array: `[start, length, count]`
160
+ * for its extent (V8's first range), then `[offset from start,
161
+ * length, count]` per block range in V8's order. Name and block flag
162
+ * are in the record, joined on the extent. Nothing when nothing ran.
163
+ * `{ spectestVersion: 2, result: [{ url, spectestScript, functions:
164
+ * [[s, l, c, o, l, c, …], …] }] }`.
165
+ * - Writes, **once per script per branch**, a **script record**
166
+ * `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
167
+ * line of the source V8 compiled (`Debugger.getScriptSource`, so a
168
+ * transpiled module — tsx, ts-node, a bundler's register hook — is
169
+ * described by the text V8 ran, not the file on disk), every function
170
+ * with its extent (the skeleton: what a dump's "ran" functions are a
171
+ * subset of, so a never-run function is known to exist), and the
172
+ * source map: the inline `data:` map in the text, the file the last
173
+ * `sourceMappingURL` comment names, or `<script>.map` next to the
174
+ * script; `sourcesContent` dropped (the sources are the repository).
175
+ * "Once per branch" is a marker file under `.spectest/scripts/`: the
176
+ * directory is shared by every process of the service and forks with
177
+ * the environment, so a record written at bring-up or by an ancestor
178
+ * is never written again, whichever process comes next.
179
+ *
180
+ * The harness ships new dumps and new records as they are. It parses
181
+ * nothing and touches no container; the control plane derives lines
182
+ * from the stored documents (`SUBSET_RUNS.md`).
140
183
  */
141
184
  export const NODE_COVERAGE_HOOK = `"use strict";
142
185
  // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
143
- // Main thread only: worker threads inherit NODE_OPTIONS and would bind
144
- // the same per-pid socket last — tsx's ESM loader runs on one, and it
145
- // stole the socket from every \`--import tsx\` server, so a dump was the
146
- // loader's isolate and never the app's.
147
- if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread) {
148
- const net = require("node:net");
149
- const fs = require("node:fs");
150
- const v8 = require("node:v8");
151
- const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
152
- const server = net.createServer((conn) => {
153
- // A dump is taken only on an explicit "dump" request.
154
- let buf = "";
155
- conn.on("data", (chunk) => {
156
- buf += chunk;
157
- if (!buf.includes("\\n")) return;
158
- let reply;
186
+ (function () {
187
+ const dir = process.env[${JSON.stringify(NODE_COVERAGE_DIR_ENV)}];
188
+ if (!dir) return;
189
+ let inspector, fs, path, crypto, url, wt, net;
190
+ try {
191
+ inspector = require("node:inspector");
192
+ fs = require("node:fs");
193
+ path = require("node:path");
194
+ crypto = require("node:crypto");
195
+ url = require("node:url");
196
+ wt = require("node:worker_threads");
197
+ net = require("node:net");
198
+ } catch (e) {
199
+ return;
200
+ }
201
+ const session = new inspector.Session();
202
+ try {
203
+ session.connect();
204
+ } catch (e) {
205
+ return;
206
+ }
207
+ // An in-process session answers before post() returns.
208
+ function post(method, params) {
209
+ let out = null;
210
+ session.post(method, params || {}, function (err, res) {
211
+ out = err ? null : res;
212
+ });
213
+ return out;
214
+ }
215
+ if (post("Profiler.enable") === null) return;
216
+ post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
217
+
218
+ const SELF = url.pathToFileURL(__filename).href;
219
+ const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
220
+ const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
221
+ const known = new Set();
222
+ let seq = 0;
223
+
224
+ function isApp(u) {
225
+ return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
226
+ }
227
+ function ran(f) {
228
+ return f.ranges.some(function (r) { return r.count > 0; });
229
+ }
230
+ function idOf(u) {
231
+ return crypto.createHash("sha256").update(u).digest("hex").slice(0, 32);
232
+ }
233
+ function lineLengths(text) {
234
+ const out = [];
235
+ let len = 0;
236
+ for (let i = 0; i < text.length; i++, len++) {
237
+ const c = text.charCodeAt(i);
238
+ if (c === 0x0a || c === 0x2028 || c === 0x2029) {
239
+ out.push(len);
240
+ len = -1;
241
+ }
242
+ }
243
+ out.push(len);
244
+ return out;
245
+ }
246
+ function readMap(scriptPath, text) {
247
+ const re = /\\/\\/[#@]\\s*sourceMappingURL=(\\S+)/g;
248
+ let ref = null;
249
+ let m;
250
+ while ((m = re.exec(text)) !== null) ref = m[1];
251
+ let mapText = null;
252
+ let mapUrl = null;
253
+ try {
254
+ if (ref === null) {
255
+ const p = scriptPath + ".map";
256
+ mapText = fs.readFileSync(p, "utf8");
257
+ mapUrl = url.pathToFileURL(p).href;
258
+ } else if (ref.startsWith("data:")) {
259
+ const c = ref.indexOf(",");
260
+ if (c < 0) return null;
261
+ const head = ref.slice(5, c);
262
+ const body = ref.slice(c + 1);
263
+ mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
264
+ } else if (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith("file:")) {
265
+ return null;
266
+ } else {
267
+ const p = ref.startsWith("file:") ? url.fileURLToPath(ref) : path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
268
+ mapText = fs.readFileSync(p, "utf8");
269
+ mapUrl = url.pathToFileURL(p).href;
270
+ }
271
+ } catch (e) {
272
+ return null;
273
+ }
274
+ let data;
275
+ try {
276
+ data = JSON.parse(mapText);
277
+ } catch (e) {
278
+ return null;
279
+ }
280
+ if (!data || typeof data !== "object" || !("mappings" in data)) return null;
281
+ delete data.sourcesContent;
282
+ return { url: mapUrl, data: data };
283
+ }
284
+ function writeAtomic(file, text) {
285
+ const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
286
+ fs.writeFileSync(tmp, text);
287
+ fs.renameSync(tmp, file);
288
+ }
289
+ function describe(s, source) {
290
+ const id = idOf(s.url);
291
+ let scriptPath = null;
292
+ try {
293
+ scriptPath = url.fileURLToPath(s.url);
294
+ } catch (e) {}
295
+ const text = typeof source === "string" ? source : null;
296
+ const record = {
297
+ url: s.url,
298
+ lineLengths: text !== null ? lineLengths(text) : null,
299
+ functions: s.functions.map(function (f) {
300
+ return [f.functionName, f.ranges[0].startOffset, f.ranges[0].endOffset];
301
+ }),
302
+ sourceMap: text !== null && scriptPath !== null ? readMap(scriptPath, text) : null,
303
+ };
304
+ writeAtomic(path.join(SCRIPTS, id + ".json"), JSON.stringify({ spectestScript: record }));
305
+ try {
306
+ fs.writeFileSync(path.join(MARKERS, id), "");
307
+ } catch (e) {}
308
+ }
309
+ function take() {
310
+ const res = post("Profiler.takePreciseCoverage");
311
+ if (!res || !Array.isArray(res.result)) return;
312
+ const kept = [];
313
+ for (const s of res.result) {
314
+ if (isApp(s.url) && Array.isArray(s.functions) && s.functions.some(ran)) kept.push(s);
315
+ }
316
+ if (kept.length === 0) return;
317
+ const fresh = [];
318
+ for (const s of kept) {
319
+ if (known.has(s.url)) continue;
320
+ known.add(s.url);
321
+ if (!fs.existsSync(path.join(MARKERS, idOf(s.url)))) fresh.push(s);
322
+ }
323
+ if (fresh.length > 0) {
159
324
  try {
160
- if (!buf.startsWith("dump")) throw new Error("unknown request");
161
- v8.takeCoverage();
162
- reply = "ok\\n";
163
- } catch (err) {
164
- reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
325
+ fs.mkdirSync(SCRIPTS, { recursive: true });
326
+ fs.mkdirSync(MARKERS, { recursive: true });
327
+ } catch (e) {}
328
+ post("Debugger.enable");
329
+ for (const s of fresh) {
330
+ const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
331
+ try {
332
+ describe(s, r && r.scriptSource);
333
+ } catch (e) {}
165
334
  }
166
- conn.end(reply);
335
+ post("Debugger.disable");
336
+ }
337
+ // A ran function is one flat array: [start, length, count] for its
338
+ // extent (V8's first range), then [offset from start, length, count]
339
+ // per block range in V8's order. Small numbers, so the dump gzips to
340
+ // less than half of V8's own JSON; name and block flag live in the
341
+ // script record, joined on the extent.
342
+ const doc = {
343
+ spectestVersion: 2,
344
+ result: kept.map(function (s) {
345
+ return {
346
+ url: s.url,
347
+ spectestScript: idOf(s.url),
348
+ functions: s.functions.filter(ran).map(function (f) {
349
+ const first = f.ranges[0];
350
+ const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
351
+ for (let i = 1; i < f.ranges.length; i++) {
352
+ const r = f.ranges[i];
353
+ out.push(r.startOffset - first.startOffset, r.endOffset - r.startOffset, r.count);
354
+ }
355
+ return out;
356
+ }),
357
+ };
358
+ }),
359
+ };
360
+ const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + process.pid + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
361
+ writeAtomic(path.join(dir, name), JSON.stringify(doc));
362
+ }
363
+
364
+ if (wt.isMainThread) {
365
+ const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
366
+ const server = net.createServer(function (conn) {
367
+ let buf = "";
368
+ conn.on("data", function (chunk) {
369
+ buf += chunk;
370
+ if (buf.indexOf("\\n") < 0) return;
371
+ let reply;
372
+ try {
373
+ if (!buf.startsWith("dump")) throw new Error("unknown request");
374
+ take();
375
+ reply = "ok\\n";
376
+ } catch (err) {
377
+ reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
378
+ }
379
+ conn.end(reply);
380
+ });
381
+ });
382
+ server.unref();
383
+ server.on("error", function () {});
384
+ try {
385
+ fs.unlinkSync(SOCK);
386
+ } catch (e) {}
387
+ server.listen(SOCK);
388
+ process.on("exit", function () {
389
+ try {
390
+ fs.unlinkSync(SOCK);
391
+ } catch (e) {}
167
392
  });
393
+ }
394
+ process.on("exit", function () {
395
+ try {
396
+ take();
397
+ } catch (e) {}
168
398
  });
169
- server.unref();
170
- server.on("error", () => {});
171
- try { fs.unlinkSync(SOCK); } catch {}
172
- server.listen(SOCK);
173
- process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
174
- }
399
+ })();
175
400
  `;
176
401
  /**
177
402
  * Coverage for a Node service — a long-lived server, or a container whose
178
403
  * node processes are short-lived CLIs run by `ctx.exec`. Sets
179
- * `NODE_V8_COVERAGE` to the coverage directory (every node process in the
180
- * container then writes V8 coverage JSON when it exits) and `--require`s
181
- * a hook that lets spectest ask every live node process for a dump at
182
- * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
183
- * `tsx`). Nothing for the app to write.
184
- *
185
- * What ships is the V8 documents themselves, compacted
186
- * ({@link compactV8Document}): the app's own scripts that ran, and each
187
- * script's source map once per branch. A map comes from Node's own
188
- * `source-map-cache` when Node found one through the script's
189
- * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
190
- * without `--enable-source-maps`), else from `<script>.map` next to the
191
- * script, read out of the container once
192
- * ({@link resolveSourceMapsFromContainer}). The second way is the one to
193
- * build a short-lived bundled process in: a map Node finds is serialized
194
- * whole into every exit dump — 12 MB per invocation for a bundled CLI,
195
- * inside the test's own time — and parsed again by the harness; a map
196
- * with no comment costs nothing there. Nothing is converted in the VM.
197
- * A dump is a **delta** by construction: V8 resets its counters at every
198
- * `takeCoverage()`, so a live server's dump after a test is that test's
199
- * own execution, and the boot dump (everything loaded) lands in the
200
- * bring-up capture and nowhere else. The control plane derives lines
201
- * from the stored documents, off the test run. Converting to lcov in
202
- * the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
203
- * service on a real project, inside the test run; a real project turned
204
- * coverage off because of it, and the conversion went.
404
+ * {@link NODE_COVERAGE_DIR_ENV} to the coverage directory and
405
+ * `--require`s {@link NODE_COVERAGE_HOOK}, which collects everything in
406
+ * the process. Nothing for the app to write, and nothing the harness
407
+ * reads out of the container: at capture it asks every live process for
408
+ * a take over its socket and lists what appeared in the directory —
409
+ * dumps and script records — for the harness to ship as they are. A
410
+ * dump is a delta by construction (V8 resets its counters at every
411
+ * take), so the report after a test is that test's own execution and
412
+ * the boot dump lands in the bring-up capture alone. Each capture ships
413
+ * only its own files: what an earlier capture on this branch shipped is
414
+ * removed first (module memory, forks with the environment).
205
415
  */
206
416
  export function node() {
207
417
  return {
@@ -209,7 +419,7 @@ export function node() {
209
419
  reports: "delta",
210
420
  configure(svc) {
211
421
  const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
212
- env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
422
+ env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
213
423
  return {
214
424
  ...svc,
215
425
  env,
@@ -226,23 +436,17 @@ export function node() {
226
436
  // rather than failed: an empty report is a report. A server whose
227
437
  // hook never loaded (NODE_OPTIONS not reaching it) then shows as
228
438
  // empty reports after bring-up, which the boot log warns about.
229
- //
230
- // Each capture ships only its own dumps: what an earlier capture on
231
- // this branch shipped is removed first (module memory, forks with
232
- // the environment).
233
439
  const t0 = performance.now();
234
- await removeShippedV8Reports(ctx.reportDir);
440
+ await removeShippedNodeReports(ctx.reportDir);
235
441
  const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
236
442
  const t1 = performance.now();
237
- const stats = await prepareV8Reports(ctx.reportDir, (urls) => resolveSourceMapsFromContainer(ctx, urls));
443
+ const stats = await collectNodeReports(ctx.reportDir);
238
444
  if (stats.dumps === 0) {
239
445
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
240
- SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
446
+ SHIPPED_NODE_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
241
447
  }
242
- const t2 = performance.now();
243
448
  console.log(`[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
244
- `${stats.dumps} dump(s), ${stats.scripts} script(s), ${stats.bytesIn} → ${stats.bytesOut} bytes compacted in ${Math.round(t2 - t1)} ms; ` +
245
- `${stats.mapsFromContainer} map(s) read from the container`);
449
+ `${stats.dumps} dump(s) (${stats.dumpBytes} B), ${stats.scripts} new script record(s) (${stats.scriptBytes} B)`);
246
450
  },
247
451
  };
248
452
  }
@@ -300,357 +504,73 @@ export async function dumpAllNodeProcesses(dir, signal) {
300
504
  }
301
505
  return live;
302
506
  }
303
- /** Report files shipped at an earlier capture on this branch, by host
304
- * path. Module memory: forks with the environment, so a forked child
305
- * removes its ancestors' reports at its first capture and ships only
306
- * its own. */
307
- const SHIPPED_V8_REPORTS = new Set();
308
- /** Source-map entries shipped earlier on this branch: script URL → hash
309
- * of the entry. Module memory, forks with the environment. A later dump
310
- * of the same script carries a stub that refers to the hash instead of
311
- * the map again. */
312
- const SHIPPED_SOURCE_MAPS = new Map();
313
- /** Scripts looked up in the container and found to have no source map
314
- * (no `sourceMappingURL`, or a map file that is not there). Module
315
- * memory, forks with the environment: one lookup per script per branch. */
316
- const NO_SOURCE_MAP = new Set();
507
+ /** Files shipped at an earlier capture on this branch, by host path:
508
+ * dumps, script records, the empty report. Module memory: forks with
509
+ * the environment, so a forked child removes its ancestors' files at
510
+ * its first capture and ships only its own. The hook's own "written
511
+ * already" markers are elsewhere and stay. */
512
+ const SHIPPED_NODE_REPORTS = new Set();
317
513
  /** Forget the branch memory (tests). */
318
514
  export function resetNodeCoverageMemory() {
319
- SHIPPED_V8_REPORTS.clear();
320
- SHIPPED_SOURCE_MAPS.clear();
321
- NO_SOURCE_MAP.clear();
515
+ SHIPPED_NODE_REPORTS.clear();
322
516
  }
323
- /** Remove from `dir` the reports an earlier capture shipped. */
324
- export async function removeShippedV8Reports(dir) {
517
+ /** Remove from `dir` the files an earlier capture shipped. */
518
+ export async function removeShippedNodeReports(dir) {
325
519
  const fs = await import("node:fs/promises");
326
- for (const file of [...SHIPPED_V8_REPORTS]) {
520
+ for (const file of [...SHIPPED_NODE_REPORTS]) {
327
521
  if (!file.startsWith(dir + "/"))
328
522
  continue;
329
523
  await fs.unlink(file).catch(() => { });
330
- SHIPPED_V8_REPORTS.delete(file);
524
+ SHIPPED_NODE_REPORTS.delete(file);
331
525
  }
332
526
  }
333
- /** A script is the app's own when it is a file outside node_modules —
334
- * and not our hook, which every dump would otherwise carry. */
335
- export function isAppScriptUrl(url) {
336
- return (url.startsWith("file://") &&
337
- !url.includes("/node_modules/") &&
338
- // Package managers run from a cache, not node_modules: corepack's pnpm
339
- // is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
340
- !url.includes("/.cache/") &&
341
- !url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
342
- }
343
- /** True when any range of any function of the script has a count above
344
- * zero: something in it ran since the previous take. A script whose
345
- * every count is zero is one V8 still lists after a reset; it says
346
- * nothing and is dropped. */
347
- export function scriptExecuted(script) {
348
- const fns = Array.isArray(script.functions) ? script.functions : [];
349
- return fns.some((f) => Array.isArray(f.ranges) && f.ranges.some((r) => typeof r.count === "number" && r.count > 0));
527
+ /** True for a dump the hook wrote (`node-*.json`). */
528
+ export function isNodeDumpName(name) {
529
+ return name.startsWith(NODE_COVERAGE_DUMP_PREFIX) && name.endsWith(".json");
350
530
  }
351
531
  /**
352
- * Compact one V8 coverage document to what the derivation needs. What
353
- * Node writes is everything the process loaded: `node:` internals, every
354
- * `node_modules` file, and — under `--enable-source-maps` — a
355
- * `source-map-cache` with each file's full map **and its sources**,
356
- * repeated in every dump. Measured on a real project: a 12–20 MiB dump
357
- * per capture, of which the app's own coverage was under 0.5 MiB, and a
358
- * suite that hit the 64 MiB cap on its third test. Kept: `file://`
359
- * scripts outside `node_modules` in which something ran, and the map
360
- * entries of exactly those scripts, minus `sourcesContent` (the sources
361
- * are the repo). Each kept map entry carries its content hash under
362
- * {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
363
- * URL — the map went out with an earlier dump on this branch — a stub
364
- * `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
365
- * the map in an earlier capture of the branch. `shipped` is updated in
366
- * place. Still a V8 document — nothing is converted here.
367
- */
368
- export function compactV8Document(doc, shipped) {
369
- const result = Array.isArray(doc.result) ? doc.result : [];
370
- const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url) && scriptExecuted(s));
371
- const out = { ...doc, result: kept };
372
- const cache = doc["source-map-cache"];
373
- if (cache && typeof cache === "object") {
374
- const urls = new Set(kept.map((s) => s.url));
375
- const slim = {};
376
- for (const [url, entry] of Object.entries(cache)) {
377
- if (!urls.has(url) || !entry || typeof entry !== "object")
378
- continue;
379
- const e = { ...entry };
380
- if (e.data && typeof e.data === "object") {
381
- const { sourcesContent: _dropped, ...data } = e.data;
382
- e.data = data;
383
- }
384
- const hash = sourceMapEntryHash(e);
385
- if (shipped?.get(url) === hash) {
386
- slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
387
- }
388
- else {
389
- slim[url] = { ...e, [SOURCE_MAP_HASH_KEY]: hash };
390
- shipped?.set(url, hash);
391
- }
392
- }
393
- if (Object.keys(slim).length > 0)
394
- out["source-map-cache"] = slim;
395
- else
396
- delete out["source-map-cache"];
397
- }
398
- return out;
399
- }
400
- /** Shell-quote one argument for `sh -c`. */
401
- function shQuote(s) {
402
- return `'${s.replace(/'/g, `'\\''`)}'`;
403
- }
404
- /**
405
- * The probe {@link resolveSourceMapsFromContainer} runs in the container:
406
- * one `sh` loop over every script path, printing `<path>\t<ref>` — the
407
- * last `sourceMappingURL` value in the file, else `<path>.map` when that
408
- * file exists, else `-`; `!` for a script that is not there. One exec per
409
- * capture per service, whatever the number of scripts: a Next.js server
410
- * loads hundreds of chunk scripts with no map, and a `docker cp` per
411
- * script (the first cut) took a capture past its budget on a busy host.
412
- */
413
- export function sourceMapProbeCommand(scriptPaths) {
414
- const script = 'for f in "$@"; do ' +
415
- 'if [ -f "$f" ]; then ' +
416
- "ref=$(grep -o 'sourceMappingURL=[^[:space:]]*' -- \"$f\" 2>/dev/null | tail -n 1 | cut -c18-); " +
417
- 'if [ -n "$ref" ]; then printf \'%s\\t%s\\n\' "$f" "$ref"; ' +
418
- 'elif [ -f "$f.map" ]; then printf \'%s\\t%s.map\\n\' "$f" "$f"; ' +
419
- 'else printf \'%s\\t-\\n\' "$f"; fi; ' +
420
- 'else printf \'%s\\t!\\n\' "$f"; fi; ' +
421
- "done";
422
- return `sh -c ${shQuote(script)} sh ${scriptPaths.map(shQuote).join(" ")}`;
423
- }
424
- /** The length of every line of `text`, the way Node computes it for
425
- * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
426
- * last line included. Lengths in UTF-16 units, which is what V8's byte
427
- * offsets count in. */
428
- export function lineLengthsOf(text) {
429
- const out = [];
430
- let len = 0;
431
- for (let i = 0; i < text.length; i++, len++) {
432
- const c = text.charCodeAt(i);
433
- if (c === 0x0a || c === 0x2028 || c === 0x2029) {
434
- out.push(len);
435
- len = -1;
436
- }
437
- }
438
- out.push(len);
439
- return out;
440
- }
441
- /**
442
- * The source maps of `scriptUrls`, read out of the container. One probe
443
- * exec ({@link sourceMapProbeCommand}) finds, per script, the map a
444
- * `sourceMappingURL` comment names or `<script>.map` next to it; only a
445
- * script that has one is then read (`docker cp`, the script for its line
446
- * lengths and the map file, or the inline `data:` map decoded). A map's
447
- * `sourcesContent` is dropped. Scripts that are not `file://` URLs, have
448
- * no map, or whose map is not there or not JSON are absent from the
449
- * result. The caller remembers every outcome, so this runs once per
450
- * script per branch.
451
- *
452
- * With no comment the map next to the script is the shape to build a
453
- * short-lived bundled process in: Node caches a map it finds through a
454
- * comment into every dump it writes under NODE_V8_COVERAGE, flag or no
455
- * flag, and a bundled CLI's map is tens of MB per exit; a map it does not
456
- * find costs nothing there.
532
+ * Mark shipped every dump at the root of `dir` and every script record
533
+ * under `scripts/` that no earlier capture shipped, and count them.
534
+ * Nothing is read: the hook wrote each file whole (write + rename), and
535
+ * the harness ships them as they are.
457
536
  */
458
- export async function resolveSourceMapsFromContainer(ctx, scriptUrls) {
459
- const out = new Map();
460
- if (!ctx.readContainerFile)
461
- return out;
462
- const { fileURLToPath, pathToFileURL } = await import("node:url");
537
+ export async function collectNodeReports(dir) {
538
+ const fs = await import("node:fs/promises");
463
539
  const path = await import("node:path");
464
- const byPath = new Map();
465
- for (const url of scriptUrls) {
466
- if (!url.startsWith("file://"))
467
- continue;
540
+ const stats = { dumps: 0, dumpBytes: 0, scripts: 0, scriptBytes: 0 };
541
+ const seen = async (d, keep, onNew) => {
542
+ let names;
468
543
  try {
469
- byPath.set(fileURLToPath(url), url);
544
+ names = await fs.readdir(d);
470
545
  }
471
546
  catch {
472
- // not a path
547
+ return;
473
548
  }
474
- }
475
- if (byPath.size === 0)
476
- return out;
477
- let probe;
478
- try {
479
- probe = (await ctx.exec(sourceMapProbeCommand([...byPath.keys()]))).stdout;
480
- }
481
- catch {
482
- return out; // no shell, no grep: no maps from this container
483
- }
484
- for (const line of probe.split("\n")) {
485
- const tab = line.indexOf("\t");
486
- if (tab < 0)
487
- continue;
488
- const scriptPath = line.slice(0, tab);
489
- const ref = line.slice(tab + 1).trim();
490
- const url = byPath.get(scriptPath);
491
- if (!url || ref === "-" || ref === "!" || ref === "")
492
- continue;
493
- let mapText;
494
- let mapUrl;
495
- if (ref.startsWith("data:")) {
496
- const comma = ref.indexOf(",");
497
- if (comma < 0)
549
+ for (const name of names.sort()) {
550
+ if (!keep(name))
551
+ continue;
552
+ const file = path.join(d, name);
553
+ if (SHIPPED_NODE_REPORTS.has(file))
498
554
  continue;
499
- const head = ref.slice(5, comma);
500
- const body = ref.slice(comma + 1);
555
+ let size;
501
556
  try {
502
- mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
557
+ size = (await fs.stat(file)).size;
503
558
  }
504
559
  catch {
505
560
  continue;
506
561
  }
507
- mapUrl = null;
508
- }
509
- else {
510
- let mapPath;
511
- if (ref.startsWith("file://")) {
512
- try {
513
- mapPath = fileURLToPath(ref);
514
- }
515
- catch {
516
- continue;
517
- }
518
- }
519
- else if (/^[a-z]+:/i.test(ref)) {
520
- continue; // an http(s) map is not in the container
521
- }
522
- else {
523
- let rel = ref;
524
- try {
525
- rel = decodeURIComponent(ref);
526
- }
527
- catch {
528
- // keep as written
529
- }
530
- mapPath = path.resolve(path.dirname(scriptPath), rel);
531
- }
532
- const map = await ctx.readContainerFile(mapPath);
533
- if (!map)
534
- continue;
535
- mapText = map.toString("utf8");
536
- mapUrl = pathToFileURL(mapPath).href;
537
- }
538
- let data;
539
- try {
540
- data = JSON.parse(mapText);
541
- }
542
- catch {
543
- continue;
544
- }
545
- if (!data || typeof data !== "object" || !("mappings" in data))
546
- continue;
547
- const script = await ctx.readContainerFile(scriptPath);
548
- if (!script)
549
- continue;
550
- const { sourcesContent: _dropped, ...slim } = data;
551
- out.set(url, { url: mapUrl, data: slim, lineLengths: lineLengthsOf(script.toString("utf8")) });
552
- }
553
- return out;
554
- }
555
- /** The content hash of a map entry, over its JSON with the spectest
556
- * keys removed. */
557
- function sourceMapEntryHash(entry) {
558
- const e = { ...entry };
559
- delete e[SOURCE_MAP_HASH_KEY];
560
- delete e[SOURCE_MAP_REF_KEY];
561
- return "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
562
- }
563
- /**
564
- * Give every kept script of a compacted document a `source-map-cache`
565
- * entry it lacks: a stub when the map shipped earlier on this branch,
566
- * else the map `resolve` finds (asked once for all such scripts; shipped
567
- * whole, with its hash, and remembered), else nothing — and that outcome
568
- * is remembered too, so a script with no map is looked up once per
569
- * branch. Returns how many maps `resolve` supplied.
570
- */
571
- export async function attachSourceMaps(doc, resolve, shipped = SHIPPED_SOURCE_MAPS, missing = NO_SOURCE_MAP) {
572
- const result = Array.isArray(doc.result) ? doc.result : [];
573
- const cache = (doc["source-map-cache"] ?? {});
574
- const unknown = [];
575
- for (const s of result) {
576
- const url = s.url;
577
- if (typeof url !== "string" || url in cache)
578
- continue;
579
- const prior = shipped.get(url);
580
- if (prior) {
581
- cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
582
- continue;
583
- }
584
- if (missing.has(url) || unknown.includes(url))
585
- continue;
586
- unknown.push(url);
587
- }
588
- let found = 0;
589
- if (unknown.length > 0) {
590
- const entries = await resolve(unknown);
591
- for (const url of unknown) {
592
- const entry = entries.get(url);
593
- if (!entry) {
594
- missing.add(url);
595
- continue;
596
- }
597
- const hash = sourceMapEntryHash(entry);
598
- cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
599
- shipped.set(url, hash);
600
- found++;
562
+ SHIPPED_NODE_REPORTS.add(file);
563
+ onNew(size);
601
564
  }
602
- }
603
- if (Object.keys(cache).length > 0)
604
- doc["source-map-cache"] = cache;
605
- return found;
606
- }
607
- /**
608
- * Compact, in place, every `coverage-*.json` in `dir` that no earlier
609
- * capture shipped, attach the maps the dump lacks through `resolve`
610
- * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
611
- * yet valid JSON) is left for the next capture.
612
- */
613
- export async function prepareV8Reports(dir, resolve) {
614
- const fs = await import("node:fs/promises");
615
- const path = await import("node:path");
616
- const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
617
- let names;
618
- try {
619
- names = await fs.readdir(dir);
620
- }
621
- catch {
622
- return stats;
623
- }
624
- for (const name of names.sort()) {
625
- if (!name.startsWith("coverage-") || !name.endsWith(".json") || name === NODE_COVERAGE_EMPTY_REPORT)
626
- continue;
627
- const file = path.join(dir, name);
628
- if (SHIPPED_V8_REPORTS.has(file))
629
- continue;
630
- let text;
631
- let doc;
632
- try {
633
- text = await fs.readFile(file, "utf8");
634
- doc = JSON.parse(text);
635
- }
636
- catch {
637
- continue; // a dump mid-write, or not ours; the read path judges it
638
- }
639
- if (!Array.isArray(doc.result))
640
- continue;
641
- const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
642
- if (resolve)
643
- stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
644
- const out = JSON.stringify(compact);
645
- const tmp = path.join(dir, `.${name}.compact`);
646
- await fs.writeFile(tmp, out);
647
- await fs.rename(tmp, file);
648
- SHIPPED_V8_REPORTS.add(file);
565
+ };
566
+ await seen(dir, isNodeDumpName, (b) => {
649
567
  stats.dumps++;
650
- stats.scripts += doc.result.length;
651
- stats.bytesIn += text.length;
652
- stats.bytesOut += out.length;
653
- }
568
+ stats.dumpBytes += b;
569
+ });
570
+ await seen(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR), (n) => n.endsWith(".json"), (b) => {
571
+ stats.scripts++;
572
+ stats.scriptBytes += b;
573
+ });
654
574
  return stats;
655
575
  }
656
576
  // ── browser ───────────────────────────────────────────────────────