@specific.dev/spectest 0.79.1 → 0.80.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.
@@ -8,22 +8,11 @@ export declare const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json"
8
8
  /** Prefix of the per-process sockets the node hook answers on, relative
9
9
  * to the coverage dir: `.ctl-<pid>`. */
10
10
  export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
11
- /** Where golden keeps the conversion tools (`c8` and its dependencies),
12
- * seen from the VM. `SPECTEST_COVERAGE_TOOLS_DIR` overrides it (tests).
13
- * The daemon bind-mounts it read-only into every covered container at
14
- * {@link NODE_COVERAGE_TOOLS_CONTAINER_DIR} when it exists. */
15
- export declare const NODE_COVERAGE_TOOLS_DIR = "/opt/spectest/coverage-tools";
16
- /** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
17
- export declare const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
18
- /** The `c8` entry point, relative to the tools dir. */
19
- export declare const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
20
- /** The hidden subdirectory of the coverage dir where `node()` parks the
21
- * V8 dumps it converts at one capture. Hidden, so the harness never
22
- * ships them: the lcov is the report. Emptied at every capture — the
23
- * lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
24
- export declare const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
25
- /** The lcov `node()` writes: the dumps of this capture, merged. */
26
- export declare const NODE_COVERAGE_LCOV = "node.lcov";
11
+ /** Key of the content hash (`sha256:<hex>`) a shipped `source-map-cache`
12
+ * entry carries, and key of the stub that stands in for an entry that
13
+ * shipped earlier on the same branch. See {@link compactV8Document}. */
14
+ export declare const SOURCE_MAP_HASH_KEY = "spectestHash";
15
+ export declare const SOURCE_MAP_REF_KEY = "spectestRef";
27
16
  /** What `configure` learns about the service it rewrites. */
28
17
  export interface CoverageConfigureInfo {
29
18
  /** The services-map key. */
@@ -146,50 +135,19 @@ export declare const NODE_COVERAGE_HOOK: string;
146
135
  * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
147
136
  * `tsx`). Nothing for the app to write.
148
137
  *
