@specific.dev/spectest 0.127.0 → 0.128.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.
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "bun:test";
2
+ import { promises as fs } from "node:fs";
3
+ import { existsSync } from "node:fs";
4
+ import { execFileSync } from "node:child_process";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { rust } from "./coverage-rust";
8
+ import { captureCoverageTarget } from "./harness/coverage-target";
9
+ import { encodeCoverageBundle } from "./harness/coverage";
10
+
11
+ async function run(args: string[], cwd?: string) {
12
+ const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" });
13
+ const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
14
+ if (code) throw new Error(`${args[0]}: ${stderr}`);
15
+ return stdout.trim();
16
+ }
17
+
18
+ const installedTools = (() => {
19
+ try {
20
+ const sysroot = execFileSync("rustc", ["--print", "sysroot"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
21
+ const host = execFileSync("rustc", ["-vV"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).match(/^host: (.+)$/m)![1];
22
+ const tools = path.join(sysroot, "lib/rustlib", host, "bin");
23
+ return existsSync(path.join(tools, "llvm-cov")) && existsSync(path.join(tools, "llvm-profdata")) ? tools : undefined;
24
+ } catch { return undefined; }
25
+ })();
26
+ test.skipIf(!installedTools)("Rust real LLVM reports capture live and exited processes, subtract baseline, and fail on lost processes", async () => {
27
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rust-cov-"));
28
+ let child: ReturnType<typeof Bun.spawn> | undefined;
29
+ try {
30
+ const tools = installedTools!;
31
+ await fs.access(path.join(tools, "llvm-cov"));
32
+ await fs.mkdir(path.join(root, "reports"));
33
+ await fs.copyFile(new URL("./coverage/rust.rs", import.meta.url), path.join(root, "spectest-coverage.rs"));
34
+ await fs.writeFile(path.join(root, "main.rs"), `#[path="spectest-coverage.rs"] mod coverage;\nuse std::io::{self, BufRead, Write};\nfn choose(left: bool) -> u32 {\n if left { 41 } else { 42 }\n}\nfn main() {\n coverage::start().unwrap();\n println!("ready");\n io::stdout().flush().unwrap();\n for line in io::stdin().lock().lines() {\n println!("{}",choose(line.unwrap()=="left"));\n io::stdout().flush().unwrap();\n }\n}\n`);
35
+ await run(["rustc", "--edition=2024", "-C", "instrument-coverage", "-C", "opt-level=0", "main.rs", "-o", "server"], root);
36
+ const env = { ...process.env, SPECTEST_COVERAGE_DIR: `${root}/reports`, SPECTEST_LLVM_COV: `${tools}/llvm-cov`, SPECTEST_LLVM_PROFDATA: `${tools}/llvm-profdata` };
37
+ const launch = () => Bun.spawn([`${root}/server`], { cwd: root, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
38
+ child = launch();
39
+ let reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
40
+ const ready = new TextDecoder().decode((await reader.read()).value);
41
+ if (!ready.includes("ready")) throw new Error(await new Response(child.stderr).text());
42
+ const context = { service: "rust", reportDir: `${root}/reports`, signal: AbortSignal.timeout(10000),
43
+ exec: async () => ({ stdout: "", stderr: "" }), writeReport: async () => {}, projectFiles: async () => [], readProjectFile: async () => null };
44
+ const capture = (baseline = false) => captureCoverageTarget({ context, coverage: { adapters: [rust()] }, root, baseline });
45
+ const setup = await capture(true);
46
+ expect(setup.error).toBeUndefined();
47
+ (child.stdin as { write(value: string): void }).write("left\n");
48
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain("41");
49
+ const changed = await capture();
50
+ if (process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE) {
51
+ const output = path.join(process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE, "rust");
52
+ await fs.mkdir(output, { recursive: true });
53
+ await fs.writeFile(path.join(output, "setup.json.gz"), encodeCoverageBundle("__setup__", [setup]));
54
+ await fs.writeFile(path.join(output, "left.json.gz"), encodeCoverageBundle("left", [changed]));
55
+ await fs.writeFile(path.join(output, "tree.txt"), "main.rs\n");
56
+ await fs.writeFile(path.join(output, "lines.json"), JSON.stringify({ "main.rs": 14 }));
57
+ }
58
+ expect(changed.error).toBeUndefined();
59
+ expect(changed.reports.map(r => r.content).join()).toMatch(/DA:4,[1-9]/);
60
+ const idle = await capture();
61
+ expect(idle.error).toBeUndefined();
62
+ expect(idle.reports.map(r => r.content).join()).toBe("TN:\n");
63
+ (child.stdin as { end(): void }).end();
64
+ expect(await child.exited).toBe(0);
65
+ expect((await capture()).error).toBeUndefined();
66
+ // A new process has a unique profile, even if the old PID is reused.
67
+ child = launch();
68
+ reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
69
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready");
70
+ child.kill("SIGKILL");
71
+ await child.exited;
72
+ expect((await capture()).error).toContain("without a final report");
73
+ } finally { child?.kill(); await fs.rm(root, { recursive: true, force: true }); }
74
+ }, 30000);
@@ -0,0 +1,79 @@
1
+ import { promises as fs, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { connect } from "node:net";
4
+ import type { CoverageAdapter, CoverageSourceOptions } from "./coverage.js";
5
+
6
+ export interface RustCoverageOptions extends CoverageSourceOptions {
7
+ /** Only for idle command containers where no Rust process has run yet. */
8
+ allowEmpty?: boolean;
9
+ /** Compiler workspace directory, when different from the runtime workdir.
10
+ * Exact SF paths below it are made repository-relative before archiving. */
11
+ buildRoot?: string;
12
+ /** Matching rustup llvm-tools-preview executables, in the runtime image. */
13
+ llvmCov?: string;
14
+ llvmProfdata?: string;
15
+ /** LLVM filename regex for external/generated sources, not application code. */
16
+ ignoreFilenameRegex?: string;
17
+ }
18
+
19
+ /** Source for a test-build-only module, copied as spectest-coverage.rs. The
20
+ * application links only this std-only helper, never the JavaScript SDK. */
21
+ export function rustHelperSource(): string {
22
+ return readFileSync(new URL("./coverage/rust.rs", import.meta.url), "utf8");
23
+ }
24
+
25
+ /** Flush a socket-backed collector. A dead process without a final flush is
26
+ * incomplete coverage, never an empty successful report. */
27
+ export async function flushCoverageSockets(dir: string, prefix: string, signal: AbortSignal): Promise<number> {
28
+ const names = (await fs.readdir(dir)).filter(name => name.startsWith(prefix));
29
+ for (const name of names) {
30
+ signal.throwIfAborted();
31
+ await new Promise<void>((resolve, reject) => {
32
+ const socket = connect(path.join(dir, name), () => socket.end("dump\n"));
33
+ let reply = "";
34
+ const abort = () => socket.destroy(new Error(`coverage flush aborted: ${name}`));
35
+ signal.addEventListener("abort", abort, { once: true });
36
+ socket.setTimeout(55_000, () => socket.destroy(new Error(`coverage flush timed out: ${name}`)));
37
+ socket.on("data", chunk => { reply += chunk; if (reply.length > 8192) socket.destroy(new Error("oversized coverage reply")); });
38
+ socket.on("error", error => reject(new Error(`coverage process ${name} exited without a final report or could not be reached: ${error.message}`)));
39
+ socket.on("close", () => {
40
+ signal.removeEventListener("abort", abort);
41
+ if (reply.trim() === "ok") resolve();
42
+ else reject(new Error(`coverage process ${name} did not flush: ${reply.trim() || "process exited without a final report"}`));
43
+ });
44
+ });
45
+ }
46
+ return names.length;
47
+ }
48
+
49
+ /** Requires instrumented binaries calling the shipped coverage/rust.rs helper.
50
+ * The helper exports cumulative per-process LCOV before exit and on request;
51
+ * no compiler tools or binaries need to be copied out of terminated pods. */
52
+ export function rust(options: RustCoverageOptions = {}): CoverageAdapter {
53
+ const { sourceRoot, excludeSources, llvmCov, llvmProfdata, ignoreFilenameRegex, buildRoot } = options;
54
+ if (buildRoot !== undefined && (!buildRoot.startsWith("/") || buildRoot === "/" || /[\0\r\n]/.test(buildRoot) || buildRoot.split("/").includes(".."))) throw new Error("Rust buildRoot must be an absolute compiler workspace directory");
55
+ for (const value of [llvmCov, llvmProfdata, ignoreFilenameRegex]) {
56
+ if (value !== undefined && (!value || /[\0\r\n]/.test(value))) throw new Error("invalid Rust coverage option");
57
+ }
58
+ return {
59
+ name: "rust", reports: "cumulative",
60
+ sourceOptions: { ...(sourceRoot !== undefined ? { sourceRoot } : {}), ...(excludeSources ? { excludeSources } : {}) },
61
+ ownsReport: name => /^rust-.*\.lcov$/.test(name),
62
+ configure: service => ({ ...service, env: { ...service.env,
63
+ SPECTEST_COVERAGE_DIR: "/spectest/coverage",
64
+ ...(buildRoot ? { SPECTEST_RUST_BUILD_ROOT: buildRoot } : {}),
65
+ ...(llvmCov ? { SPECTEST_LLVM_COV: llvmCov } : {}),
66
+ ...(llvmProfdata ? { SPECTEST_LLVM_PROFDATA: llvmProfdata } : {}),
67
+ ...(ignoreFilenameRegex ? { SPECTEST_RUST_IGNORE: ignoreFilenameRegex } : {}),
68
+ } }),
69
+ async capture(ctx) {
70
+ await flushCoverageSockets(ctx.reportDir, ".rust-ctl-", ctx.signal);
71
+ const names = await fs.readdir(ctx.reportDir);
72
+ if (names.some(name => name.startsWith("rust-error-"))) throw new Error("Rust coverage helper failed; inspect rust-error report");
73
+ if (!names.some(name => /^rust-.*\.lcov$/.test(name))) {
74
+ if (options.allowEmpty) { await ctx.writeReport("rust-empty.lcov", "TN:\n"); return; }
75
+ throw new Error("no Rust coverage reports: build with -C instrument-coverage and call spectest_coverage::start() before application work");
76
+ }
77
+ },
78
+ };
79
+ }
@@ -540,7 +540,7 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
540
540
  const names = await dumpsIn(dir);
541
541
  expect(names.length).toBe(2);
542
542
  const byThread = new Map<string, Dump>();
543
- for (const n of names) byThread.set(n.split("-")[2]!, await readDump(dir, n));
543
+ for (const n of names) byThread.set(n.split("-")[3]!, await readDump(dir, n));
544
544
  const recs = await records(dir);
545
545
  expect(recs.size).toBe(1); // one script, one record, whichever thread came first
546
546
  const rec = recs.get(`file://${path.join(dir, "threads.js")}`)!;
@@ -600,14 +600,14 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
600
600
  expect(out.trim().split("\n")).toEqual(["npm:@s/b@2.0.0", "npm:a@1.0.0", "npm:c@3.0.0", "npm:d@4.0.0", "npm:e", "npm:f@6.0.0"]);
601
601
  });
602
602
 
603
- test("a stale socket left by a killed process is removed at capture", async () => {
603
+ test("a killed covered process makes capture incomplete instead of silently losing execution", async () => {
604
604
  const dir = await scratch();
605
605
  const server = await start(dir, "server.js", 1);
606
606
  server.kill("SIGKILL");
607
607
  await new Promise<void>((r) => server.on("exit", () => r()));
608
608
  expect((await socketsIn(dir)).length).toBe(1); // SIGKILL: no exit handler, socket left behind
609
- await node().capture!(fakeCtx(dir));
610
- expect((await socketsIn(dir)).length).toBe(0);
609
+ await expect(node().capture!(fakeCtx(dir))).rejects.toThrow("without a final report");
610
+ expect((await socketsIn(dir)).length).toBe(1);
611
611
  });
612
612
 
613
613
  test("no process and no dump: an empty report is written, once", async () => {
package/src/coverage.ts CHANGED
@@ -36,6 +36,8 @@ import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-
36
36
  import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
37
37
  export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
38
38
  export { next, type NextCoverageOptions } from "./coverage-next.js";
39
+ export { bun, type BunCoverageOptions } from "./coverage-bun.js";
40
+ export { rust, rustHelperSource, type RustCoverageOptions } from "./coverage-rust.js";
39
41
 
40
42
  /** Where `node()` mounts its hook inside the container. */
41
43
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
@@ -44,7 +46,7 @@ export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
44
46
  export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
45
47
 
46
48
  /** Prefix of the per-process sockets the node hook answers on, relative
47
- * to the coverage dir: `.ctl-<pid>`. */
49
+ * to the coverage dir: `.ctl-<pid>-<nonce>`. */
48
50
  export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
49
51
 
50
52
 
@@ -150,6 +152,10 @@ export interface CoverageAdapter {
150
152
  load?(ctx: CoverageLoadContext): void | Promise<void>;
151
153
  /** Make the report appear in the directory. A throw fails the test. */
152
154
  capture?(ctx: CoverageCaptureContext): void | Promise<void>;
155
+ /** Component-owned containers (for example k3s pods). Their reports retain
156
+ * their own source roots and counter baselines, rather than the outer
157
+ * service's filesystem identity. Internal: ordinary adapters use capture. */
158
+ collect?(ctx: CoverageInventoryContext & CoverageCaptureContext, baseline: boolean): Promise<import("./harness/coverage.js").ServiceCoverageCapture[]>;
153
159
  /**
154
160
  * Project paths (gitignore-style patterns, project-relative) whose
155
161
  * changes this adapter's **inventory** accounts for — a package
@@ -245,7 +251,7 @@ export function validateCoverage(service: string, cov: unknown): void {
245
251
  ((a as CoverageAdapter).explains === undefined ||
246
252
  (Array.isArray((a as CoverageAdapter).explains) &&
247
253
  ((a as CoverageAdapter).explains as unknown[]).every((e) => typeof e === "string"))) &&
248
- ["configure", "load", "capture", "inventory", "ownsReport"].every(
254
+ ["configure", "load", "capture", "collect", "inventory", "ownsReport"].every(
249
255
  (k) =>
250
256
  (a as Record<string, unknown>)[k] === undefined ||
251
257
  typeof (a as Record<string, unknown>)[k] === "function",
@@ -398,7 +404,7 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
398
404
  * without `--enable-source-maps`. For a bundled CLI that was 12 MB per
399
405
  * invocation, inside the test's own time, parsed again at capture.
400
406
  * - **Takes** on request — the main thread binds a Unix socket
401
- * `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
407
+ * `.ctl-<pid>-<nonce>` in the coverage directory and takes on `dump\n`; every
402
408
  * process binds its own (the first node process is often a wrapper,
403
409
  * `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
404
410
  * handler, which is how a short-lived CLI and a worker thread report.
@@ -448,6 +454,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
448
454
  (function () {
449
455
  const dir = process.env[${JSON.stringify(NODE_COVERAGE_DIR_ENV)}];
450
456
  if (!dir) return;
457
+ const bunRuntime = !!process.versions.bun && process.env.SPECTEST_COVERAGE_RUNTIME === "bun";
451
458
  let inspector, fs, path, crypto, url, wt, net;
452
459
  try {
453
460
  inspector = require("node:inspector");
@@ -468,19 +475,46 @@ export const NODE_COVERAGE_HOOK = `"use strict";
468
475
  }
469
476
  // An in-process session answers before post() returns.
470
477
  function post(method, params) {
478
+ // Bun's callback API queues a microtask, including during process exit.
479
+ // Its synchronous return API is necessary for short-lived commands.
480
+ if (bunRuntime) return session.post(method, params || {});
471
481
  let out = null;
472
482
  session.post(method, params || {}, function (err, res) {
473
483
  out = err ? null : res;
474
484
  });
475
485
  return out;
476
486
  }
477
- if (post("Profiler.enable") === null) return;
478
- post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
487
+ fs.mkdirSync(dir, { recursive: true });
488
+ function fail(error) {
489
+ fs.writeFileSync(path.join(dir, "coverage-error-" + process.pid + ".txt"), String(error));
490
+ }
491
+ try {
492
+ if (post("Profiler.enable") === null) throw new Error("coverage profiler unavailable");
493
+ if (!post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })) throw new Error("precise coverage unavailable");
494
+ if (bunRuntime && (!process.versions.bun || !wt.isMainThread)) throw new Error("Bun coverage requires a supported main-thread runtime");
495
+ } catch (error) { fail(error); throw error; }
496
+ let bunRangeAdjustment = 0;
497
+ if (bunRuntime) {
498
+ // Bun releases have exposed both JSC-inclusive and V8-exclusive range
499
+ // ends. Probe the actual runtime before app code loads: interpreting an
500
+ // inclusive closing brace as exclusive attributes its source-map token
501
+ // to startup, making untouched function bodies appear boot-executed.
502
+ const probe = "let x=0;\\nfunction spectestCoverageProbe(){return 1;}\\nspectestCoverageProbe();\\n//# sourceURL=spectest-coverage-probe";
503
+ eval(probe);
504
+ const measured = post("Profiler.takePreciseCoverage");
505
+ const script = measured && measured.result.find(s => s.url === "spectest-coverage-probe");
506
+ const fn = script && script.functions.find(f => f.ranges[0].startOffset === probe.indexOf("function"));
507
+ if (!fn) { const error = new Error("Bun precise coverage capability probe failed"); fail(error); throw error; }
508
+ bunRangeAdjustment = probe.indexOf("}") + 1 - fn.ranges[0].endOffset;
509
+ if (bunRangeAdjustment !== 0 && bunRangeAdjustment !== 1) {
510
+ const error = new Error("unsupported Bun coverage range semantics"); fail(error); throw error;
511
+ }
512
+ }
479
513
 
480
514
  // Attach before app scripts execute. Enabling only at capture loses the
481
515
  // source of collected vm contexts (e.g. Temporal workflow bundles).
482
516
  // Bound V8's collected-script cache; live sources remain owned by V8.
483
- post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
517
+ if (!bunRuntime) post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
484
518
 
485
519
  const GENERATED = new Map();
486
520
  try {
@@ -499,6 +533,10 @@ export const NODE_COVERAGE_HOOK = `"use strict";
499
533
  // can be reused by another process, vm context, or compilation.
500
534
  const known = new Map();
501
535
  let seq = 0;
536
+ const processToken = process.pid + "-" + crypto.randomBytes(6).toString("hex");
537
+ const liveMarker = path.join(dir, ".live-" + processToken + "-" + wt.threadId);
538
+ fs.writeFileSync(liveMarker, "");
539
+ fs.writeFileSync(path.join(dir, ".attached-" + (bunRuntime ? "bun" : "node")), "");
502
540
 
503
541
  function isApp(u) {
504
542
  return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
@@ -674,6 +712,12 @@ export const NODE_COVERAGE_HOOK = `"use strict";
674
712
  const kept = [];
675
713
  const units = new Set();
676
714
  for (const s of res.result) {
715
+ if (bunRuntime && bunRangeAdjustment && Array.isArray(s.functions)) {
716
+ const length = Math.max(...s.functions.flatMap(f => f.ranges.map(r => r.endOffset)));
717
+ for (const f of s.functions) for (const range of f.ranges) {
718
+ if (range.endOffset < length) range.endOffset += bunRangeAdjustment;
719
+ }
720
+ }
677
721
  if (!Array.isArray(s.functions) || !s.functions.some(ran)) continue;
678
722
  if (isApp(s.url)) kept.push(s);
679
723
  else if (isDep(s.url)) {
@@ -695,7 +739,18 @@ export const NODE_COVERAGE_HOOK = `"use strict";
695
739
  fs.mkdirSync(MARKERS, { recursive: true });
696
740
  } catch (e) {}
697
741
  for (const s of fresh) {
698
- const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
742
+ let r;
743
+ if (bunRuntime) {
744
+ // Bun does not expose getScriptSource through the in-process API.
745
+ // Accept only precompiled, untranspiled bundles, never guess that
746
+ // disk TypeScript offsets describe executed JavaScript.
747
+ const source = fs.readFileSync(url.fileURLToPath(s.url), "utf8");
748
+ const extent = Math.max(...s.functions.flatMap(f => f.ranges.map(r => r.endOffset)));
749
+ if (!source.startsWith("// @bun\\n") || source.length !== extent) {
750
+ throw new Error("Bun coverage needs an unchanged bun build --target=bun bundle with source maps: " + s.url);
751
+ }
752
+ r = { scriptSource: source };
753
+ } else r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
699
754
  try {
700
755
  const source = r && r.scriptSource;
701
756
  if (GENERATED.has(s.url) && typeof source === "string") {
@@ -737,12 +792,12 @@ export const NODE_COVERAGE_HOOK = `"use strict";
737
792
  };
738
793
  }),
739
794
  };
740
- const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + process.pid + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
795
+ const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + processToken + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
741
796
  writeAtomic(path.join(dir, name), JSON.stringify(doc));
742
797
  }
743
798
 
744
799
  if (wt.isMainThread) {
745
- const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
800
+ const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + processToken);
746
801
  const server = net.createServer(function (conn) {
747
802
  let buf = "";
748
803
  conn.on("data", function (chunk) {
@@ -754,6 +809,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
754
809
  take();
755
810
  reply = "ok\\n";
756
811
  } catch (err) {
812
+ fail(err);
757
813
  reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
758
814
  }
759
815
  conn.end(reply);
@@ -774,7 +830,8 @@ export const NODE_COVERAGE_HOOK = `"use strict";
774
830
  process.on("exit", function () {
775
831
  try {
776
832
  take();
777
- } catch (e) {}
833
+ fs.unlinkSync(liveMarker);
834
+ } catch (e) { fail(e); }
778
835
  });
779
836
  })();
780
837
  `;
@@ -992,9 +1049,9 @@ export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
992
1049
 
993
1050
  /**
994
1051
  * Ask every node process that holds a hook socket in `dir` for a dump.
995
- * A socket nobody answers (its process died without unlinking — SIGKILL,
996
- * OOM) is removed. Returns how many processes answered; a process that
997
- * answered with an error throws.
1052
+ * An unanswered socket with a live marker means the process died without
1053
+ * a final report and fails collection. Stale sockets without live markers
1054
+ * are removed. Returns how many processes answered; error replies throw.
998
1055
  */
999
1056
  export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number> {
1000
1057
  const fs = await import("node:fs/promises");
@@ -1029,6 +1086,10 @@ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Pr
1029
1086
  });
1030
1087
  });
1031
1088
  if (reply === null) {
1089
+ const pid = name.slice(NODE_COVERAGE_SOCKET_PREFIX.length);
1090
+ if ((await fs.readdir(dir)).some(marker => marker.startsWith(".live-" + pid + "-"))) {
1091
+ throw new Error(`coverage process ${name} exited without a final report`);
1092
+ }
1032
1093
  // Nobody home: the process is gone. Clean up so the next capture
1033
1094
  // does not knock again.
1034
1095
  await fs.unlink(sock).catch(() => {});
package/src/daemon.ts CHANGED
@@ -3873,7 +3873,11 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
3873
3873
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
3874
3874
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
3875
3875
  const all = [...byName.values()];
3876
- const services = all.filter((s) => s.coverage !== undefined);
3876
+ const covered = all.filter((s) => s.coverage !== undefined);
3877
+ const services = covered.filter(s => {
3878
+ const adapters = coverageAdapters(s.coverage);
3879
+ return !adapters.some(a => a.collect) || adapters.some(a => !a.collect);
3880
+ });
3877
3881
  // The environment's own capture is unconditional (see above); the
3878
3882
  // services' come from the ones that opted in, none included.
3879
3883
  const environment = captureEnvironmentCoverage(all, baseline);
@@ -3922,7 +3926,23 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
3922
3926
  return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
3923
3927
  }),
3924
3928
  );
3925
- return [...captures, await environment];
3929
+ const components = await Promise.all(covered.flatMap(svc => coverageAdapters(svc.coverage)
3930
+ .filter(a => a.collect).map(async adapter => {
3931
+ const signal = AbortSignal.timeout(COVERAGE_CAPTURE_TIMEOUT_MS);
3932
+ try {
3933
+ return await adapter.collect!({
3934
+ ...coverageCaptureContext(svc, signal, new Set()),
3935
+ projectFiles: () => walkProjectFiles(WORKSPACE),
3936
+ async readProjectFile(p) {
3937
+ try { return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8"); }
3938
+ catch { return null; }
3939
+ },
3940
+ }, baseline);
3941
+ } catch (error) {
3942
+ return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
3943
+ }
3944
+ })));
3945
+ return [...captures, ...components.flat(), await environment];
3926
3946
  }
3927
3947
 
3928
3948
  /** Per-Browser rrweb session shipped to the control plane. */
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
3
3
 
4
- export type KubernetesImage = string | (BuildImage & { reference: string });
4
+ export type KubernetesImage = string | (BuildImage & { reference: string; coverage?: import("../coverage.js").ServiceCoverage });
5
5
 
6
6
  /** containerd/CRI use fully qualified names, including the default tag. */
7
7
  export function canonicalImageReference(reference: string): string {
@@ -45,7 +45,10 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
45
45
  }
46
46
  seen.set(reference, identity);
47
47
  if (typeof image === "string") pulls.push(image);
48
- else builds.push({ reference, image });
48
+ else {
49
+ const { coverage: _coverage, ...build } = image;
50
+ builds.push({ reference, image: image.coverage === undefined ? image : build });
51
+ }
49
52
  }
50
53
  return { pulls, builds };
51
54
  }
@@ -0,0 +1,55 @@
1
+ // Runtime adapters operate on this same context in Docker and nested runtimes.
2
+ // Counter baselines belong to an instance; source identity belongs to its image.
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions,
6
+ type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
7
+ import { applyCoverageDelta, readCoverageDir, type ServiceCoverageCapture } from "./coverage.js";
8
+
9
+ export async function captureCoverageTarget(options: {
10
+ context: CoverageCaptureContext & CoverageInventoryContext;
11
+ coverage: ServiceCoverage;
12
+ root: string;
13
+ baseline: boolean;
14
+ inventoryAvailable?: boolean;
15
+ counterKey?: string;
16
+ }): Promise<ServiceCoverageCapture> {
17
+ const { context: ctx, coverage, root, baseline } = options;
18
+ const adapters = coverageAdapters(coverage);
19
+ const written = new Set<string>();
20
+ const delta = new Set<string>();
21
+ const context = { ...ctx, async writeReport(name: string, content: string) {
22
+ const resolved = path.resolve(ctx.reportDir, name);
23
+ if (!resolved.startsWith(path.resolve(ctx.reportDir) + path.sep)) throw new Error("coverage report escapes directory");
24
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
25
+ await fs.writeFile(resolved + ".tmp", content);
26
+ await fs.rename(resolved + ".tmp", resolved);
27
+ written.add(name);
28
+ } };
29
+ try {
30
+ ctx.signal.throwIfAborted();
31
+ const policies = await resolveCoverageSourceOptions(adapters, ctx, typeof coverage === "object" ? {
32
+ sourceRoot: coverage.sourceRoot, excludeSources: coverage.excludeSources,
33
+ } : undefined);
34
+ for (const adapter of adapters) {
35
+ if (adapter.collect || adapter.load) throw new Error(`adapter ${adapter.name} cannot run inside a container; attach browser coverage to the cluster service`);
36
+ written.clear();
37
+ await adapter.capture?.(context);
38
+ if (adapter.reports === "delta") for (const name of written) delta.add(name);
39
+ }
40
+ if (baseline && options.inventoryAvailable !== false) {
41
+ const units = new Set<string>();
42
+ for (const adapter of adapters) for (const unit of await adapter.inventory?.(context) ?? []) units.add(unit);
43
+ await context.writeReport("inventory.units", [...units].sort().join("\n"));
44
+ }
45
+ ctx.signal.throwIfAborted();
46
+ const capture = await readCoverageDir(ctx.service, ctx.reportDir);
47
+ if (baseline) await fs.rm(path.join(ctx.reportDir, "inventory.units"), { force: true });
48
+ capture.root = root;
49
+ attachCoverageSourceOptions(capture, policies);
50
+ const result = applyCoverageDelta({ ...capture, service: options.counterKey ?? capture.service }, coverageReportsMode(coverage), delta);
51
+ return { ...result, service: capture.service };
52
+ } catch (error) {
53
+ return { service: ctx.service, reports: [], error: String(error) };
54
+ }
55
+ }
package/src/index.ts CHANGED
@@ -175,7 +175,7 @@ export {
175
175
  type CoverageLoadContext,
176
176
  type ServiceCoverage,
177
177
  } from "./coverage.js";
178
- import { applyCoverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
178
+ import { applyCoverageAdapters, coverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
179
179
  import { resolveExistingProjectPath } from "./project-files.js";
180
180
  export { ca } from "./environment-ca.js";
181
181
 
@@ -1138,6 +1138,15 @@ function expandServiceGroups(input: InputServicesMap): ServicesMap {
1138
1138
  // Rewrite relative dependsOn to final keys; a spread keeps function
1139
1139
  // fields (helpers/setup) and the enumerable-symbol ingress decls.
1140
1140
  const final: ServiceConfig = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
1141
+ // Component-owned nested containers keep their collection when callers
1142
+ // add ordinary service coverage (for example browser coverage on k3s).
1143
+ const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
1144
+ if (part === entry.primary && collectors.length && primaryOverrides.coverage !== undefined) {
1145
+ const extra = primaryOverrides.coverage;
1146
+ final.coverage = { ...(typeof extra === "object" ? extra : {}), adapters: [
1147
+ ...collectors, ...coverageAdapters(extra).filter(adapter => !collectors.includes(adapter)),
1148
+ ] };
1149
+ }
1141
1150
  if (final.dependsOn?.length) {
1142
1151
  final.dependsOn = final.dependsOn.map((d) =>
1143
1152
  partKeys.has(d) ? naming.key(d) : d,