@specific.dev/spectest 0.59.4 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/coverage.ts CHANGED
@@ -14,8 +14,11 @@
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. Adapters convert nothing — the control
18
- // plane derives what it needs from the stored bytes.
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.
19
22
  //
20
23
  // Imported from `@specific.dev/spectest/coverage`. The three shipped
21
24
  // adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
@@ -26,10 +29,10 @@
26
29
  // coverage: { adapters: [coverage.node(), coverage.browser()] }
27
30
 
28
31
  import type { ServiceConfig } from "./index.js";
29
- import { browserCoverageReports } from "./browser-coverage.js";
32
+ import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
30
33
 
31
- import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
32
- export { COVERAGE_CONTAINER_DIR };
34
+ import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
35
+ export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
33
36
 
34
37
  /** Where `node()` mounts its hook inside the container. */
35
38
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
@@ -41,6 +44,27 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
41
44
  * to the coverage dir: `.ctl-<pid>`. */
42
45
  export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
43
46
 
47
+ /** Where golden keeps the conversion tools (`c8` and its dependencies),
48
+ * seen from the VM. `SPECTEST_COVERAGE_TOOLS_DIR` overrides it (tests).
49
+ * The daemon bind-mounts it read-only into every covered container at
50
+ * {@link NODE_COVERAGE_TOOLS_CONTAINER_DIR} when it exists. */
51
+ export const NODE_COVERAGE_TOOLS_DIR = "/opt/spectest/coverage-tools";
52
+
53
+ /** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
54
+ export const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
55
+
56
+ /** The `c8` entry point, relative to the tools dir. */
57
+ export const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
58
+
59
+ /** The hidden subdirectory of the coverage dir where `node()` parks the
60
+ * V8 dumps it converts at one capture. Hidden, so the harness never
61
+ * ships them: the lcov is the report. Emptied at every capture — the
62
+ * lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
63
+ export const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
64
+
65
+ /** The lcov `node()` writes: the dumps of this capture, merged. */
66
+ export const NODE_COVERAGE_LCOV = "node.lcov";
67
+
44
68
  /** What `configure` learns about the service it rewrites. */
