@specific.dev/spectest 0.58.0 → 0.59.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,327 @@
1
+ // Coverage adapters — how spectest gets a coverage report out of a service.
2
+ //
3
+ // A service opts in with `coverage: true` (the program writes its own
4
+ // reports into `/spectest/coverage/`) or `coverage: { adapters: [...] }`
5
+ // (spectest gets the reports out). An adapter has three optional moments:
6
+ //
7
+ // configure — config time, inside `defineEnvironment`. Rewrites the
8
+ // service (env, files, command). Pure: its output is part
9
+ // of the wire config and of the warm-template hash.
10
+ // load — harness, at /load. Asks the harness for capabilities a
11
+ // container cannot supply itself (browser scripts).
12
+ // capture — harness, after bring-up and after every test. Must leave
13
+ // a report in the directory.
14
+ //
15
+ // After every adapter ran, the harness reads the directory the same way
16
+ // it does for `coverage: true`: at least one well-formed report (lcov or
17
+ // V8 JSON), shipped verbatim. Adapters convert nothing — the control
18
+ // plane derives what it needs from the stored bytes.
19
+ //
20
+ // Imported from `@specific.dev/spectest/coverage`. The three shipped
21
+ // adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
22
+ // (scripts the guest browser loads from the service), `command(cmd)` (run
23
+ // a command, then read). A project writes its own with `defineAdapter`.
24
+ //
25
+ // import * as coverage from "@specific.dev/spectest/coverage";
26
+ // coverage: { adapters: [coverage.node(), coverage.browser()] }
27
+
28
+ import type { ServiceConfig } from "./index.js";
29
+ import { browserCoverageReports } from "./browser-coverage.js";
30
+
31
+ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
32
+ export { COVERAGE_CONTAINER_DIR };
33
+
34
+ /** Where `node()` mounts its hook inside the container. */
35
+ export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
36
+
37
+ /** The socket the node hook answers on, relative to the coverage dir. */
38
+ export const NODE_COVERAGE_SOCKET = ".ctl";
39
+
40
+ /** What `configure` learns about the service it rewrites. */
41
+ export interface CoverageConfigureInfo {
42
+ /** The services-map key. */
43
+ key: string;
44
+ }
45
+
46
+ /** The harness capabilities an adapter can ask for at `/load`. */
47
+ export interface CoverageLoadContext {
48
+ /** The services-map key. */
49
+ service: string;
50
+ /**
51
+ * Collect V8 coverage for every script the guest browser
52
+ * (`ctx.browser()` / `ctx.mobile()`) loads from this service's origins:
53
+ * its key, `<key>.internal`, its `hostnames`/`dnsName` aliases, its
54
+ * `tls`/`proxy` hostnames and any service-targeted wildcard. The
55
+ * harness maps each script back to source through the served source
56
+ * map and keeps per-service totals in memory (they fork with the
57
+ * environment). `browser()` calls this and writes the totals
58
+ * at capture.
59
+ */
60
+ collectBrowserScripts(): void;
61
+ }
62
+
63
+ /** What one adapter's `capture` runs against. */
64
+ export interface CoverageCaptureContext {
65
+ /** The services-map key. */
66
+ service: string;
67
+ /**
68
+ * The coverage directory, seen from the harness. It is the same
69
+ * directory the container sees at `/spectest/coverage/` (a bind
70
+ * mount), so a file written here is in the container and a socket the
71
+ * container bound here is reachable.
72
+ */
73
+ reportDir: string;
74
+ /** Run a shell command (`sh -c`) inside the container. Rejects on a
75
+ * non-zero exit with the command's output in the message. */
76
+ exec(command: string): Promise<{ stdout: string; stderr: string }>;
77
+ /** Write one report file (name relative to the directory). */
78
+ writeReport(name: string, content: string): Promise<void>;
79
+ /** Aborts when the per-service capture budget runs out. */
80
+ signal: AbortSignal;
81
+ }
82
+
83
+ export interface CoverageAdapter {
84
+ /** Names the adapter in errors (`coverage adapter "node" on service …`). */
85
+ name: string;
86
+ /**
87
+ * Rewrite the service at config time. Must be pure and deterministic
88
+ * (the result is hashed into the warm-template key) and must **append**
89
+ * to `env` values like `NODE_OPTIONS` rather than replace them.
90
+ */
91
+ configure?(service: ServiceConfig, info: CoverageConfigureInfo): ServiceConfig;
92
+ /** Ask the harness for capabilities, at `/load`. */
93
+ load?(ctx: CoverageLoadContext): void | Promise<void>;
94
+ /** Make the report appear in the directory. A throw fails the test. */
95
+ capture?(ctx: CoverageCaptureContext): void | Promise<void>;
96
+ }
97
+
98
+ /** `ServiceConfig.coverage`: the program writes its own reports, or a
99
+ * list of adapters gets them out. */
100
+ export type ServiceCoverage = true | { adapters: readonly CoverageAdapter[] };
101
+
102
+ /** Type an inline adapter. Identity at runtime. */
103
+ export function defineAdapter(adapter: CoverageAdapter): CoverageAdapter {
104
+ return adapter;
105
+ }
106
+
107
+ /** Check one service's `coverage` field. Exported so the daemon applies
108
+ * the same rule to a runtime `startService` spec. */
109
+ export function validateCoverage(service: string, cov: unknown): void {
110
+ if (cov === undefined || cov === true) return;
111
+ const adapters = (cov as { adapters?: unknown } | null)?.adapters;
112
+ if (typeof cov === "object" && cov !== null && Array.isArray(adapters)) {
113
+ for (const a of adapters) {
114
+ const ok =
115
+ typeof a === "object" &&
116
+ a !== null &&
117
+ typeof (a as CoverageAdapter).name === "string" &&
118
+ (a as CoverageAdapter).name.length > 0 &&
119
+ ["configure", "load", "capture"].every(
120
+ (k) =>
121
+ (a as Record<string, unknown>)[k] === undefined ||
122
+ typeof (a as Record<string, unknown>)[k] === "function",
123
+ );
124
+ if (!ok) {
125
+ throw new Error(
126
+ `service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
127
+ );
128
+ }
129
+ }
130
+ return;
131
+ }
132
+ throw new Error(
133
+ `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("…")\`)`,
134
+ );
135
+ }
136
+
137
+ /** The adapters of a `coverage` value (`true` has none). */
138
+ export function coverageAdapters(cov: ServiceCoverage | undefined): readonly CoverageAdapter[] {
139
+ return typeof cov === "object" && cov !== null ? cov.adapters : [];
140
+ }
141
+
142
+ /**
143
+ * Apply every adapter's `configure` to a service, in list order. Called by
144
+ * `defineEnvironment` (after group expansion, before validation) and by
145
+ * the daemon for a runtime `startService` spec. The adapter list stays on
146
+ * the returned config for the harness; on the wire it serializes as the
147
+ * adapter names (`toJSON`), never as functions.
148
+ */
149
+ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S {
150
+ validateCoverage(key, svc.coverage);
151
+ const adapters = coverageAdapters(svc.coverage);
152
+ if (adapters.length === 0) return svc;
153
+ let out: ServiceConfig = svc;
154
+ for (const a of adapters) {
155
+ if (a.configure) out = a.configure(out, { key });
156
+ }
157
+ const wire = { adapters: adapters.map((a) => withWireName(a)) };
158
+ return { ...out, coverage: wire } as unknown as S;
159
+ }
160
+
161
+ function withWireName(a: CoverageAdapter): CoverageAdapter {
162
+ if (Object.prototype.hasOwnProperty.call(a, "toJSON")) return a;
163
+ return Object.assign(Object.create(Object.getPrototypeOf(a) as object | null), a, {
164
+ toJSON: () => ({ adapter: a.name }),
165
+ }) as CoverageAdapter;
166
+ }
167
+
168
+ /** `env` with `value` appended to `name` (space-separated), or set. */
169
+ export function appendEnvFlag(
170
+ env: Readonly<Record<string, string>> | undefined,
171
+ name: string,
172
+ value: string,
173
+ ): Record<string, string> {
174
+ const prior = env?.[name]?.trim();
175
+ return { ...env, [name]: prior ? `${prior} ${value}` : value };
176
+ }
177
+
178
+ // ── node ──────────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * The hook `node()` mounts and `--require`s into every node
182
+ * process of the container. Node writes V8 coverage JSON into
183
+ * `NODE_V8_COVERAGE` when a process exits; a long-lived server never
184
+ * exits, so the hook binds a Unix socket in the coverage directory and
185
+ * calls `v8.takeCoverage()` for each connection. The first process to
186
+ * start owns the socket (a stale socket left by a dead process is
187
+ * reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
188
+ * alone and writes at its own exit. No signal is used: signals are
189
+ * claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
190
+ * nodemon), a socket is nobody's.
191
+ */
192
+ export const NODE_COVERAGE_HOOK = `"use strict";
193
+ // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
194
+ if (process.env.NODE_V8_COVERAGE) {
195
+ const net = require("node:net");
196
+ const fs = require("node:fs");
197
+ const v8 = require("node:v8");
198
+ const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
199
+ const server = net.createServer((conn) => {
200
+ // A dump is taken only on an explicit "dump" request: a bystander's
201
+ // liveness probe connects and closes without one.
202
+ let buf = "";
203
+ conn.on("data", (chunk) => {
204
+ buf += chunk;
205
+ if (!buf.includes("\\n")) return;
206
+ let reply;
207
+ try {
208
+ if (!buf.startsWith("dump")) throw new Error("unknown request");
209
+ v8.takeCoverage();
210
+ reply = "ok\\n";
211
+ } catch (err) {
212
+ reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
213
+ }
214
+ conn.end(reply);
215
+ });
216
+ });
217
+ server.unref();
218
+ server.on("error", (err) => {
219
+ if (err.code !== "EADDRINUSE") return;
220
+ // Someone holds the socket. If it answers, it is the live server and
221
+ // this process is a bystander; if not, it is a stale file.
222
+ const probe = net.connect(SOCK);
223
+ probe.on("connect", () => probe.destroy());
224
+ probe.on("error", () => {
225
+ try { fs.unlinkSync(SOCK); } catch {}
226
+ server.listen(SOCK);
227
+ });
228
+ });
229
+ server.listen(SOCK);
230
+ }
231
+ `;
232
+
233
+ /**
234
+ * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
235
+ * directory (every node process in the container then writes V8
236
+ * coverage JSON when it exits) and `--require`s a hook that lets spectest
237
+ * ask the long-lived server process for a dump at capture time. Nothing
238
+ * for the app to write. Source maps: with `--enable-source-maps` (or a
239
+ * `sourceMappingURL` next to the file) Node records the map in the
240
+ * report, which is what maps a TypeScript service back to its sources.
241
+ */
242
+ export function node(): CoverageAdapter {
243
+ return {
244
+ name: "node",
245
+ configure(svc) {
246
+ const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
247
+ env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
248
+ return {
249
+ ...svc,
250
+ env,
251
+ files: [...(svc.files ?? []), { path: NODE_COVERAGE_HOOK_PATH, content: NODE_COVERAGE_HOOK }],
252
+ };
253
+ },
254
+ async capture(ctx) {
255
+ const { connect } = await import("node:net");
256
+ const path = await import("node:path");
257
+ const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
258
+ const reply = await new Promise<string>((resolve, reject) => {
259
+ const chunks: Buffer[] = [];
260
+ const c = connect(sock, () => c.write("dump\n"));
261
+ const onAbort = (): void => {
262
+ c.destroy();
263
+ reject(new Error("timed out waiting for the node hook to write a report"));
264
+ };
265
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
266
+ c.on("data", (b: Buffer) => chunks.push(b));
267
+ c.on("error", (err: NodeJS.ErrnoException) => {
268
+ ctx.signal.removeEventListener("abort", onAbort);
269
+ reject(
270
+ new Error(
271
+ err.code === "ENOENT" || err.code === "ECONNREFUSED"
272
+ ? `no node process is serving ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET} — is the service's main process node, and does it run with the service's env (NODE_OPTIONS)?`
273
+ : err.message,
274
+ ),
275
+ );
276
+ });
277
+ c.on("close", () => {
278
+ ctx.signal.removeEventListener("abort", onAbort);
279
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
280
+ });
281
+ });
282
+ if (reply !== "ok") throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
283
+ },
284
+ };
285
+ }
286
+
287
+ // ── browser ───────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Coverage for the frontend a service serves. The code runs in the guest
291
+ * browser — spectest's own process — so no report can be written by the
292
+ * container; spectest collects V8 coverage for every script the browser
293
+ * loads from this service's origins, maps it back to source through the
294
+ * served source map, and writes one lcov per capture. Nothing ran yet on
295
+ * this branch ⇒ an lcov with no records, which is a report saying so.
296
+ */
297
+ export function browser(): CoverageAdapter {
298
+ return {
299
+ name: "browser",
300
+ load(ctx) {
301
+ ctx.collectBrowserScripts();
302
+ },
303
+ async capture(ctx) {
304
+ const lcov = browserCoverageReports().get(ctx.service) ?? "TN:\n";
305
+ await ctx.writeReport("browser.lcov", lcov);
306
+ },
307
+ };
308
+ }
309
+
310
+ // ── command ───────────────────────────────────────────────────────
311
+
312
+ /**
313
+ * Run `command` (via `sh -c`) in the container at every capture, so the
314
+ * service writes a fresh report into `/spectest/coverage/`. The escape
315
+ * hatch for a runtime with no shipped adapter.
316
+ */
317
+ export function command(command: string): CoverageAdapter {
318
+ if (typeof command !== "string" || command.trim().length === 0) {
319
+ throw new Error("coverage.command: `command` must be a non-empty shell command");
320
+ }
321
+ return {
322
+ name: "command",
323
+ async capture(ctx) {
324
+ await ctx.exec(command);
325
+ },
326
+ };
327
+ }
package/src/daemon.ts CHANGED
@@ -32,9 +32,6 @@ import {
32
32
  dnsName as makeDnsDecl,
33
33
  isWildcard,
34
34
  proxy as makeProxyDecl,
35
- validateCoverage,
36
- coverageUsesBrowser,
37
- coverageUsesContainer,
38
35
  } from "./index.js";
39
36
  import type { DnsTarget, LoweredIngress } from "./index.js";
40
37
  import {
@@ -42,15 +39,20 @@ import {
42
39
  coverageBundleRef,
43
40
  coverageHostDir,
44
41
  encodeCoverageBundle,
42
+ isEmptyReport,
45
43
  readCoverageDir,
46
44
  type CoverageBundleRef,
47
45
  type CoverageReport,
48
46
  type ServiceCoverageCapture,
49
47
  } from "./harness/coverage.js";
48
+ import { configureBrowserCoverage } from "./browser-coverage.js";
50
49
  import {
51
- browserCoverageReports,
52
- configureBrowserCoverage,
53
- } from "./browser-coverage.js";
50
+ applyCoverageAdapters,
51
+ coverageAdapters,
52
+ validateCoverage,
53
+ type CoverageAdapter,
54
+ type CoverageCaptureContext,
55
+ } from "./coverage.js";
54
56
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
55
57
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
56
58
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
@@ -785,9 +787,6 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
785
787
  async function ensureCoverage(svc: NamedService): Promise<string[]> {
786
788
  if (svc.coverage === undefined) return [];
787
789
  validateCoverage(svc.name, svc.coverage);
788
- // A browser-only opt-in has no container process to write reports, so it
789
- // gets no mount — its coverage comes from the guest browser.
790
- if (!coverageUsesContainer(svc.coverage)) return [];
791
790
  const host = coverageHostDir(WORKSPACE, svc.name);
792
791
  await fs.mkdir(host, { recursive: true });
793
792
  await fs.chmod(host, 0o777);
@@ -1813,7 +1812,11 @@ function buildIngress(project: Project): void {
1813
1812
  // generic decl set the daemon executes. The special-casing lives in the
1814
1813
  // SDK's lowerIngress, not here.
1815
1814
  LOWERED = lowerIngress(project);
1816
- configureCoverageAttribution(project);
1815
+ BROWSER_COVERAGE_SERVICES.clear();
1816
+ void runCoverageLoadHooks(namedServices(project.environment)).catch((err) => {
1817
+ // eslint-disable-next-line no-console
1818
+ console.warn("[coverage] load hook failed:", err);
1819
+ });
1817
1820
  if (!project.fakes) return;
1818
1821
  for (const [name, def] of Object.entries(project.fakes)) {
1819
1822
  FAKES.set(name, {
@@ -2797,7 +2800,9 @@ const RUNTIME_SERVICES = new Map<string, NamedService>();
2797
2800
  // the orchestration helpers want a NamedService, which is the same shape.
2798
2801
  function specToNamedService(spec: RuntimeServiceSpec): NamedService {
2799
2802
  const { name, ...rest } = spec;
2800
- return { name, ...(rest as ServiceConfig) };
2803
+ // A runtime spec skipped `defineEnvironment`, so its coverage adapters
2804
+ // get their config-time pass here.
2805
+ return { name, ...applyCoverageAdapters(name, rest as ServiceConfig) };
2801
2806
  }
2802
2807
 
2803
2808
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
@@ -2811,6 +2816,7 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2811
2816
  const t0 = Date.now();
2812
2817
  const resv = reserveEvent();
2813
2818
  const svc = specToNamedService(spec);
2819
+ await runCoverageLoadHooks([svc]);
2814
2820
  const aliases = (spec.hostnames ?? []).map((h) => h.toLowerCase());
2815
2821
  const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
2816
2822
  try {
@@ -3601,34 +3607,53 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
3601
3607
  }
3602
3608
 
3603
3609
  // ────────────────────────────────────────────────────────────────────────
3604
- // Coverage capture (opt-in per service; see harness/coverage.ts)
3610
+ // Coverage capture (opt-in per service; see coverage.ts + harness/coverage.ts)
3605
3611
  // ────────────────────────────────────────────────────────────────────────
3606
3612
  //
3607
- // Read after bring-up and after every test, on every case. The reports are
3608
- // cumulative from process start and live on the rootfs, so what a child
3609
- // fork reads already includes everything its ancestors ran — no markers,
3610
- // no deltas (unlike the service logs above). A `command` runs in the
3611
- // container first so the service writes a fresh report. The reports ship
3612
- // verbatim; the server derives what it needs from the stored bytes.
3613
-
3614
- const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
3615
-
3616
- /** Set the browser-coverage host→service attribution from the loaded
3617
- * project: only services that opted in to `coverage: { browser: true }`
3618
- * are attributed, so a script from an uncovered service is ignored. Null
3619
- * when no service opted in — coverage then attaches nothing. Called on
3620
- * every /load. */
3621
- function configureCoverageAttribution(project: Project): void {
3622
- const env = project.environment;
3623
- const browserServices = new Set<string>();
3624
- for (const svc of namedServices(env)) {
3625
- if (coverageUsesBrowser(svc.coverage)) browserServices.add(svc.name);
3613
+ // Read after bring-up and after every test, on every case. A service's
3614
+ // adapters run first (each makes its report appear in the directory —
3615
+ // `nodeCoverage` asks the hook for a dump, `browserCoverage` writes the
3616
+ // harness's totals, `commandCoverage` runs the command), then the
3617
+ // directory is read the same way for every service, `coverage: true`
3618
+ // included. The reports live on the rootfs, so what a child fork reads
3619
+ // already includes everything its ancestors ran — no markers, no deltas
3620
+ // (unlike the service logs above). The reports ship verbatim; the server
3621
+ // derives what it needs from the stored bytes.
3622
+
3623
+ /** Budget for one service's adapters at one capture, all of them. */
3624
+ const COVERAGE_CAPTURE_TIMEOUT_MS = 60_000;
3625
+
3626
+ /** Services whose adapters asked for browser scripts at /load
3627
+ * (`CoverageLoadContext.collectBrowserScripts`). Cleared on every /load;
3628
+ * a runtime service's adapters add to it when it starts. */
3629
+ const BROWSER_COVERAGE_SERVICES = new Set<string>();
3630
+
3631
+ /** Run every adapter's `load` hook for `services`, then rebuild the
3632
+ * browser-coverage attribution from whoever asked. The attribution is a
3633
+ * host → service resolver over the lowered ingress, restricted to those
3634
+ * services, so a script from an uncovered service is ignored; null when
3635
+ * nobody asked, which costs nothing. */
3636
+ async function runCoverageLoadHooks(services: NamedService[]): Promise<void> {
3637
+ for (const svc of services) {
3638
+ for (const a of coverageAdapters(svc.coverage)) {
3639
+ if (!a.load) continue;
3640
+ await a.load({
3641
+ service: svc.name,
3642
+ collectBrowserScripts: () => {
3643
+ BROWSER_COVERAGE_SERVICES.add(svc.name);
3644
+ },
3645
+ });
3646
+ }
3626
3647
  }
3627
- if (browserServices.size === 0) {
3648
+ rebuildBrowserCoverageResolver();
3649
+ }
3650
+
3651
+ function rebuildBrowserCoverageResolver(): void {
3652
+ if (BROWSER_COVERAGE_SERVICES.size === 0 || !loaded) {
3628
3653
  configureBrowserCoverage(null);
3629
3654
  return;
3630
3655
  }
3631
- const all = namedServices(env).map((s) => s.name);
3656
+ const all = [...namedServices(loaded.project.environment).map((s) => s.name), ...RUNTIME_SERVICES.keys()];
3632
3657
  const table = {
3633
3658
  proxies: LOWERED.proxies,
3634
3659
  aliasesByService: LOWERED.aliasesByService,
@@ -3637,11 +3662,72 @@ function configureCoverageAttribution(project: Project): void {
3637
3662
  configureBrowserCoverage({
3638
3663
  serviceForHost(host: string): string | undefined {
3639
3664
  const svc = hostToService(host, all, table);
3640
- return svc && browserServices.has(svc) ? svc : undefined;
3665
+ return svc && BROWSER_COVERAGE_SERVICES.has(svc) ? svc : undefined;
3641
3666
  },
3642
3667
  });
3643
3668
  }
3644
3669
 
3670
+ /** The context one adapter's `capture` runs against. */
3671
+ function coverageCaptureContext(svc: NamedService, signal: AbortSignal): CoverageCaptureContext {
3672
+ const reportDir = coverageHostDir(WORKSPACE, svc.name);
3673
+ return {
3674
+ service: svc.name,
3675
+ reportDir,
3676
+ signal,
3677
+ async exec(command: string) {
3678
+ const r = await docker(["exec", svc.name, "sh", "-c", command], COVERAGE_CAPTURE_TIMEOUT_MS);
3679
+ if (r.code !== 0) {
3680
+ const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
3681
+ throw new Error(`${JSON.stringify(command)} exited ${r.code}${tail ? `\n${tail}` : ""}`);
3682
+ }
3683
+ return { stdout: r.stdout, stderr: r.stderr };
3684
+ },
3685
+ async writeReport(name: string, content: string) {
3686
+ const file = path.join(reportDir, name);
3687
+ if (path.relative(reportDir, file).startsWith("..")) {
3688
+ throw new Error(`writeReport: ${JSON.stringify(name)} leaves the coverage directory`);
3689
+ }
3690
+ await fs.mkdir(path.dirname(file), { recursive: true });
3691
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
3692
+ await fs.writeFile(tmp, content);
3693
+ await fs.rename(tmp, file);
3694
+ },
3695
+ };
3696
+ }
3697
+
3698
+ /** 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> {
3701
+ const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
3702
+ if (adapters.length === 0) return undefined;
3703
+ const ac = new AbortController();
3704
+ const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
3705
+ const ctx = coverageCaptureContext(svc, ac.signal);
3706
+ try {
3707
+ for (const a of adapters) {
3708
+ try {
3709
+ await Promise.race([
3710
+ (a as Required<Pick<CoverageAdapter, "capture">>).capture(ctx),
3711
+ new Promise<never>((_, reject) => {
3712
+ ac.signal.addEventListener(
3713
+ "abort",
3714
+ () => reject(new Error(`no report within ${COVERAGE_CAPTURE_TIMEOUT_MS / 1000}s`)),
3715
+ { once: true },
3716
+ );
3717
+ }),
3718
+ ]);
3719
+ } catch (err) {
3720
+ return `coverage adapter "${a.name}" on service "${svc.name}" failed: ${
3721
+ err instanceof Error ? err.message : String(err)
3722
+ }`;
3723
+ }
3724
+ }
3725
+ return undefined;
3726
+ } finally {
3727
+ clearTimeout(timer);
3728
+ }
3729
+ }
3730
+
3645
3731
  async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
3646
3732
  const l = loaded;
3647
3733
  if (!l) return [];
@@ -3649,48 +3735,11 @@ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
3649
3735
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
3650
3736
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
3651
3737
  const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3652
- // Browser coverage is per-service lcov, accumulated in module memory
3653
- // (browser-coverage.ts) across every harvest; read it once here.
3654
- const browser = browserCoverageReports();
3655
3738
  return Promise.all(
3656
3739
  services.map(async (svc): Promise<ServiceCoverageCapture> => {
3657
- const cov = svc.coverage;
3658
- const reports: CoverageReport[] = [];
3659
- // Container reports (a `command` runs first to flush a fresh dump).
3660
- if (coverageUsesContainer(cov)) {
3661
- if (typeof cov === "object" && cov !== null && cov.command) {
3662
- const r = await docker(
3663
- ["exec", svc.name, "sh", "-c", cov.command],
3664
- COVERAGE_COMMAND_TIMEOUT_MS,
3665
- );
3666
- if (r.code !== 0) {
3667
- const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
3668
- return {
3669
- service: svc.name,
3670
- reports: [],
3671
- error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
3672
- };
3673
- }
3674
- }
3675
- const dir = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3676
- // A service that ALSO does browser coverage may legitimately have
3677
- // an empty container dir for a test — don't fail if the browser
3678
- // side has something. Otherwise the container contract stands.
3679
- if (dir.error && !(coverageUsesBrowser(cov) && browser.has(svc.name))) {
3680
- return dir;
3681
- }
3682
- reports.push(...dir.reports);
3683
- }
3684
- // Browser report for this service, when any script it served ran.
3685
- const lcov = browser.get(svc.name);
3686
- if (lcov) reports.push({ name: "browser.lcov", content: lcov });
3687
- if (reports.length === 0 && coverageUsesBrowser(cov) && !coverageUsesContainer(cov)) {
3688
- // Browser-only service, no browser activity yet this branch. Not an
3689
- // error — a test needn't open a browser; the report appears once
3690
- // one does (and forks to descendants from there).
3691
- return { service: svc.name, reports: [] };
3692
- }
3693
- return { service: svc.name, reports };
3740
+ const error = await runCoverageAdapters(svc);
3741
+ if (error) return { service: svc.name, reports: [], error };
3742
+ return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3694
3743
  }),
3695
3744
  );
3696
3745
  }
@@ -6143,7 +6192,19 @@ export function harnessMethods(state: RouteState): MethodTable {
6143
6192
  // the control plane logs it.
6144
6193
  let coverage: CoverageBundleRef | undefined;
6145
6194
  try {
6146
- coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
6195
+ const captures = await captureServiceCoverage();
6196
+ for (const c of captures) {
6197
+ // After bring-up the code that started the service has run, so a
6198
+ // report set that names no file at all is a misconfigured tool —
6199
+ // visible in the boot log, never a failure (there is no case).
6200
+ if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
6201
+ // eslint-disable-next-line no-console
6202
+ console.warn(
6203
+ `[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`,
6204
+ );
6205
+ }
6206
+ }
6207
+ coverage = parkCoverageBundle(SETUP_CASE_ID, captures);
6147
6208
  } catch (err) {
6148
6209
  // eslint-disable-next-line no-console
6149
6210
  console.warn("[coverage] baseline capture failed:", err);