149
- * At capture the dumps are **converted to lcov inside the container**
150
- * with `c8` (`c8 report`), and `node.lcov` is the one report that
151
- * ships. It holds **what ran since the previous capture**: V8 resets its
152
- * counters at every `takeCoverage()`, so a live server's dump after a
153
- * test is that test's own execution — the boot dump (everything loaded,
154
- * which at file level is everything "ran") lands in the bring-up
155
- * capture and nowhere else. A run's total is the union over its cases
156
- * on the server; a test's set is the test's own. Merging every dump on
157
- * the branch instead was tried first and would have made a long-lived
158
- * server's per-test set the boot set, every time. The conversion runs
159
- * in the container, not in the harness, because `v8-to-istanbul` needs
160
- * the script text (for byte offsets → lines) and the source maps
161
- * (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
162
- * and only the container has them. `c8` comes from golden
163
- * ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
164
- * documents ship instead, compacted ({@link compactV8Document}), as they
165
- * did before SDK 0.60.
138
+ * What ships is the V8 documents themselves, compacted
139
+ * ({@link compactV8Document}): the app's own scripts that ran, and each
140
+ * script's source map once per branch. Nothing is converted in the VM.
141
+ * A dump is a **delta** by construction: V8 resets its counters at every
142
+ * `takeCoverage()`, so a live server's dump after a test is that test's
143
+ * own execution, and the boot dump (everything loaded) lands in the
144
+ * bring-up capture and nowhere else. The control plane derives lines
145
+ * from the stored documents, off the test run. Converting to lcov in
146
+ * the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
147
+ * service on a real project, inside the test run; a real project turned
148
+ * coverage off because of it, and the conversion went.
166
149
  */
167
150
  export declare function node(): CoverageAdapter;
168
- /** The VM-side tools dir, after the test override. */
169
- export declare function nodeCoverageToolsDir(): string;
170
- /** True when golden (or the override) ships `c8`. The daemon mounts the
171
- * directory into covered containers on the same check, so what the VM
172
- * has is what the container sees. */
173
- export declare function nodeCoverageToolsAvailable(): Promise<boolean>;
174
- /** The command `convertV8ReportsToLcov` runs in the container. From `/`,
175
- * so lcov paths come out relative to the root (made absolute after). */
176
- export declare function nodeCoverageConvertCommand(): string;
177
- /**
178
- * Convert the V8 dumps written since the previous capture to one lcov,
179
- * `node.lcov`. New dumps at the root of the dir are compacted to the
180
- * app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
181
- * reads the original sources from there when they are not on disk, the
182
- * usual shape of a multi-stage image) and moved into `.v8/`, which is
183
- * emptied first; `c8 report` merges that directory. So the lcov is this
184
- * capture's delta: what the live processes ran since their last take,
185
- * plus every process that exited since. No new dump ⇒ `TN:` alone,
186
- * nothing ran. The `SF:` paths come out relative to `/` and are made
187
- * absolute, so they read as container paths like every other tool's.
188
- */
189
- export declare function convertV8ReportsToLcov(ctx: CoverageCaptureContext): Promise<void>;
190
- /** `SF:` records relative to `/` (what `c8` run from `/` writes) made
191
- * absolute. An lcov with nothing in it becomes the empty report. */
192
- export declare function absoluteLcovPaths(lcov: string): string;
193
151
  /**
194
152
  * Ask every node process that holds a hook socket in `dir` for a dump.
195
153
  * A socket nobody answers (its process died without unlinking — SIGKILL,
@@ -197,27 +155,59 @@ export declare function absoluteLcovPaths(lcov: string): string;
197
155
  * answered with an error throws.
198
156
  */
199
157
  export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
158
+ /** Forget the branch memory (tests). */
159
+ export declare function resetNodeCoverageMemory(): void;
160
+ /** Remove from `dir` the reports an earlier capture shipped. */
161
+ export declare function removeShippedV8Reports(dir: string): Promise<void>;
200
162
  /** A script is the app's own when it is a file outside node_modules —
201
163
  * and not our hook, which every dump would otherwise carry. */
202
164
  export declare function isAppScriptUrl(url: string): boolean;
165
+ interface V8Range {
166
+ count?: unknown;
167
+ }
168
+ interface V8Function {
169
+ ranges?: V8Range[];
170
+ }
171
+ interface V8Script {
172
+ url?: unknown;
173
+ functions?: V8Function[];
174
+ }
175
+ /** True when any range of any function of the script has a count above
176
+ * zero: something in it ran since the previous take. A script whose
177
+ * every count is zero is one V8 still lists after a reset; it says
178
+ * nothing and is dropped. */
179
+ export declare function scriptExecuted(script: V8Script): boolean;
203
180
  /**
204
- * Compact one V8 coverage document to the app's own scripts. What Node
205
- * writes is everything the process loaded: `node:` internals, every
181
+ * Compact one V8 coverage document to what the derivation needs. What
182
+ * Node writes is everything the process loaded: `node:` internals, every
206
183
  * `node_modules` file, and — under `--enable-source-maps` — a
207
184
  * `source-map-cache` with each file's full map **and its sources**,
208
185
  * repeated in every dump. Measured on a real project: a 12–20 MiB dump
209
186
  * per capture, of which the app's own coverage was under 0.5 MiB, and a
210
187
  * suite that hit the 64 MiB cap on its third test. Kept: `file://`
211
- * scripts outside `node_modules`, the map entries of exactly those
212
- * scripts, minus `sourcesContent` (the sources are the repo) unless
213
- * `keepSourcesContent` — the lcov conversion reads original sources from
214
- * there. Still a V8 document — nothing is converted here.
188
+ * scripts outside `node_modules` in which something ran, and the map
189
+ * entries of exactly those scripts, minus `sourcesContent` (the sources
190
+ * are the repo). Each kept map entry carries its content hash under
191
+ * {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
192
+ * URL — the map went out with an earlier dump on this branch — a stub
193
+ * `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
194
+ * the map in an earlier capture of the branch. `shipped` is updated in
195
+ * place. Still a V8 document — nothing is converted here.
196
+ */
197
+ export declare function compactV8Document(doc: Record<string, unknown>, shipped?: Map<string, string>): Record<string, unknown>;
198
+ /** What {@link prepareV8Reports} did, for the capture's log line. */
199
+ export interface PrepareV8Stats {
200
+ dumps: number;
201
+ scripts: number;
202
+ bytesIn: number;
203
+ bytesOut: number;
204
+ }
205
+ /**
206
+ * Compact, in place, every `coverage-*.json` in `dir` that no earlier
207
+ * capture shipped, and mark it shipped. A dump mid-write (not yet valid
208
+ * JSON) is left for the next capture.
215
209
  */
216
- export declare function compactV8Document(doc: Record<string, unknown>, opts?: {
217
- keepSourcesContent?: boolean;
218
- }): Record<string, unknown>;
219
- /** Compact every not-yet-compacted V8 document in `dir`, in place. */
220
- export declare function compactV8Reports(dir: string): Promise<void>;
210
+ export declare function prepareV8Reports(dir: string): Promise<PrepareV8Stats>;
221
211
  /**
222
212
  * Coverage for the frontend a service serves. The code runs in the guest
223
213
  * browser — spectest's own process — so no report can be written by the
package/dist/coverage.js CHANGED
@@ -14,11 +14,10 @@
14
14
  //
15
15
  // After every adapter ran, the harness reads the directory the same way
16
16
  // it does for `coverage: true`: at least one well-formed report (lcov or
17
- // V8 JSON), shipped verbatim. The control plane derives what it needs
18
- // from the stored bytes. One adapter converts: `node()` turns the V8
19
- // dumps into lcov inside the container (with `c8`, which golden ships),
20
- // because the conversion needs the sources and the source maps, which
21
- // only the container has.
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.
22
21
  //
23
22
  // Imported from `@specific.dev/spectest/coverage`. The three shipped
24
23
  // adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
@@ -27,6 +26,7 @@
27
26
  //
28
27
  // import * as coverage from "@specific.dev/spectest/coverage";
29
28
  // coverage: { adapters: [coverage.node(), coverage.browser()] }
29
+ import { createHash } from "node:crypto";
30
30
  import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
31
31
  import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
32
32
  export { COVERAGE_CONTAINER_DIR };
@@ -37,22 +37,11 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
37
37
  /** Prefix of the per-process sockets the node hook answers on, relative
38
38
  * to the coverage dir: `.ctl-<pid>`. */
39
39
  export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
40
- /** Where golden keeps the conversion tools (`c8` and its dependencies),
41
- * seen from the VM. `SPECTEST_COVERAGE_TOOLS_DIR` overrides it (tests).
42
- * The daemon bind-mounts it read-only into every covered container at
43
- * {@link NODE_COVERAGE_TOOLS_CONTAINER_DIR} when it exists. */
44
- export const NODE_COVERAGE_TOOLS_DIR = "/opt/spectest/coverage-tools";
45
- /** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
46
- export const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
47
- /** The `c8` entry point, relative to the tools dir. */
48
- export const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
49
- /** The hidden subdirectory of the coverage dir where `node()` parks the
50
- * V8 dumps it converts at one capture. Hidden, so the harness never
51
- * ships them: the lcov is the report. Emptied at every capture — the
52
- * lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
53
- export const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
54
- /** The lcov `node()` writes: the dumps of this capture, merged. */
55
- export const NODE_COVERAGE_LCOV = "node.lcov";
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";
56
45
  /** The `reports` mode of a `coverage` value, for the reports no adapter
57
46
  * wrote (the program's own, a `command`'s). */
58
47
  export function coverageReportsMode(cov) {
@@ -193,23 +182,17 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
193
182
  * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
194
183
  * `tsx`). Nothing for the app to write.
195
184
  *
196
- * At capture the dumps are **converted to lcov inside the container**
197
- * with `c8` (`c8 report`), and `node.lcov` is the one report that
198
- * ships. It holds **what ran since the previous capture**: V8 resets its
199
- * counters at every `takeCoverage()`, so a live server's dump after a
200
- * test is that test's own execution — the boot dump (everything loaded,
201
- * which at file level is everything "ran") lands in the bring-up
202
- * capture and nowhere else. A run's total is the union over its cases
203
- * on the server; a test's set is the test's own. Merging every dump on
204
- * the branch instead was tried first and would have made a long-lived
205
- * server's per-test set the boot set, every time. The conversion runs
206
- * in the container, not in the harness, because `v8-to-istanbul` needs
207
- * the script text (for byte offsets → lines) and the source maps
208
- * (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
209
- * and only the container has them. `c8` comes from golden
210
- * ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
211
- * documents ship instead, compacted ({@link compactV8Document}), as they
212
- * did before SDK 0.60.
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. Nothing is converted in the VM.
188
+ * A dump is a **delta** by construction: V8 resets its counters at every
189
+ * `takeCoverage()`, so a live server's dump after a test is that test's
190
+ * own execution, and the boot dump (everything loaded) lands in the
191
+ * bring-up capture and nowhere else. The control plane derives lines
192
+ * from the stored documents, off the test run. Converting to lcov in
193
+ * the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
194
+ * service on a real project, inside the test run; a real project turned
195
+ * coverage off because of it, and the conversion went.
213
196
  */
214
197
  export function node() {
215
198
  return {
@@ -234,114 +217,25 @@ export function node() {
234
217
  // rather than failed: an empty report is a report. A server whose
235
218
  // hook never loaded (NODE_OPTIONS not reaching it) then shows as
236
219
  // empty reports after bring-up, which the boot log warns about.
237
- await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
238
- if (await nodeCoverageToolsAvailable()) {
239
- await convertV8ReportsToLcov(ctx);
240
- return;
241
- }
242
- await compactV8Reports(ctx.reportDir);
243
- if (!(await hasV8Reports(ctx.reportDir))) {
220
+ //
221
+ // Each capture ships only its own dumps: what an earlier capture on
222
+ // this branch shipped is removed first (module memory, forks with
223
+ // the environment).
224
+ const t0 = performance.now();
225
+ await removeShippedV8Reports(ctx.reportDir);
226
+ const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
227
+ const t1 = performance.now();
228
+ const stats = await prepareV8Reports(ctx.reportDir);
229
+ if (stats.dumps === 0) {
244
230
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
231
+ SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
245
232
  }
233
+ const t2 = performance.now();
234
+ console.log(`[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
235
+ `${stats.dumps} dump(s), ${stats.scripts} script(s), ${stats.bytesIn} → ${stats.bytesOut} bytes compacted in ${Math.round(t2 - t1)} ms`);
246
236
  },
247
237
  };
248
238
  }
249
- /** The VM-side tools dir, after the test override. */
250
- export function nodeCoverageToolsDir() {
251
- return process.env.SPECTEST_COVERAGE_TOOLS_DIR || NODE_COVERAGE_TOOLS_DIR;
252
- }
253
- /** True when golden (or the override) ships `c8`. The daemon mounts the
254
- * directory into covered containers on the same check, so what the VM
255
- * has is what the container sees. */
256
- export async function nodeCoverageToolsAvailable() {
257
- const fs = await import("node:fs/promises");
258
- const path = await import("node:path");
259
- try {
260
- await fs.access(path.join(nodeCoverageToolsDir(), NODE_COVERAGE_C8_BIN));
261
- return true;
262
- }
263
- catch {
264
- return false;
265
- }
266
- }
267
- /** The command `convertV8ReportsToLcov` runs in the container. From `/`,
268
- * so lcov paths come out relative to the root (made absolute after). */
269
- export function nodeCoverageConvertCommand() {
270
- const c8 = `${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN}`;
271
- const dumps = `${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR}`;
272
- const out = `${COVERAGE_CONTAINER_DIR}/.c8`;
273
- // `env -u`: the converter is a node process in a container whose env
274
- // carries NODE_V8_COVERAGE and the `--require` hook, so without this it
275
- // would load the hook and write a dump of itself at exit — a raw V8
276
- // document at the root of the dir, shipped as a report (seen on the
277
- // first run of hello-node under 0.60).
278
- return `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${c8} report --temp-directory ${dumps} --reporter=lcovonly --reports-dir ${out}`;
279
- }
280
- /**
281
- * Convert the V8 dumps written since the previous capture to one lcov,
282
- * `node.lcov`. New dumps at the root of the dir are compacted to the
283
- * app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
284
- * reads the original sources from there when they are not on disk, the
285
- * usual shape of a multi-stage image) and moved into `.v8/`, which is
286
- * emptied first; `c8 report` merges that directory. So the lcov is this
287
- * capture's delta: what the live processes ran since their last take,
288
- * plus every process that exited since. No new dump ⇒ `TN:` alone,
289
- * nothing ran. The `SF:` paths come out relative to `/` and are made
290
- * absolute, so they read as container paths like every other tool's.
291
- */
292
- export async function convertV8ReportsToLcov(ctx) {
293
- const fs = await import("node:fs/promises");
294
- const path = await import("node:path");
295
- const dumpsDir = path.join(ctx.reportDir, NODE_COVERAGE_DUMPS_SUBDIR);
296
- await fs.rm(dumpsDir, { recursive: true, force: true });
297
- await fs.mkdir(dumpsDir, { recursive: true });
298
- await fs.chmod(dumpsDir, 0o755);
299
- let dumps = 0;
300
- for (const name of await fs.readdir(ctx.reportDir)) {
301
- if (!name.startsWith("coverage-") || !name.endsWith(".json"))
302
- continue;
303
- const file = path.join(ctx.reportDir, name);
304
- if (name === NODE_COVERAGE_EMPTY_REPORT) {
305
- await fs.unlink(file).catch(() => { }); // a pre-0.60 placeholder
306
- continue;
307
- }
308
- let doc;
309
- try {
310
- doc = JSON.parse(await fs.readFile(file, "utf8"));
311
- }
312
- catch {
313
- continue; // a dump mid-write; the next capture takes it
314
- }
315
- if (!Array.isArray(doc.result))
316
- continue;
317
- const target = path.join(dumpsDir, name);
318
- await fs.writeFile(`${target}.tmp`, JSON.stringify(compactV8Document(doc, { keepSourcesContent: true })));
319
- await fs.chmod(`${target}.tmp`, 0o644);
320
- await fs.rename(`${target}.tmp`, target);
321
- await fs.unlink(file);
322
- dumps++;
323
- }
324
- if (dumps === 0) {
325
- await ctx.writeReport(NODE_COVERAGE_LCOV, "TN:\n");
326
- return;
327
- }
328
- await ctx.exec(nodeCoverageConvertCommand());
329
- let lcov;
330
- try {
331
- lcov = await fs.readFile(path.join(ctx.reportDir, ".c8", "lcov.info"), "utf8");
332
- }
333
- catch (err) {
334
- throw new Error(`c8 wrote no lcov.info: ${err instanceof Error ? err.message : String(err)}`);
335
- }
336
- await ctx.writeReport(NODE_COVERAGE_LCOV, absoluteLcovPaths(lcov));
337
- }
338
- /** `SF:` records relative to `/` (what `c8` run from `/` writes) made
339
- * absolute. An lcov with nothing in it becomes the empty report. */
340
- export function absoluteLcovPaths(lcov) {
341
- if (lcov.trim().length === 0)
342
- return "TN:\n";
343
- return lcov.replace(/^SF:(?!\/)/gm, "SF:/");
344
- }
345
239
  /**
346
240
  * Ask every node process that holds a hook socket in `dir` for a dump.
347
241
  * A socket nobody answers (its process died without unlinking — SIGKILL,
@@ -396,20 +290,31 @@ export async function dumpAllNodeProcesses(dir, signal) {
396
290
  }
397
291
  return live;
398
292
  }
399
- /** True when `dir` holds at least one `coverage-*.json` other than the
400
- * empty placeholder. */
401
- async function hasV8Reports(dir) {
293
+ /** Report files shipped at an earlier capture on this branch, by host
294
+ * path. Module memory: forks with the environment, so a forked child
295
+ * removes its ancestors' reports at its first capture and ships only
296
+ * its own. */
297
+ const SHIPPED_V8_REPORTS = new Set();
298
+ /** Source-map entries shipped earlier on this branch: script URL → hash
299
+ * of the entry. Module memory, forks with the environment. A later dump
300
+ * of the same script carries a stub that refers to the hash instead of
301
+ * the map again. */
302
+ const SHIPPED_SOURCE_MAPS = new Map();
303
+ /** Forget the branch memory (tests). */
304
+ export function resetNodeCoverageMemory() {
305
+ SHIPPED_V8_REPORTS.clear();
306
+ SHIPPED_SOURCE_MAPS.clear();
307
+ }
308
+ /** Remove from `dir` the reports an earlier capture shipped. */
309
+ export async function removeShippedV8Reports(dir) {
402
310
  const fs = await import("node:fs/promises");
403
- try {
404
- return (await fs.readdir(dir)).some((n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT);
405
- }
406
- catch {
407
- return false;
311
+ for (const file of [...SHIPPED_V8_REPORTS]) {
312
+ if (!file.startsWith(dir + "/"))
313
+ continue;
314
+ await fs.unlink(file).catch(() => { });
315
+ SHIPPED_V8_REPORTS.delete(file);
408
316
  }
409
317
  }
410
- /** V8 files already compacted, by path. Module memory: forks with the
411
- * environment, so a child never re-parses its ancestors' dumps. */
412
- const COMPACTED_V8_REPORTS = new Set();
413
318
  /** A script is the app's own when it is a file outside node_modules —
414
319
  * and not our hook, which every dump would otherwise carry. */
415
320
  export function isAppScriptUrl(url) {
@@ -420,22 +325,34 @@ export function isAppScriptUrl(url) {
420
325
  !url.includes("/.cache/") &&
421
326
  !url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
422
327
  }
328
+ /** True when any range of any function of the script has a count above
329
+ * zero: something in it ran since the previous take. A script whose
330
+ * every count is zero is one V8 still lists after a reset; it says
331
+ * nothing and is dropped. */
332
+ export function scriptExecuted(script) {
333
+ const fns = Array.isArray(script.functions) ? script.functions : [];
334
+ return fns.some((f) => Array.isArray(f.ranges) && f.ranges.some((r) => typeof r.count === "number" && r.count > 0));
335
+ }
423
336
  /**
424
- * Compact one V8 coverage document to the app's own scripts. What Node
425
- * writes is everything the process loaded: `node:` internals, every
337
+ * Compact one V8 coverage document to what the derivation needs. What
338
+ * Node writes is everything the process loaded: `node:` internals, every
426
339
  * `node_modules` file, and — under `--enable-source-maps` — a
427
340
  * `source-map-cache` with each file's full map **and its sources**,
428
341
  * repeated in every dump. Measured on a real project: a 12–20 MiB dump
429
342
  * per capture, of which the app's own coverage was under 0.5 MiB, and a
430
343
  * suite that hit the 64 MiB cap on its third test. Kept: `file://`
431
- * scripts outside `node_modules`, the map entries of exactly those
432
- * scripts, minus `sourcesContent` (the sources are the repo) unless
433
- * `keepSourcesContent` — the lcov conversion reads original sources from
434
- * there. Still a V8 document — nothing is converted here.
344
+ * scripts outside `node_modules` in which something ran, and the map
345
+ * entries of exactly those scripts, minus `sourcesContent` (the sources
346
+ * are the repo). Each kept map entry carries its content hash under
347
+ * {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
348
+ * URL — the map went out with an earlier dump on this branch — a stub
349
+ * `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
350
+ * the map in an earlier capture of the branch. `shipped` is updated in
351
+ * place. Still a V8 document — nothing is converted here.
435
352
  */
436
- export function compactV8Document(doc, opts = {}) {
353
+ export function compactV8Document(doc, shipped) {
437
354
  const result = Array.isArray(doc.result) ? doc.result : [];
438
- const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
355
+ const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url) && scriptExecuted(s));
439
356
  const out = { ...doc, result: kept };
440
357
  const cache = doc["source-map-cache"];
441
358
  if (cache && typeof cache === "object") {
@@ -445,11 +362,20 @@ export function compactV8Document(doc, opts = {}) {
445
362
  if (!urls.has(url) || !entry || typeof entry !== "object")
446
363
  continue;
447
364
  const e = { ...entry };
448
- if (!opts.keepSourcesContent && e.data && typeof e.data === "object") {
365
+ if (e.data && typeof e.data === "object") {
449
366
  const { sourcesContent: _dropped, ...data } = e.data;
450
367
  e.data = data;
451
368
  }
452
- slim[url] = e;
369
+ delete e[SOURCE_MAP_HASH_KEY];
370
+ delete e[SOURCE_MAP_REF_KEY];
371
+ const hash = "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
372
+ if (shipped?.get(url) === hash) {
373
+ slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
374
+ }
375
+ else {
376
+ slim[url] = { ...e, [SOURCE_MAP_HASH_KEY]: hash };
377
+ shipped?.set(url, hash);
378
+ }
453
379
  }
454
380
  if (Object.keys(slim).length > 0)
455
381
  out["source-map-cache"] = slim;
@@ -458,37 +384,50 @@ export function compactV8Document(doc, opts = {}) {
458
384
  }
459
385
  return out;
460
386
  }
461
- /** Compact every not-yet-compacted V8 document in `dir`, in place. */
462
- export async function compactV8Reports(dir) {
387
+ /**
388
+ * Compact, in place, every `coverage-*.json` in `dir` that no earlier
389
+ * capture shipped, and mark it shipped. A dump mid-write (not yet valid
390
+ * JSON) is left for the next capture.
391
+ */
392
+ export async function prepareV8Reports(dir) {
463
393
  const fs = await import("node:fs/promises");
464
394
  const path = await import("node:path");
395
+ const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0 };
465
396
  let names;
466
397
  try {
467
398
  names = await fs.readdir(dir);
468
399
  }
469
400
  catch {
470
- return;
401
+ return stats;
471
402
  }
472
- for (const name of names) {
473
- if (!name.startsWith("coverage-") || !name.endsWith(".json"))
403
+ for (const name of names.sort()) {
404
+ if (!name.startsWith("coverage-") || !name.endsWith(".json") || name === NODE_COVERAGE_EMPTY_REPORT)
474
405
  continue;
475
406
  const file = path.join(dir, name);
476
- if (COMPACTED_V8_REPORTS.has(file))
407
+ if (SHIPPED_V8_REPORTS.has(file))
477
408
  continue;
409
+ let text;
478
410
  let doc;
479
411
  try {
480
- doc = JSON.parse(await fs.readFile(file, "utf8"));
412
+ text = await fs.readFile(file, "utf8");
413
+ doc = JSON.parse(text);
481
414
  }
482
415
  catch {
483
416
  continue; // a dump mid-write, or not ours; the read path judges it
484
417
  }
485
418
  if (!Array.isArray(doc.result))
486
419
  continue;
420
+ const out = JSON.stringify(compactV8Document(doc, SHIPPED_SOURCE_MAPS));
487
421
  const tmp = path.join(dir, `.${name}.compact`);
488
- await fs.writeFile(tmp, JSON.stringify(compactV8Document(doc)));
422
+ await fs.writeFile(tmp, out);
489
423
  await fs.rename(tmp, file);
490
- COMPACTED_V8_REPORTS.add(file);
424
+ SHIPPED_V8_REPORTS.add(file);
425
+ stats.dumps++;
426
+ stats.scripts += doc.result.length;
427
+ stats.bytesIn += text.length;
428
+ stats.bytesOut += out.length;
491
429
  }
430
+ return stats;
492
431
  }
493
432
  // ── browser ───────────────────────────────────────────────────────
494
433
  /**