@specific.dev/spectest 0.59.4 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import type { CDPSession } from "playwright-core";
2
+ import { type FileCounts } from "./harness/browser-coverage.js";
2
3
  export interface BrowserCoverageConfig {
3
4
  /** Which covered service (if any) serves scripts from this host. */
4
5
  serviceForHost(host: string): string | undefined;
@@ -16,5 +17,15 @@ export declare function attachBrowserCoverage(cdp: CDPSession): Promise<void>;
16
17
  * take, nothing is lost.
17
18
  */
18
19
  export declare function harvestBrowserCoverage(cdp: CDPSession): Promise<void>;
19
- /** One lcov document per service that has any browser coverage. */
20
+ /** Fold one script's per-file counts into a service's totals. */
21
+ export declare function foldBrowserCoverage(service: string, files: FileCounts): void;
22
+ /** Harvest every live session, so a capture sees the counters of code
23
+ * that ran since the last browser op. */
24
+ export declare function harvestAllBrowserCoverage(): Promise<void>;
25
+ /** One lcov document per service that has any browser coverage — and
26
+ * the totals are cleared, so the next capture holds only what runs
27
+ * from now on. Module memory forks with the environment, so a forked
28
+ * child starts from its parent's last capture, empty. */
29
+ export declare function takeBrowserCoverageReports(): Map<string, string>;
30
+ /** The current totals, without clearing them (tests). */
20
31
  export declare function browserCoverageReports(): Map<string, string>;
@@ -32,6 +32,9 @@ export function browserCoverageActive() {
32
32
  /** service → file → line → count, summed over every harvest. */
33
33
  const TOTALS = new Map();
34
34
  const COLLECTORS = new WeakMap();
35
+ /** The collectors of every session attached so far, for the harvest at
36
+ * capture; a closed session's take fails and is dropped there. */
37
+ const LIVE_COLLECTORS = new Set();
35
38
  /** Decoded maps by URL (or content hash for inline `data:` maps). */
36
39
  const SOURCE_MAPS = new Map();
37
40
  const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
@@ -42,6 +45,7 @@ export async function attachBrowserCoverage(cdp) {
42
45
  return;
43
46
  const collector = { cdp, scripts: new Map(), inFlight: null };
44
47
  COLLECTORS.set(cdp, collector);
48
+ LIVE_COLLECTORS.add(collector);
45
49
  cdp.on("Debugger.scriptParsed", (ev) => {
46
50
  if (!ev.url)
47
51
  return;
@@ -72,7 +76,14 @@ export async function harvestBrowserCoverage(cdp) {
72
76
  return c.inFlight;
73
77
  }
74
78
  async function harvestInner(c, config) {
75
- const { result } = (await c.cdp.send("Profiler.takePreciseCoverage"));
79
+ let result;
80
+ try {
81
+ ({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")));
82
+ }
83
+ catch (err) {
84
+ LIVE_COLLECTORS.delete(c); // the session is gone
85
+ throw err;
86
+ }
76
87
  for (const script of result) {
77
88
  const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
78
89
  if (!info)
@@ -111,14 +122,18 @@ async function harvestInner(c, config) {
111
122
  const files = info.map
112
123
  ? mapToOriginal(info.map, generated)
113
124
  : new Map([[url.pathname, generated]]);
114
- let totals = TOTALS.get(service);
115
- if (!totals) {
116
- totals = new Map();
117
- TOTALS.set(service, totals);
118
- }
119
- mergeFileCounts(totals, files);
125
+ foldBrowserCoverage(service, files);
120
126
  }
121
127
  }
128
+ /** Fold one script's per-file counts into a service's totals. */
129
+ export function foldBrowserCoverage(service, files) {
130
+ let totals = TOTALS.get(service);
131
+ if (!totals) {
132
+ totals = new Map();
133
+ TOTALS.set(service, totals);
134
+ }
135
+ mergeFileCounts(totals, files);
136
+ }
122
137
  /** Resolve, fetch (or inline-decode) and decode a script's source map. */
123
138
  async function sourceMapFor(info, scriptUrl) {
124
139
  let ref = info.sourceMapURL;
@@ -176,7 +191,25 @@ function parseDataUrl(ref) {
176
191
  return null;
177
192
  }
178
193
  }
179
- /** One lcov document per service that has any browser coverage. */
194
+ /** Harvest every live session, so a capture sees the counters of code
195
+ * that ran since the last browser op. */
196
+ export async function harvestAllBrowserCoverage() {
197
+ await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
198
+ }
199
+ /** One lcov document per service that has any browser coverage — and
200
+ * the totals are cleared, so the next capture holds only what runs
201
+ * from now on. Module memory forks with the environment, so a forked
202
+ * child starts from its parent's last capture, empty. */
203
+ export function takeBrowserCoverageReports() {
204
+ const out = new Map();
205
+ for (const [service, files] of TOTALS) {
206
+ if (files.size > 0)
207
+ out.set(service, lcovDocument(files));
208
+ }
209
+ TOTALS.clear();
210
+ return out;
211
+ }
212
+ /** The current totals, without clearing them (tests). */
180
213
  export function browserCoverageReports() {
181
214
  const out = new Map();
182
215
  for (const [service, files] of TOTALS) {
@@ -1,6 +1,6 @@
1
1
  import type { ServiceConfig } from "./index.js";
2
- import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
3
- export { COVERAGE_CONTAINER_DIR };
2
+ import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
3
+ export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
4
4
  /** Where `node()` mounts its hook inside the container. */
5
5
  export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
6
6
  /** The report `node()` writes when nothing has run yet on a branch. */
@@ -8,6 +8,22 @@ 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
27
  /** What `configure` learns about the service it rewrites. */
12
28
  export interface CoverageConfigureInfo {
13
29
  /** The services-map key. */
@@ -54,6 +70,15 @@ export interface CoverageCaptureContext {
54
70
  export interface CoverageAdapter {
55
71
  /** Names the adapter in errors (`coverage adapter "node" on service …`). */
56
72
  name: string;
73
+ /**
74
+ * How the reports this adapter writes (through `ctx.writeReport`)
75
+ * count: `"delta"` — what ran since the previous capture, the tool
76
+ * resets its own counters; `"cumulative"` (the default) — since
77
+ * process start, and spectest subtracts the previous capture. Reports
78
+ * the adapter makes appear some other way (`command`) follow the
79
+ * service's own `reports`.
80
+ */
81
+ reports?: CoverageReportsMode;
57
82
  /**
58
83
  * Rewrite the service at config time. Must be pure and deterministic
59
84
  * (the result is hashed into the warm-template key) and must **append**
@@ -65,11 +90,16 @@ export interface CoverageAdapter {
65
90
  /** Make the report appear in the directory. A throw fails the test. */
66
91
  capture?(ctx: CoverageCaptureContext): void | Promise<void>;
67
92
  }
68
- /** `ServiceConfig.coverage`: the program writes its own reports, or a
69
- * list of adapters gets them out. */
93
+ /** `ServiceConfig.coverage`: the program writes its own reports (`true`),
94
+ * or a list of adapters gets them out, and/or how the reports the
95
+ * program itself writes count (`reports`, default `"cumulative"`). */
70
96
  export type ServiceCoverage = true | {
71
- adapters: readonly CoverageAdapter[];
97
+ adapters?: readonly CoverageAdapter[];
98
+ reports?: CoverageReportsMode;
72
99
  };
100
+ /** The `reports` mode of a `coverage` value, for the reports no adapter
101
+ * wrote (the program's own, a `command`'s). */
102
+ export declare function coverageReportsMode(cov: ServiceCoverage | undefined): CoverageReportsMode;
73
103
  /** Type an inline adapter. Identity at runtime. */
74
104
  export declare function defineAdapter(adapter: CoverageAdapter): CoverageAdapter;
75
105
  /** Check one service's `coverage` field. Exported so the daemon applies
@@ -114,13 +144,52 @@ export declare const NODE_COVERAGE_HOOK: string;
114
144
  * container then writes V8 coverage JSON when it exits) and `--require`s
115
145
  * a hook that lets spectest ask every live node process for a dump at
116
146
  * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
117
- * `tsx`). Nothing
118
- * for the app to write. Each dump is compacted to the app's own scripts
119
- * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
120
- * `sourceMappingURL` next to the file) Node records the map in the
121
- * report, which is what maps a TypeScript service back to its sources.
147
+ * `tsx`). Nothing for the app to write.
148
+ *
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.
122
166
  */
123
167
  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;
124
193
  /**
125
194
  * Ask every node process that holds a hook socket in `dir` for a dump.
126
195
  * A socket nobody answers (its process died without unlinking — SIGKILL,
@@ -140,10 +209,13 @@ export declare function isAppScriptUrl(url: string): boolean;
140
209
  * per capture, of which the app's own coverage was under 0.5 MiB, and a
141
210
  * suite that hit the 64 MiB cap on its third test. Kept: `file://`
142
211
  * scripts outside `node_modules`, the map entries of exactly those
143
- * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
144
- * document — nothing is converted.
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.
145
215
  */
146
- export declare function compactV8Document(doc: Record<string, unknown>): Record<string, unknown>;
216
+ export declare function compactV8Document(doc: Record<string, unknown>, opts?: {
217
+ keepSourcesContent?: boolean;
218
+ }): Record<string, unknown>;
147
219
  /** Compact every not-yet-compacted V8 document in `dir`, in place. */
148
220
  export declare function compactV8Reports(dir: string): Promise<void>;
149
221
  /**
@@ -151,13 +223,18 @@ export declare function compactV8Reports(dir: string): Promise<void>;
151
223
  * browser — spectest's own process — so no report can be written by the
152
224
  * container; spectest collects V8 coverage for every script the browser
153
225
  * loads from this service's origins, maps it back to source through the
154
- * served source map, and writes one lcov per capture. Nothing ran yet on
155
- * this branch ⇒ an lcov with no records, which is a report saying so.
226
+ * served source map, and writes one lcov per capture (`browser.lcov`)
227
+ * holding what ran since the previous capture: every live session is
228
+ * harvested first, and the totals are taken and cleared. Nothing ran ⇒
229
+ * an lcov with no records, which is a report saying so.
156
230
  */
157
231
  export declare function browser(): CoverageAdapter;
158
232
  /**
159
233
  * Run `command` (via `sh -c`) in the container at every capture, so the
160
234
  * service writes a fresh report into `/spectest/coverage/`. The escape
161
- * hatch for a runtime with no shipped adapter.
235
+ * hatch for a runtime with no shipped adapter. The reports it makes
236
+ * appear follow the service's `reports` mode (cumulative by default —
237
+ * spectest subtracts the previous capture; `{ reports: "delta" }` when
238
+ * the tool resets its own counters).
162
239
  */
163
240
  export declare function command(command: string): CoverageAdapter;
package/dist/coverage.js 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()`
@@ -24,7 +27,7 @@
24
27
  //
25
28
  // import * as coverage from "@specific.dev/spectest/coverage";
26
29
  // coverage: { adapters: [coverage.node(), coverage.browser()] }
27
- import { browserCoverageReports } from "./browser-coverage.js";
30
+ import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
28
31
  import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
29
32
  export { COVERAGE_CONTAINER_DIR };
30
33
  /** Where `node()` mounts its hook inside the container. */
@@ -34,6 +37,27 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
34
37
  /** Prefix of the per-process sockets the node hook answers on, relative
35
38
  * to the coverage dir: `.ctl-<pid>`. */
36
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";
56
+ /** The `reports` mode of a `coverage` value, for the reports no adapter
57
+ * wrote (the program's own, a `command`'s). */
58
+ export function coverageReportsMode(cov) {
59
+ return typeof cov === "object" && cov !== null && cov.reports ? cov.reports : "cumulative";
60
+ }
37
61
  /** Type an inline adapter. Identity at runtime. */
38
62
  export function defineAdapter(adapter) {
39
63
  return adapter;
@@ -43,26 +67,34 @@ export function defineAdapter(adapter) {
43
67
  export function validateCoverage(service, cov) {
44
68
  if (cov === undefined || cov === true)
45
69
  return;
46
- const adapters = cov?.adapters;
47
- if (typeof cov === "object" && cov !== null && Array.isArray(adapters)) {
48
- for (const a of adapters) {
70
+ const obj = typeof cov === "object" && cov !== null ? cov : null;
71
+ const adapters = obj?.adapters;
72
+ const reports = obj?.reports;
73
+ const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports");
74
+ const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
75
+ const okAdapters = adapters === undefined || Array.isArray(adapters);
76
+ if (obj !== null && knownKeys && okReports && okAdapters && (adapters !== undefined || reports !== undefined)) {
77
+ for (const a of adapters ?? []) {
49
78
  const ok = typeof a === "object" &&
50
79
  a !== null &&
51
80
  typeof a.name === "string" &&
52
81
  a.name.length > 0 &&
82
+ (a.reports === undefined ||
83
+ a.reports === "delta" ||
84
+ a.reports === "cumulative") &&
53
85
  ["configure", "load", "capture"].every((k) => a[k] === undefined ||
54
86
  typeof a[k] === "function");
55
87
  if (!ok) {
56
- throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
88
+ throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
57
89
  }
58
90
  }
59
91
  return;
60
92
  }
61
- throw new Error(`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("…")\`)`);
93
+ throw new Error(`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" }\``);
62
94
  }
63
95
  /** The adapters of a `coverage` value (`true` has none). */
64
96
  export function coverageAdapters(cov) {
65
- return typeof cov === "object" && cov !== null ? cov.adapters : [];
97
+ return typeof cov === "object" && cov !== null ? (cov.adapters ?? []) : [];
66
98
  }
67
99
  /**
68
100
  * Apply every adapter's `configure` to a service, in list order. Called by
@@ -81,7 +113,8 @@ export function applyCoverageAdapters(key, svc) {
81
113
  if (a.configure)
82
114
  out = a.configure(out, { key });
83
115
  }
84
- const wire = { adapters: adapters.map((a) => withWireName(a)) };
116
+ const mode = svc.coverage.reports;
117
+ const wire = { adapters: adapters.map((a) => withWireName(a)), ...(mode ? { reports: mode } : {}) };
85
118
  return { ...out, coverage: wire };
86
119
  }
87
120
  function withWireName(a) {
@@ -158,15 +191,30 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
158
191
  * container then writes V8 coverage JSON when it exits) and `--require`s
159
192
  * a hook that lets spectest ask every live node process for a dump at
160
193
  * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
161
- * `tsx`). Nothing
162
- * for the app to write. Each dump is compacted to the app's own scripts
163
- * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
164
- * `sourceMappingURL` next to the file) Node records the map in the
165
- * report, which is what maps a TypeScript service back to its sources.
194
+ * `tsx`). Nothing for the app to write.
195
+ *
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.
166
213
  */
167
214
  export function node() {
168
215
  return {
169
216
  name: "node",
217
+ reports: "delta",
170
218
  configure(svc) {
171
219
  const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
172
220
  env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
@@ -181,12 +229,16 @@ export function node() {
181
229
  // here; a service whose node processes are short-lived (a CLI run
182
230
  // by `ctx.exec` from a `sleep infinity` container) has no socket to
183
231
  // answer and its coverage is the exit-time dumps already on disk.
184
- // So no live process is not an error. No report at all — nothing
185
- // has run yet on this branch — is recorded as an empty document
232
+ // So no live process is not an error. No dump at all — nothing
233
+ // has run yet on this branch — is recorded as an empty report
186
234
  // rather than failed: an empty report is a report. A server whose
187
235
  // hook never loaded (NODE_OPTIONS not reaching it) then shows as
188
236
  // empty reports after bring-up, which the boot log warns about.
189
237
  await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
238
+ if (await nodeCoverageToolsAvailable()) {
239
+ await convertV8ReportsToLcov(ctx);
240
+ return;
241
+ }
190
242
  await compactV8Reports(ctx.reportDir);
191
243
  if (!(await hasV8Reports(ctx.reportDir))) {
192
244
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
@@ -194,6 +246,102 @@ export function node() {
194
246
  },
195
247
  };
196
248
  }
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
+ }
197
345
  /**
198
346
  * Ask every node process that holds a hook socket in `dir` for a dump.
199
347
  * A socket nobody answers (its process died without unlinking — SIGKILL,
@@ -281,10 +429,11 @@ export function isAppScriptUrl(url) {
281
429
  * per capture, of which the app's own coverage was under 0.5 MiB, and a
282
430
  * suite that hit the 64 MiB cap on its third test. Kept: `file://`
283
431
  * scripts outside `node_modules`, the map entries of exactly those
284
- * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
285
- * document — nothing is converted.
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.
286
435
  */
287
- export function compactV8Document(doc) {
436
+ export function compactV8Document(doc, opts = {}) {
288
437
  const result = Array.isArray(doc.result) ? doc.result : [];
289
438
  const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
290
439
  const out = { ...doc, result: kept };
@@ -296,7 +445,7 @@ export function compactV8Document(doc) {
296
445
  if (!urls.has(url) || !entry || typeof entry !== "object")
297
446
  continue;
298
447
  const e = { ...entry };
299
- if (e.data && typeof e.data === "object") {
448
+ if (!opts.keepSourcesContent && e.data && typeof e.data === "object") {
300
449
  const { sourcesContent: _dropped, ...data } = e.data;
301
450
  e.data = data;
302
451
  }
@@ -347,17 +496,21 @@ export async function compactV8Reports(dir) {
347
496
  * browser — spectest's own process — so no report can be written by the
348
497
  * container; spectest collects V8 coverage for every script the browser
349
498
  * loads from this service's origins, maps it back to source through the
350
- * served source map, and writes one lcov per capture. Nothing ran yet on
351
- * this branch ⇒ an lcov with no records, which is a report saying so.
499
+ * served source map, and writes one lcov per capture (`browser.lcov`)
500
+ * holding what ran since the previous capture: every live session is
501
+ * harvested first, and the totals are taken and cleared. Nothing ran ⇒
502
+ * an lcov with no records, which is a report saying so.
352
503
  */
353
504
  export function browser() {
354
505
  return {
355
506
  name: "browser",
507
+ reports: "delta",
356
508
  load(ctx) {
357
509
  ctx.collectBrowserScripts();
358
510
  },
359
511
  async capture(ctx) {
360
- const lcov = browserCoverageReports().get(ctx.service) ?? "TN:\n";
512
+ await harvestAllBrowserCoverage();
513
+ const lcov = takeBrowserCoverageReports().get(ctx.service) ?? "TN:\n";
361
514
  await ctx.writeReport("browser.lcov", lcov);
362
515
  },
363
516
  };
@@ -366,7 +519,10 @@ export function browser() {
366
519
  /**
367
520
  * Run `command` (via `sh -c`) in the container at every capture, so the
368
521
  * service writes a fresh report into `/spectest/coverage/`. The escape
369
- * hatch for a runtime with no shipped adapter.
522
+ * hatch for a runtime with no shipped adapter. The reports it makes
523
+ * appear follow the service's `reports` mode (cumulative by default —
524
+ * spectest subtracts the previous capture; `{ reports: "delta" }` when
525
+ * the tool resets its own counters).
370
526
  */
371
527
  export function command(command) {
372
528
  if (typeof command !== "string" || command.trim().length === 0) {