45
69
  export interface CoverageConfigureInfo {
46
70
  /** The services-map key. */
@@ -87,6 +111,15 @@ export interface CoverageCaptureContext {
87
111
  export interface CoverageAdapter {
88
112
  /** Names the adapter in errors (`coverage adapter "node" on service …`). */
89
113
  name: string;
114
+ /**
115
+ * How the reports this adapter writes (through `ctx.writeReport`)
116
+ * count: `"delta"` — what ran since the previous capture, the tool
117
+ * resets its own counters; `"cumulative"` (the default) — since
118
+ * process start, and spectest subtracts the previous capture. Reports
119
+ * the adapter makes appear some other way (`command`) follow the
120
+ * service's own `reports`.
121
+ */
122
+ reports?: CoverageReportsMode;
90
123
  /**
91
124
  * Rewrite the service at config time. Must be pure and deterministic
92
125
  * (the result is hashed into the warm-template key) and must **append**
@@ -99,9 +132,18 @@ export interface CoverageAdapter {
99
132
  capture?(ctx: CoverageCaptureContext): void | Promise<void>;
100
133
  }
101
134
 
102
- /** `ServiceConfig.coverage`: the program writes its own reports, or a
103
- * list of adapters gets them out. */
104
- export type ServiceCoverage = true | { adapters: readonly CoverageAdapter[] };
135
+ /** `ServiceConfig.coverage`: the program writes its own reports (`true`),
136
+ * or a list of adapters gets them out, and/or how the reports the
137
+ * program itself writes count (`reports`, default `"cumulative"`). */
138
+ export type ServiceCoverage =
139
+ | true
140
+ | { adapters?: readonly CoverageAdapter[]; reports?: CoverageReportsMode };
141
+
142
+ /** The `reports` mode of a `coverage` value, for the reports no adapter
143
+ * wrote (the program's own, a `command`'s). */
144
+ export function coverageReportsMode(cov: ServiceCoverage | undefined): CoverageReportsMode {
145
+ return typeof cov === "object" && cov !== null && cov.reports ? cov.reports : "cumulative";
146
+ }
105
147
 
106
148
  /** Type an inline adapter. Identity at runtime. */
107
149
  export function defineAdapter(adapter: CoverageAdapter): CoverageAdapter {
@@ -112,14 +154,22 @@ export function defineAdapter(adapter: CoverageAdapter): CoverageAdapter {
112
154
  * the same rule to a runtime `startService` spec. */
113
155
  export function validateCoverage(service: string, cov: unknown): void {
114
156
  if (cov === undefined || cov === true) return;
115
- const adapters = (cov as { adapters?: unknown } | null)?.adapters;
116
- if (typeof cov === "object" && cov !== null && Array.isArray(adapters)) {
117
- for (const a of adapters) {
157
+ const obj = typeof cov === "object" && cov !== null ? (cov as Record<string, unknown>) : null;
158
+ const adapters = obj?.adapters;
159
+ const reports = obj?.reports;
160
+ const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports");
161
+ const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
162
+ const okAdapters = adapters === undefined || Array.isArray(adapters);
163
+ if (obj !== null && knownKeys && okReports && okAdapters && (adapters !== undefined || reports !== undefined)) {
164
+ for (const a of (adapters as unknown[] | undefined) ?? []) {
118
165
  const ok =
119
166
  typeof a === "object" &&
120
167
  a !== null &&
121
168
  typeof (a as CoverageAdapter).name === "string" &&
122
169
  (a as CoverageAdapter).name.length > 0 &&
170
+ ((a as CoverageAdapter).reports === undefined ||
171
+ (a as CoverageAdapter).reports === "delta" ||
172
+ (a as CoverageAdapter).reports === "cumulative") &&
123
173
  ["configure", "load", "capture"].every(
124
174
  (k) =>
125
175
  (a as Record<string, unknown>)[k] === undefined ||
@@ -127,20 +177,20 @@ export function validateCoverage(service: string, cov: unknown): void {
127
177
  );
128
178
  if (!ok) {
129
179
  throw new Error(
130
- `service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
180
+ `service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
131
181
  );
132
182
  }
133
183
  }
134
184
  return;
135
185
  }
136
186
  throw new Error(
137
- `service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports) or \`{ adapters: [...] }\` from @specific.dev/spectest/coverage (\`node()\`, \`browser()\`, \`command("…")\`)`,
187
+ `service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports), \`{ adapters: [...] }\` from @specific.dev/spectest/coverage (\`node()\`, \`browser()\`, \`command("…")\`), and/or \`{ reports: "delta" | "cumulative" }\``,
138
188
  );
139
189
  }
140
190
 
141
191
  /** The adapters of a `coverage` value (`true` has none). */
142
192
  export function coverageAdapters(cov: ServiceCoverage | undefined): readonly CoverageAdapter[] {
143
- return typeof cov === "object" && cov !== null ? cov.adapters : [];
193
+ return typeof cov === "object" && cov !== null ? (cov.adapters ?? []) : [];
144
194
  }
145
195
 
146
196
  /**
@@ -158,7 +208,8 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
158
208
  for (const a of adapters) {
159
209
  if (a.configure) out = a.configure(out, { key });
160
210
  }
161
- const wire = { adapters: adapters.map((a) => withWireName(a)) };
211
+ const mode = (svc.coverage as { reports?: CoverageReportsMode }).reports;
212
+ const wire = { adapters: adapters.map((a) => withWireName(a)), ...(mode ? { reports: mode } : {}) };
162
213
  return { ...out, coverage: wire } as unknown as S;
163
214
  }
164
215
 
@@ -243,15 +294,30 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
243
294
  * container then writes V8 coverage JSON when it exits) and `--require`s
244
295
  * a hook that lets spectest ask every live node process for a dump at
245
296
  * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
246
- * `tsx`). Nothing
247
- * for the app to write. Each dump is compacted to the app's own scripts
248
- * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
249
- * `sourceMappingURL` next to the file) Node records the map in the
250
- * report, which is what maps a TypeScript service back to its sources.
297
+ * `tsx`). Nothing for the app to write.
298
+ *
299
+ * At capture the dumps are **converted to lcov inside the container**
300
+ * with `c8` (`c8 report`), and `node.lcov` is the one report that
301
+ * ships. It holds **what ran since the previous capture**: V8 resets its
302
+ * counters at every `takeCoverage()`, so a live server's dump after a
303
+ * test is that test's own execution — the boot dump (everything loaded,
304
+ * which at file level is everything "ran") lands in the bring-up
305
+ * capture and nowhere else. A run's total is the union over its cases
306
+ * on the server; a test's set is the test's own. Merging every dump on
307
+ * the branch instead was tried first and would have made a long-lived
308
+ * server's per-test set the boot set, every time. The conversion runs
309
+ * in the container, not in the harness, because `v8-to-istanbul` needs
310
+ * the script text (for byte offsets → lines) and the source maps
311
+ * (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
312
+ * and only the container has them. `c8` comes from golden
313
+ * ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
314
+ * documents ship instead, compacted ({@link compactV8Document}), as they
315
+ * did before SDK 0.60.
251
316
  */
252
317
  export function node(): CoverageAdapter {
253
318
  return {
254
319
  name: "node",
320
+ reports: "delta",
255
321
  configure(svc) {
256
322
  const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
257
323
  env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
@@ -266,12 +332,16 @@ export function node(): CoverageAdapter {
266
332
  // here; a service whose node processes are short-lived (a CLI run
267
333
  // by `ctx.exec` from a `sleep infinity` container) has no socket to
268
334
  // answer and its coverage is the exit-time dumps already on disk.
269
- // So no live process is not an error. No report at all — nothing
270
- // has run yet on this branch — is recorded as an empty document
335
+ // So no live process is not an error. No dump at all — nothing
336
+ // has run yet on this branch — is recorded as an empty report
271
337
  // rather than failed: an empty report is a report. A server whose
272
338
  // hook never loaded (NODE_OPTIONS not reaching it) then shows as
273
339
  // empty reports after bring-up, which the boot log warns about.
274
340
  await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
341
+ if (await nodeCoverageToolsAvailable()) {
342
+ await convertV8ReportsToLcov(ctx);
343
+ return;
344
+ }
275
345
  await compactV8Reports(ctx.reportDir);
276
346
  if (!(await hasV8Reports(ctx.reportDir))) {
277
347
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
@@ -280,6 +350,101 @@ export function node(): CoverageAdapter {
280
350
  };
281
351
  }
282
352
 
353
+ /** The VM-side tools dir, after the test override. */
354
+ export function nodeCoverageToolsDir(): string {
355
+ return process.env.SPECTEST_COVERAGE_TOOLS_DIR || NODE_COVERAGE_TOOLS_DIR;
356
+ }
357
+
358
+ /** True when golden (or the override) ships `c8`. The daemon mounts the
359
+ * directory into covered containers on the same check, so what the VM
360
+ * has is what the container sees. */
361
+ export async function nodeCoverageToolsAvailable(): Promise<boolean> {
362
+ const fs = await import("node:fs/promises");
363
+ const path = await import("node:path");
364
+ try {
365
+ await fs.access(path.join(nodeCoverageToolsDir(), NODE_COVERAGE_C8_BIN));
366
+ return true;
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+
372
+ /** The command `convertV8ReportsToLcov` runs in the container. From `/`,
373
+ * so lcov paths come out relative to the root (made absolute after). */
374
+ export function nodeCoverageConvertCommand(): string {
375
+ const c8 = `${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN}`;
376
+ const dumps = `${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR}`;
377
+ const out = `${COVERAGE_CONTAINER_DIR}/.c8`;
378
+ // `env -u`: the converter is a node process in a container whose env
379
+ // carries NODE_V8_COVERAGE and the `--require` hook, so without this it
380
+ // would load the hook and write a dump of itself at exit — a raw V8
381
+ // document at the root of the dir, shipped as a report (seen on the
382
+ // first run of hello-node under 0.60).
383
+ return `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${c8} report --temp-directory ${dumps} --reporter=lcovonly --reports-dir ${out}`;
384
+ }
385
+
386
+ /**
387
+ * Convert the V8 dumps written since the previous capture to one lcov,
388
+ * `node.lcov`. New dumps at the root of the dir are compacted to the
389
+ * app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
390
+ * reads the original sources from there when they are not on disk, the
391
+ * usual shape of a multi-stage image) and moved into `.v8/`, which is
392
+ * emptied first; `c8 report` merges that directory. So the lcov is this
393
+ * capture's delta: what the live processes ran since their last take,
394
+ * plus every process that exited since. No new dump ⇒ `TN:` alone,
395
+ * nothing ran. The `SF:` paths come out relative to `/` and are made
396
+ * absolute, so they read as container paths like every other tool's.
397
+ */
398
+ export async function convertV8ReportsToLcov(ctx: CoverageCaptureContext): Promise<void> {
399
+ const fs = await import("node:fs/promises");
400
+ const path = await import("node:path");
401
+ const dumpsDir = path.join(ctx.reportDir, NODE_COVERAGE_DUMPS_SUBDIR);
402
+ await fs.rm(dumpsDir, { recursive: true, force: true });
403
+ await fs.mkdir(dumpsDir, { recursive: true });
404
+ await fs.chmod(dumpsDir, 0o755);
405
+ let dumps = 0;
406
+ for (const name of await fs.readdir(ctx.reportDir)) {
407
+ if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
408
+ const file = path.join(ctx.reportDir, name);
409
+ if (name === NODE_COVERAGE_EMPTY_REPORT) {
410
+ await fs.unlink(file).catch(() => {}); // a pre-0.60 placeholder
411
+ continue;
412
+ }
413
+ let doc: Record<string, unknown>;
414
+ try {
415
+ doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
416
+ } catch {
417
+ continue; // a dump mid-write; the next capture takes it
418
+ }
419
+ if (!Array.isArray(doc.result)) continue;
420
+ const target = path.join(dumpsDir, name);
421
+ await fs.writeFile(`${target}.tmp`, JSON.stringify(compactV8Document(doc, { keepSourcesContent: true })));
422
+ await fs.chmod(`${target}.tmp`, 0o644);
423
+ await fs.rename(`${target}.tmp`, target);
424
+ await fs.unlink(file);
425
+ dumps++;
426
+ }
427
+ if (dumps === 0) {
428
+ await ctx.writeReport(NODE_COVERAGE_LCOV, "TN:\n");
429
+ return;
430
+ }
431
+ await ctx.exec(nodeCoverageConvertCommand());
432
+ let lcov: string;
433
+ try {
434
+ lcov = await fs.readFile(path.join(ctx.reportDir, ".c8", "lcov.info"), "utf8");
435
+ } catch (err) {
436
+ throw new Error(`c8 wrote no lcov.info: ${err instanceof Error ? err.message : String(err)}`);
437
+ }
438
+ await ctx.writeReport(NODE_COVERAGE_LCOV, absoluteLcovPaths(lcov));
439
+ }
440
+
441
+ /** `SF:` records relative to `/` (what `c8` run from `/` writes) made
442
+ * absolute. An lcov with nothing in it becomes the empty report. */
443
+ export function absoluteLcovPaths(lcov: string): string {
444
+ if (lcov.trim().length === 0) return "TN:\n";
445
+ return lcov.replace(/^SF:(?!\/)/gm, "SF:/");
446
+ }
447
+
283
448
  /**
284
449
  * Ask every node process that holds a hook socket in `dir` for a dump.
285
450
  * A socket nobody answers (its process died without unlinking — SIGKILL,
@@ -369,10 +534,14 @@ export function isAppScriptUrl(url: string): boolean {
369
534
  * per capture, of which the app's own coverage was under 0.5 MiB, and a
370
535
  * suite that hit the 64 MiB cap on its third test. Kept: `file://`
371
536
  * scripts outside `node_modules`, the map entries of exactly those
372
- * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
373
- * document — nothing is converted.
537
+ * scripts, minus `sourcesContent` (the sources are the repo) unless
538
+ * `keepSourcesContent` — the lcov conversion reads original sources from
539
+ * there. Still a V8 document — nothing is converted here.
374
540
  */
375
- export function compactV8Document(doc: Record<string, unknown>): Record<string, unknown> {
541
+ export function compactV8Document(
542
+ doc: Record<string, unknown>,
543
+ opts: { keepSourcesContent?: boolean } = {},
544
+ ): Record<string, unknown> {
376
545
  const result = Array.isArray(doc.result) ? (doc.result as { url?: unknown }[]) : [];
377
546
  const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
378
547
  const out: Record<string, unknown> = { ...doc, result: kept };
@@ -383,7 +552,7 @@ export function compactV8Document(doc: Record<string, unknown>): Record<string,
383
552
  for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
384
553
  if (!urls.has(url) || !entry || typeof entry !== "object") continue;
385
554
  const e = { ...(entry as Record<string, unknown>) };
386
- if (e.data && typeof e.data === "object") {
555
+ if (!opts.keepSourcesContent && e.data && typeof e.data === "object") {
387
556
  const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
388
557
  e.data = data;
389
558
  }
@@ -430,17 +599,21 @@ export async function compactV8Reports(dir: string): Promise<void> {
430
599
  * browser — spectest's own process — so no report can be written by the
431
600
  * container; spectest collects V8 coverage for every script the browser
432
601
  * loads from this service's origins, maps it back to source through the
433
- * served source map, and writes one lcov per capture. Nothing ran yet on
434
- * this branch ⇒ an lcov with no records, which is a report saying so.
602
+ * served source map, and writes one lcov per capture (`browser.lcov`)
603
+ * holding what ran since the previous capture: every live session is
604
+ * harvested first, and the totals are taken and cleared. Nothing ran ⇒
605
+ * an lcov with no records, which is a report saying so.
435
606
  */
436
607
  export function browser(): CoverageAdapter {
437
608
  return {
438
609
  name: "browser",
610
+ reports: "delta",
439
611
  load(ctx) {
440
612
  ctx.collectBrowserScripts();
441
613
  },
442
614
  async capture(ctx) {
443
- const lcov = browserCoverageReports().get(ctx.service) ?? "TN:\n";
615
+ await harvestAllBrowserCoverage();
616
+ const lcov = takeBrowserCoverageReports().get(ctx.service) ?? "TN:\n";
444
617
  await ctx.writeReport("browser.lcov", lcov);
445
618
  },
446
619
  };
@@ -451,7 +624,10 @@ export function browser(): CoverageAdapter {
451
624
  /**
452
625
  * Run `command` (via `sh -c`) in the container at every capture, so the
453
626
  * service writes a fresh report into `/spectest/coverage/`. The escape
454
- * hatch for a runtime with no shipped adapter.
627
+ * hatch for a runtime with no shipped adapter. The reports it makes
628
+ * appear follow the service's `reports` mode (cumulative by default —
629
+ * spectest subtracts the previous capture; `{ reports: "delta" }` when
630
+ * the tool resets its own counters).
455
631
  */
456
632
  export function command(command: string): CoverageAdapter {
457
633
  if (typeof command !== "string" || command.trim().length === 0) {
package/src/daemon.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  encodeCoverageBundle,
42
42
  isEmptyReport,
43
43
  readCoverageDir,
44
+ applyCoverageDelta,
44
45
  type CoverageBundleRef,
45
46
  type CoverageReport,
46
47
  type ServiceCoverageCapture,
@@ -49,7 +50,11 @@ import { configureBrowserCoverage } from "./browser-coverage.js";
49
50
  import {
50
51
  applyCoverageAdapters,
51
52
  coverageAdapters,
53
+ coverageReportsMode,
52
54
  validateCoverage,
55
+ nodeCoverageToolsAvailable,
56
+ nodeCoverageToolsDir,
57
+ NODE_COVERAGE_TOOLS_CONTAINER_DIR,
53
58
  type CoverageAdapter,
54
59
  type CoverageCaptureContext,
55
60
  } from "./coverage.js";
@@ -790,7 +795,16 @@ async function ensureCoverage(svc: NamedService): Promise<string[]> {
790
795
  const host = coverageHostDir(WORKSPACE, svc.name);
791
796
  await fs.mkdir(host, { recursive: true });
792
797
  await fs.chmod(host, 0o777);
793
- return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
798
+ const flags = [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
799
+ // The conversion tools golden ships (`c8` for `coverage.node()`),
800
+ // read-only, on the same existence check the adapter makes at capture
801
+ // — so the VM and the container agree on whether they are there. A
802
+ // plain `--volume`, never a config `volumes` entry: an absolute-source
803
+ // volume dir is listed in the delta-restore manifest and wiped.
804
+ if (await nodeCoverageToolsAvailable()) {
805
+ flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
806
+ }
807
+ return flags;
794
808
  }
795
809
 
796
810
  /** A `user`/`group` pair resolved to the numeric ids the kernel wants.
@@ -3667,8 +3681,14 @@ function rebuildBrowserCoverageResolver(): void {
3667
3681
  });
3668
3682
  }
3669
3683
 
3670
- /** The context one adapter's `capture` runs against. */
3671
- function coverageCaptureContext(svc: NamedService, signal: AbortSignal): CoverageCaptureContext {
3684
+ /** The context one adapter's `capture` runs against. Every report it
3685
+ * writes through `writeReport` is added to `written`, so the delta step
3686
+ * knows which files came from which adapter. */
3687
+ function coverageCaptureContext(
3688
+ svc: NamedService,
3689
+ signal: AbortSignal,
3690
+ written: Set<string>,
3691
+ ): CoverageCaptureContext {
3672
3692
  const reportDir = coverageHostDir(WORKSPACE, svc.name);
3673
3693
  return {
3674
3694
  service: svc.name,
@@ -3691,20 +3711,27 @@ function coverageCaptureContext(svc: NamedService, signal: AbortSignal): Coverag
3691
3711
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
3692
3712
  await fs.writeFile(tmp, content);
3693
3713
  await fs.rename(tmp, file);
3714
+ written.add(path.relative(reportDir, file));
3694
3715
  },
3695
3716
  };
3696
3717
  }
3697
3718
 
3698
3719
  /** Run one service's adapters in order under one budget. Returns the
3699
- * error to fail the capture with, naming the adapter. */
3700
- async function runCoverageAdapters(svc: NamedService): Promise<string | undefined> {
3720
+ * error to fail the capture with, naming the adapter, and the reports
3721
+ * written by adapters that declare `reports: "delta"`. */
3722
+ async function runCoverageAdapters(
3723
+ svc: NamedService,
3724
+ ): Promise<{ error?: string; deltaReports: Set<string> }> {
3725
+ const deltaReports = new Set<string>();
3701
3726
  const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
3702
- if (adapters.length === 0) return undefined;
3727
+ if (adapters.length === 0) return { deltaReports };
3703
3728
  const ac = new AbortController();
3704
3729
  const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
3705
- const ctx = coverageCaptureContext(svc, ac.signal);
3730
+ const written = new Set<string>();
3731
+ const ctx = coverageCaptureContext(svc, ac.signal, written);
3706
3732
  try {
3707
3733
  for (const a of adapters) {
3734
+ written.clear();
3708
3735
  try {
3709
3736
  await Promise.race([
3710
3737
  (a as Required<Pick<CoverageAdapter, "capture">>).capture(ctx),
@@ -3717,12 +3744,16 @@ async function runCoverageAdapters(svc: NamedService): Promise<string | undefine
3717
3744
  }),
3718
3745
  ]);
3719
3746
  } catch (err) {
3720
- return `coverage adapter "${a.name}" on service "${svc.name}" failed: ${
3721
- err instanceof Error ? err.message : String(err)
3722
- }`;
3747
+ return {
3748
+ error: `coverage adapter "${a.name}" on service "${svc.name}" failed: ${
3749
+ err instanceof Error ? err.message : String(err)
3750
+ }`,
3751
+ deltaReports,
3752
+ };
3723
3753
  }
3754
+ if (a.reports === "delta") for (const n of written) deltaReports.add(n);
3724
3755
  }
3725
- return undefined;
3756
+ return { deltaReports };
3726
3757
  } finally {
3727
3758
  clearTimeout(timer);
3728
3759
  }
@@ -3737,9 +3768,14 @@ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
3737
3768
  const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3738
3769
  return Promise.all(
3739
3770
  services.map(async (svc): Promise<ServiceCoverageCapture> => {
3740
- const error = await runCoverageAdapters(svc);
3771
+ const { error, deltaReports } = await runCoverageAdapters(svc);
3741
3772
  if (error) return { service: svc.name, reports: [], error };
3742
- return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3773
+ const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3774
+ // What ships is what ran during this capture, whatever the tool
3775
+ // counts: cumulative lcov is subtracted against the previous
3776
+ // capture (module memory, forks with the env); a delta adapter's
3777
+ // files and V8 JSON pass through.
3778
+ return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
3743
3779
  }),
3744
3780
  );
3745
3781
  }
@@ -131,3 +131,88 @@ test("bundle round-trips and the ref summarises it", () => {
131
131
  test("coverageHostDir sits under the workspace's .spectest", () => {
132
132
  expect(coverageHostDir("/workspace", "api")).toBe("/workspace/.spectest/coverage/api");
133
133
  });
134
+
135
+ import {
136
+ applyCoverageDelta,
137
+ parseLcovCounts,
138
+ renderLcovCounts,
139
+ resetCoverageDeltaMemory,
140
+ subtractLcovCounts,
141
+ } from "./coverage.js";
142
+
143
+ describe("coverage delta", () => {
144
+ const boot = "TN:\nSF:/app/a.py\nFN:1,main\nFNDA:1,main\nDA:1,1\nDA:2,1\nDA:3,0\nend_of_record\nSF:/app/b.py\nDA:1,5\nend_of_record\n";
145
+ const later = "TN:\nSF:/app/a.py\nFN:1,main\nFNDA:1,main\nDA:1,1\nDA:2,1\nDA:3,4\nend_of_record\nSF:/app/b.py\nDA:1,5\nend_of_record\nSF:/app/c.py\nDA:1,2\nend_of_record\n";
146
+
147
+ test("parse merges records for one path and reads DA/FNDA/FN", () => {
148
+ const c = parseLcovCounts("SF:/x\nDA:1,1\nend_of_record\nSF:/x\nDA:1,2\nFN:3,f\nFNDA:4,f\nend_of_record\nSF:\nDA:9,9\n");
149
+ expect([...c.keys()]).toEqual(["/x"]);
150
+ expect(c.get("/x")!.lines.get(1)).toBe(3);
151
+ expect(c.get("/x")!.functions.get("f")).toBe(4);
152
+ expect(c.get("/x")!.functionLines.get("f")).toBe(3);
153
+ });
154
+
155
+ test("subtract keeps only what ran since; a dropped counter is a restart; a new file counts whole", () => {
156
+ const d = subtractLcovCounts(parseLcovCounts(later), parseLcovCounts(boot));
157
+ expect([...d.keys()].sort()).toEqual(["/app/a.py", "/app/c.py"]); // b.py: nothing new
158
+ expect(d.get("/app/a.py")!.lines.get(3)).toBe(4);
159
+ expect(d.get("/app/a.py")!.lines.get(1)).toBe(0);
160
+ expect(d.get("/app/a.py")!.functions.get("main")).toBe(0);
161
+ expect(d.get("/app/c.py")!.lines.get(1)).toBe(2);
162
+ const names: string[] = [];
163
+ const restarted = subtractLcovCounts(parseLcovCounts("SF:/app/b.py\nDA:1,2\nend_of_record\n"), parseLcovCounts(boot), names);
164
+ expect(restarted.get("/app/b.py")!.lines.get(1)).toBe(2);
165
+ expect(names).toEqual(["/app/b.py"]);
166
+ // A restart is per record: one dropped counter means every count in
167
+ // that record stands, including a line that ran exactly as often
168
+ // again (the boot line: 1 before, 1 now — not 0). Other records in
169
+ // the same report still subtract.
170
+ const mixed = subtractLcovCounts(
171
+ parseLcovCounts("SF:/app/a.py\nFNDA:1,main\nDA:1,1\nDA:2,1\nDA:3,0\nend_of_record\nSF:/app/b.py\nDA:1,3\nend_of_record\n"),
172
+ parseLcovCounts("SF:/app/a.py\nFNDA:1,main\nDA:1,1\nDA:2,1\nDA:3,7\nend_of_record\nSF:/app/b.py\nDA:1,1\nend_of_record\n"),
173
+ );
174
+ expect(mixed.get("/app/a.py")!.lines.get(1)).toBe(1);
175
+ expect(mixed.get("/app/a.py")!.functions.get("main")).toBe(1);
176
+ expect(mixed.get("/app/b.py")!.lines.get(1)).toBe(2);
177
+ // A record with no counts at all: presence is its signal, new only once.
178
+ const bare = "SF:/app/bare.py\nend_of_record\n";
179
+ expect(subtractLcovCounts(parseLcovCounts(bare), new Map()).has("/app/bare.py")).toBe(true);
180
+ expect(subtractLcovCounts(parseLcovCounts(bare), parseLcovCounts(bare)).has("/app/bare.py")).toBe(false);
181
+ });
182
+
183
+ test("render round-trips and totals the header counts", () => {
184
+ const text = renderLcovCounts(subtractLcovCounts(parseLcovCounts(later), parseLcovCounts(boot)));
185
+ expect(text).toBe(
186
+ "TN:\nSF:/app/a.py\nFN:1,main\nFNDA:0,main\nFNF:1\nFNH:0\nDA:1,0\nDA:2,0\nDA:3,4\nLF:3\nLH:1\nend_of_record\nSF:/app/c.py\nFNF:0\nFNH:0\nDA:1,2\nLF:1\nLH:1\nend_of_record\n",
187
+ );
188
+ expect(parseLcovCounts(text).get("/app/a.py")!.lines.get(3)).toBe(4);
189
+ });
190
+
191
+ test("applyCoverageDelta: cumulative reports are subtracted per service and name; delta and V8 reports pass", () => {
192
+ resetCoverageDeltaMemory();
193
+ const first = applyCoverageDelta(
194
+ { service: "api", reports: [{ name: "cov.lcov", content: boot }, { name: "node.lcov", content: later }, { name: "coverage-1.json", content: '{"result":[]}' }] },
195
+ "cumulative",
196
+ new Set(["node.lcov"]),
197
+ );
198
+ // First capture: no previous, the whole boot set.
199
+ expect(first.reports[0]!.content).toContain("SF:/app/a.py");
200
+ expect(first.reports[0]!.content).toContain("SF:/app/b.py");
201
+ expect(first.reports[1]!.content).toBe(later); // delta adapter's own file, untouched
202
+ expect(first.reports[2]!.content).toBe('{"result":[]}');
203
+ const second = applyCoverageDelta({ service: "api", reports: [{ name: "cov.lcov", content: later }] }, "cumulative", new Set());
204
+ expect(second.reports[0]!.content).not.toContain("SF:/app/b.py");
205
+ expect(second.reports[0]!.content).toContain("DA:3,4");
206
+ // Nothing new: an lcov with no records.
207
+ const third = applyCoverageDelta({ service: "api", reports: [{ name: "cov.lcov", content: later }] }, "cumulative", new Set());
208
+ expect(third.reports[0]!.content).toBe("TN:\n");
209
+ // Another service has its own memory; a delta-mode service is never touched.
210
+ const other = applyCoverageDelta({ service: "web", reports: [{ name: "cov.lcov", content: later }] }, "cumulative", new Set());
211
+ expect(other.reports[0]!.content).toContain("SF:/app/b.py");
212
+ const asIs = applyCoverageDelta({ service: "api", reports: [{ name: "cov.lcov", content: later }] }, "delta", new Set());
213
+ expect(asIs.reports[0]!.content).toBe(later);
214
+ // An errored capture passes through.
215
+ const err = applyCoverageDelta({ service: "api", reports: [], error: "x" }, "cumulative", new Set());
216
+ expect(err.error).toBe("x");
217
+ });
218
+ });