@specific.dev/spectest 0.56.1 → 0.57.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.
@@ -36,9 +36,12 @@ export interface SupabaseOptions {
36
36
  /**
37
37
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
38
38
  * daemon's CA-trusted TLS reverse proxy (in addition to the plain
39
- * `http://<name>:8000`). Set this only when the app under test hardcodes a
40
- * specific Supabase URL it can't be told to override — otherwise leave it and
41
- * point the app at `sb.url`.
39
+ * `http://<name>:8000`). `sb.url` and `sb.appEnv` then point at the HTTPS
40
+ * address. Use TLS wherever you can: set this when a browser reaches the
41
+ * gateway, because a page that is open over `https://` cannot call an
42
+ * `http://` origin (Chromium blocks the request before it leaves the
43
+ * browser, and the server logs show nothing). Also set it when the app under
44
+ * test hardcodes a Supabase URL it cannot be told to override.
42
45
  */
43
46
  hostname?: string;
44
47
  /**
package/dist/daemon.js CHANGED
@@ -21,7 +21,8 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
21
21
  import net from "node:net";
22
22
  import path from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
- import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
24
+ import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, validateCoverage, } from "./index.js";
25
+ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
25
26
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
26
27
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
27
28
  // Pure ingress hostname matching, ported out of this file (see
@@ -510,6 +511,23 @@ async function ensureVolumes(svc) {
510
511
  }
511
512
  return flags;
512
513
  }
514
+ /**
515
+ * The coverage directory for an opted-in service: one host dir per service
516
+ * under the workspace (so the delta-restore teardown wipes it), mounted
517
+ * writable at `/spectest/coverage/`. World-writable because the container
518
+ * may run as any user and spectest creates the dir as root. The reports in
519
+ * it ride snapshots and forks like everything else on the rootfs, which is
520
+ * what makes each read cumulative along the test's ancestor chain.
521
+ */
522
+ async function ensureCoverage(svc) {
523
+ if (svc.coverage === undefined)
524
+ return [];
525
+ validateCoverage(svc.name, svc.coverage);
526
+ const host = coverageHostDir(WORKSPACE, svc.name);
527
+ await fs.mkdir(host, { recursive: true });
528
+ await fs.chmod(host, 0o777);
529
+ return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
530
+ }
513
531
  /** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
514
532
  * new content every rebuild, and a stale uid is silently wrong. */
515
533
  const IMAGE_ID_TABLES = new Map();
@@ -2309,6 +2327,7 @@ async function startRuntimeService(spec) {
2309
2327
  ...(await ensureVolumes(svc)),
2310
2328
  ...(await ensureFiles(svc, tag)),
2311
2329
  ...(await ensureCertificates(svc, tag)),
2330
+ ...(await ensureCoverage(svc)),
2312
2331
  ];
2313
2332
  await runContainer(svc, tag, flags, aliases);
2314
2333
  await waitForReady(svc);
@@ -2707,6 +2726,7 @@ async function bootstrapInner() {
2707
2726
  ...(await ensureVolumes(svc)),
2708
2727
  ...(await ensureFiles(svc, tag)),
2709
2728
  ...(await ensureCertificates(svc, tag)),
2729
+ ...(await ensureCoverage(svc)),
2710
2730
  ];
2711
2731
  const tRun = Date.now();
2712
2732
  progressService(svc.name, { status: "starting", detail: undefined });
@@ -2827,18 +2847,37 @@ async function runProjectSetupInner() {
2827
2847
  // bundle in guest RAM — entries older than the last few are always
2828
2848
  // already-fetched leftovers frozen into some snapshot.
2829
2849
  const REPLAY_BUNDLES = new Map();
2830
- const REPLAY_BUNDLES_MAX = 8;
2831
- function stashReplayBundle(caseId, gz) {
2850
+ /** Coverage bundles, same side-channel and same lifetime rules; pulled
2851
+ * through `coverageChunk`. The env-setup baseline parks under the
2852
+ * control plane's synthetic `__setup__` case id. */
2853
+ const COVERAGE_BUNDLES = new Map();
2854
+ const PARKED_BUNDLES_MAX = 8;
2855
+ /** The control plane's synthetic case id for env-setup captures
2856
+ * (`storage::SETUP_CASE_ID`). Not a real case id — those are kebab slugs. */
2857
+ const SETUP_CASE_ID = "__setup__";
2858
+ function stashBundle(map, caseId, gz) {
2832
2859
  // Re-insert to refresh recency (Map iterates in insertion order).
2833
- REPLAY_BUNDLES.delete(caseId);
2834
- REPLAY_BUNDLES.set(caseId, gz);
2835
- while (REPLAY_BUNDLES.size > REPLAY_BUNDLES_MAX) {
2836
- const oldest = REPLAY_BUNDLES.keys().next().value;
2860
+ map.delete(caseId);
2861
+ map.set(caseId, gz);
2862
+ while (map.size > PARKED_BUNDLES_MAX) {
2863
+ const oldest = map.keys().next().value;
2837
2864
  if (oldest === undefined)
2838
2865
  break;
2839
- REPLAY_BUNDLES.delete(oldest);
2866
+ map.delete(oldest);
2840
2867
  }
2841
2868
  }
2869
+ function stashReplayBundle(caseId, gz) {
2870
+ stashBundle(REPLAY_BUNDLES, caseId, gz);
2871
+ }
2872
+ /** Encode + park a case's coverage captures; returns the ref for the reply.
2873
+ * `undefined` when no service opted in, so uncovered projects ship nothing. */
2874
+ function parkCoverageBundle(caseId, captures) {
2875
+ if (captures.length === 0)
2876
+ return undefined;
2877
+ const gz = encodeCoverageBundle(caseId, captures);
2878
+ stashBundle(COVERAGE_BUNDLES, caseId, gz);
2879
+ return coverageBundleRef(gz, captures);
2880
+ }
2842
2881
  // ────────────────────────────────────────────────────────────────────────
2843
2882
  // Cumulative service logs (per-case deltas → S3, reconstructed on the web)
2844
2883
  // ────────────────────────────────────────────────────────────────────────
@@ -2930,6 +2969,43 @@ async function captureServiceLogDeltas() {
2930
2969
  };
2931
2970
  }));
2932
2971
  }
2972
+ // ────────────────────────────────────────────────────────────────────────
2973
+ // Coverage capture (opt-in per service; see harness/coverage.ts)
2974
+ // ────────────────────────────────────────────────────────────────────────
2975
+ //
2976
+ // Read after bring-up and after every test, on every case. The reports are
2977
+ // cumulative from process start and live on the rootfs, so what a child
2978
+ // fork reads already includes everything its ancestors ran — no markers,
2979
+ // no deltas (unlike the service logs above). A `command` runs in the
2980
+ // container first so the service writes a fresh report. The reports ship
2981
+ // verbatim; the server derives what it needs from the stored bytes.
2982
+ const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
2983
+ async function captureServiceCoverage() {
2984
+ const l = loaded;
2985
+ if (!l)
2986
+ return [];
2987
+ const byName = new Map();
2988
+ for (const s of namedServices(l.project.environment))
2989
+ byName.set(s.name, s);
2990
+ for (const [name, s] of RUNTIME_SERVICES)
2991
+ byName.set(name, s);
2992
+ const services = [...byName.values()].filter((s) => s.coverage !== undefined);
2993
+ return Promise.all(services.map(async (svc) => {
2994
+ const cov = svc.coverage;
2995
+ if (typeof cov === "object" && cov !== null) {
2996
+ const r = await docker(["exec", svc.name, "sh", "-c", cov.command], COVERAGE_COMMAND_TIMEOUT_MS);
2997
+ if (r.code !== 0) {
2998
+ const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
2999
+ return {
3000
+ service: svc.name,
3001
+ reports: [],
3002
+ error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
3003
+ };
3004
+ }
3005
+ }
3006
+ return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3007
+ }));
3008
+ }
2933
3009
  /**
2934
3010
  * Mint a session id. `idScope` (the running test's case id; `"eval"`
2935
3011
  * for eval-context sessions) is baked in because `randomUUID()` alone
@@ -4128,6 +4204,33 @@ async function runOne(testCase) {
4128
4204
  // eslint-disable-next-line no-console
4129
4205
  console.warn("[service-logs] delta capture failed:", err);
4130
4206
  }
4207
+ // Coverage for the opted-in services, read after the clock stops like
4208
+ // the log deltas. Unlike them it is NOT best-effort: a service that opted
4209
+ // in and produced nothing readable fails the case, so a hole in the
4210
+ // coverage data is visible where it happened rather than silently
4211
+ // widening every later subset run.
4212
+ let coverageCaptures = [];
4213
+ try {
4214
+ coverageCaptures = await captureServiceCoverage();
4215
+ }
4216
+ catch (err) {
4217
+ coverageCaptures = [
4218
+ {
4219
+ service: "*",
4220
+ reports: [],
4221
+ error: `coverage capture failed: ${err instanceof Error ? err.message : String(err)}`,
4222
+ },
4223
+ ];
4224
+ }
4225
+ const coverageErrors = coverageCaptures
4226
+ .filter((c) => c.error)
4227
+ .map((c) => c.error);
4228
+ if (coverageErrors.length > 0 && outcome.status === "passed") {
4229
+ outcome = {
4230
+ status: "failed",
4231
+ error: { message: coverageErrors.join("\n") },
4232
+ };
4233
+ }
4131
4234
  const events = stopRecording();
4132
4235
  // Drop sessions whose linking event didn't survive — an exec/terminal
4133
4236
  // inside a failed `ctx.poll` iteration has its events removed by
@@ -4147,6 +4250,7 @@ async function runOne(testCase) {
4147
4250
  browserSessions: sessions.map((s) => s.record),
4148
4251
  terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
4149
4252
  serviceLogDeltas,
4253
+ coverageCaptures,
4150
4254
  error: outcome.error,
4151
4255
  };
4152
4256
  }
@@ -4898,7 +5002,20 @@ export function harnessMethods(state) {
4898
5002
  // eslint-disable-next-line no-console
4899
5003
  console.warn("[service-logs] baseline capture failed:", err);
4900
5004
  }
4901
- return { serviceLogDeltas };
5005
+ // The coverage baseline rides the same call: what the opted-in
5006
+ // services ran during bring-up + project setup, parked under the
5007
+ // synthetic `__setup__` case for `coverageChunk`. A capture error is
5008
+ // reported in the ref, not thrown — there is no case to fail here,
5009
+ // the control plane logs it.
5010
+ let coverage;
5011
+ try {
5012
+ coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
5013
+ }
5014
+ catch (err) {
5015
+ // eslint-disable-next-line no-console
5016
+ console.warn("[coverage] baseline capture failed:", err);
5017
+ }
5018
+ return { serviceLogDeltas, coverage };
4902
5019
  },
4903
5020
  run: async (params) => {
4904
5021
  const caseId = requireString(params, "caseId", "caseId");
@@ -4912,7 +5029,10 @@ export function harnessMethods(state) {
4912
5029
  // (with a warning) rather than failing the case — and never falls
4913
5030
  // back to inlining, which is exactly the oversized reply this path
4914
5031
  // exists to avoid.
4915
- const { browserSessions, ...wire } = result;
5032
+ const { browserSessions, coverageCaptures, ...wire } = result;
5033
+ // Coverage reports take the same side channel: park, reply with a
5034
+ // ref, let the control plane pull through `coverageChunk`.
5035
+ const coverage = parkCoverageBundle(caseId, coverageCaptures);
4916
5036
  let replay;
4917
5037
  if (browserSessions.length > 0) {
4918
5038
  try {
@@ -4925,7 +5045,7 @@ export function harnessMethods(state) {
4925
5045
  console.warn("[replay] bundle encode failed; dropping recordings:", err);
4926
5046
  }
4927
5047
  }
4928
- return { ...wire, replay };
5048
+ return { ...wire, replay, coverage };
4929
5049
  });
4930
5050
  },
4931
5051
  // One chunk of a parked replay bundle. Case ids are arbitrary user
@@ -4937,6 +5057,14 @@ export function harnessMethods(state) {
4937
5057
  throw notFound(`no replay bundle for case: ${caseId}`);
4938
5058
  return replayChunk(gz, params.offset, params.limit);
4939
5059
  },
5060
+ // One chunk of a parked coverage bundle (same shape as replayChunk).
5061
+ coverageChunk: async (params) => {
5062
+ const caseId = requireString(params, "caseId", "caseId");
5063
+ const gz = COVERAGE_BUNDLES.get(caseId);
5064
+ if (!gz)
5065
+ throw notFound(`no coverage bundle for case: ${caseId}`);
5066
+ return replayChunk(gz, params.offset, params.limit);
5067
+ },
4940
5068
  };
4941
5069
  }
4942
5070
  /**
@@ -0,0 +1,60 @@
1
+ /** Where the directory is mounted inside an opted-in container. */
2
+ export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
3
+ /** Raw bytes of reports one service may ship per capture. Reports are
4
+ * cumulative and bounded by code size, so anything past this is a
5
+ * runaway (a tool writing a new file per request), not coverage. */
6
+ export declare const COVERAGE_MAX_BYTES_PER_SERVICE: number;
7
+ /** Root of the per-service host directories, under the workspace so the
8
+ * delta-restore teardown wipes them with everything else. */
9
+ export declare function coverageHostDir(workspace: string, service: string): string;
10
+ /** One report file, verbatim. `name` is its path relative to the
11
+ * coverage directory. */
12
+ export interface CoverageReport {
13
+ name: string;
14
+ content: string;
15
+ }
16
+ /** One service's capture: every report in its directory, verbatim. */
17
+ export interface ServiceCoverageCapture {
18
+ service: string;
19
+ reports: CoverageReport[];
20
+ /** Set when the capture failed: the command failed, the directory was
21
+ * empty, no file held an `SF:` record, or the reports were too large.
22
+ * A case with an error here fails — a hole in the coverage data must
23
+ * never be silent. */
24
+ error?: string;
25
+ }
26
+ /** The small ref that rides the `run` reply in place of the bundle. */
27
+ export interface CoverageBundleRef {
28
+ /** Gzipped bundle size; the control plane pulls exactly this many bytes
29
+ * through `coverageChunk`. */
30
+ bytes: number;
31
+ services: {
32
+ service: string;
33
+ reports: number;
34
+ /** Raw (uncompressed) bytes of this service's reports. */
35
+ bytes: number;
36
+ error?: string;
37
+ }[];
38
+ }
39
+ /** The gzipped JSON document `coverageChunk` serves. Mirrors
40
+ * `storage::CaseCoverageBundle` on the server. */
41
+ export interface CoverageBundle {
42
+ caseId: string;
43
+ services: ServiceCoverageCapture[];
44
+ }
45
+ /** True when `text` holds at least one lcov `SF:` record. */
46
+ export declare function hasLcovSourceFile(text: string): boolean;
47
+ /** Every regular file under `dir`, recursively, as paths relative to it.
48
+ * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
49
+ * a tmp file mid-write — is not a report). */
50
+ export declare function listReportFiles(dir: string): Promise<string[]>;
51
+ /**
52
+ * Read every report under `dir`, verbatim. An empty directory, or one with
53
+ * no `SF:` record anywhere, is an error rather than an empty capture: the
54
+ * contract is that an opted-in service always has a report to read.
55
+ */
56
+ export declare function readCoverageDir(service: string, dir: string): Promise<ServiceCoverageCapture>;
57
+ /** Gzip a case's captures into the bundle `coverageChunk` serves. */
58
+ export declare function encodeCoverageBundle(caseId: string, services: ServiceCoverageCapture[]): Buffer;
59
+ /** The ref for a bundle: its size plus a per-service summary. */
60
+ export declare function coverageBundleRef(gz: Buffer, services: ServiceCoverageCapture[]): CoverageBundleRef;
@@ -0,0 +1,122 @@
1
+ // Coverage collection — the pure part.
2
+ //
3
+ // A service that opts in (`coverage: true | { command }`) gets a writable
4
+ // directory bind-mounted at `/spectest/coverage/` in its container. The
5
+ // service's own tool writes lcov reports there. The daemon reads the
6
+ // directory from the VM side (no `docker exec` to fetch) after bring-up
7
+ // and after each test, and ships the reports **verbatim** to the control
8
+ // plane as a gzipped bundle. Nothing is interpreted here beyond the one
9
+ // contract the harness must fail a case on: at least one report, and at
10
+ // least one lcov `SF:` record among them. The server derives whatever it
11
+ // needs (today the set of source files; later, finer things) from the
12
+ // stored bytes, so a change in that derivation is a re-read of stored
13
+ // runs, never a hole in the history.
14
+ //
15
+ // The container side (mount flag, `command` exec) is in `daemon.ts`.
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+ import { gzipSync } from "node:zlib";
19
+ /** Where the directory is mounted inside an opted-in container. */
20
+ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
21
+ /** Raw bytes of reports one service may ship per capture. Reports are
22
+ * cumulative and bounded by code size, so anything past this is a
23
+ * runaway (a tool writing a new file per request), not coverage. */
24
+ export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
25
+ /** Root of the per-service host directories, under the workspace so the
26
+ * delta-restore teardown wipes them with everything else. */
27
+ export function coverageHostDir(workspace, service) {
28
+ return path.join(workspace, ".spectest", "coverage", service);
29
+ }
30
+ /** True when `text` holds at least one lcov `SF:` record. */
31
+ export function hasLcovSourceFile(text) {
32
+ return /^SF:\S/m.test(text);
33
+ }
34
+ /** Every regular file under `dir`, recursively, as paths relative to it.
35
+ * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
36
+ * a tmp file mid-write — is not a report). */
37
+ export async function listReportFiles(dir) {
38
+ const out = [];
39
+ async function walk(d, rel) {
40
+ let entries;
41
+ try {
42
+ entries = await fs.readdir(d, { withFileTypes: true });
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ for (const e of entries) {
48
+ if (e.name.startsWith("."))
49
+ continue;
50
+ const r = rel ? `${rel}/${e.name}` : e.name;
51
+ if (e.isDirectory())
52
+ await walk(path.join(d, e.name), r);
53
+ else if (e.isFile())
54
+ out.push(r);
55
+ }
56
+ }
57
+ await walk(dir, "");
58
+ return out.sort();
59
+ }
60
+ /**
61
+ * Read every report under `dir`, verbatim. An empty directory, or one with
62
+ * no `SF:` record anywhere, is an error rather than an empty capture: the
63
+ * contract is that an opted-in service always has a report to read.
64
+ */
65
+ export async function readCoverageDir(service, dir) {
66
+ const names = await listReportFiles(dir);
67
+ if (names.length === 0) {
68
+ return {
69
+ service,
70
+ reports: [],
71
+ error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov file there`,
72
+ };
73
+ }
74
+ const reports = [];
75
+ let total = 0;
76
+ let anyLcov = false;
77
+ for (const name of names) {
78
+ let content;
79
+ try {
80
+ content = await fs.readFile(path.join(dir, name), "utf8");
81
+ }
82
+ catch {
83
+ continue;
84
+ }
85
+ total += content.length;
86
+ if (total > COVERAGE_MAX_BYTES_PER_SERVICE) {
87
+ return {
88
+ service,
89
+ reports: [],
90
+ error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — reports must be cumulative, not one file per dump`,
91
+ };
92
+ }
93
+ if (hasLcovSourceFile(content))
94
+ anyLcov = true;
95
+ reports.push({ name, content });
96
+ }
97
+ if (!anyLcov) {
98
+ return {
99
+ service,
100
+ reports,
101
+ error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none holds an lcov \`SF:\` record — only lcov reports are read`,
102
+ };
103
+ }
104
+ return { service, reports };
105
+ }
106
+ /** Gzip a case's captures into the bundle `coverageChunk` serves. */
107
+ export function encodeCoverageBundle(caseId, services) {
108
+ const doc = { caseId, services };
109
+ return gzipSync(Buffer.from(JSON.stringify(doc)));
110
+ }
111
+ /** The ref for a bundle: its size plus a per-service summary. */
112
+ export function coverageBundleRef(gz, services) {
113
+ return {
114
+ bytes: gz.length,
115
+ services: services.map((s) => ({
116
+ service: s.service,
117
+ reports: s.reports.length,
118
+ bytes: s.reports.reduce((n, r) => n + r.content.length, 0),
119
+ ...(s.error ? { error: s.error } : {}),
120
+ })),
121
+ };
122
+ }
package/dist/index.d.ts CHANGED
@@ -115,6 +115,27 @@ export interface ServiceConfig {
115
115
  tls?: readonly ServiceTls[];
116
116
  /** Bind-mounted volumes for state that survives snapshot/fork. */
117
117
  volumes?: readonly VolumeMount[];
118
+ /**
119
+ * Opt this service in to **code coverage collection** (experimental).
120
+ *
121
+ * The service gets a writable directory bind-mounted at
122
+ * `/spectest/coverage/`. The service's own coverage tool writes **lcov**
123
+ * reports there — cumulative from process start, never reset. After
124
+ * bring-up and after every test, spectest runs `command` (if given) inside
125
+ * the container, then reads every file in the directory and records the
126
+ * set of source files that ran. Only the `SF:` records are read.
127
+ *
128
+ * - `true`: the service writes its reports by itself (a dump on a timer,
129
+ * an exit-time dump for short-lived processes).
130
+ * - `{ command }`: run this (via `sh -c`) in the container to make the
131
+ * service write a report, before the directory is read.
132
+ *
133
+ * The directory must hold at least one lcov report each time it is read;
134
+ * an empty directory or a failed command fails the test with a clear
135
+ * error, so a hole in the coverage data is never silent. See
136
+ * `spectest docs /services/coverage`.
137
+ */
138
+ coverage?: ServiceCoverage;
118
139
  /**
119
140
  * Files seeded into the container's filesystem **before it starts**.
120
141
  * Each entry's `content` is written to a VM-host staging path and
@@ -642,6 +663,14 @@ export type ServiceImage = {
642
663
  /** Extra glob patterns to exclude from the build context. */
643
664
  exclude?: readonly string[];
644
665
  };
666
+ /**
667
+ * A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
668
+ * `true` when the service writes its own reports; `{ command }` when
669
+ * spectest must run a command in the container to produce one.
670
+ */
671
+ export type ServiceCoverage = true | {
672
+ command: string;
673
+ };
645
674
  export interface VolumeMount {
646
675
  /**
647
676
  * Named shared volume. Two services mounting the same `name` share one
@@ -854,6 +883,11 @@ export interface FakeContext {
854
883
  * for a TLS-provisioning provider hands back to the app under test. */
855
884
  certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
856
885
  }
886
+ /**
887
+ * Check one service's `coverage` field. Exported so the daemon applies the
888
+ * same rule to a runtime `startService` spec.
889
+ */
890
+ export declare function validateCoverage(service: string, cov: unknown): void;
857
891
  /**
858
892
  * A single test step. Created via `test(...)` or `createTest(services)`.
859
893
  * Tests are referenced (not named by string) when one test depends on
package/dist/index.js CHANGED
@@ -215,6 +215,21 @@ function expandServiceGroups(input) {
215
215
  }
216
216
  return out;
217
217
  }
218
+ /**
219
+ * Check one service's `coverage` field. Exported so the daemon applies the
220
+ * same rule to a runtime `startService` spec.
221
+ */
222
+ export function validateCoverage(service, cov) {
223
+ if (cov === undefined || cov === true)
224
+ return;
225
+ if (typeof cov === "object" &&
226
+ cov !== null &&
227
+ typeof cov.command === "string" &&
228
+ cov.command.trim().length > 0) {
229
+ return;
230
+ }
231
+ throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``);
232
+ }
218
233
  function validateEnvironmentConfig(config) {
219
234
  const entries = Object.entries(config.services);
220
235
  const serviceNames = new Set(entries.map(([n]) => n));
@@ -246,6 +261,7 @@ function validateEnvironmentConfig(config) {
246
261
  throw new Error(`service "${name}" volume name ${JSON.stringify(vol.name)} must be alphanumeric plus [._-]`);
247
262
  }
248
263
  }
264
+ validateCoverage(name, svc.coverage);
249
265
  for (const raw of svc.hostnames ?? []) {
250
266
  const h = raw.toLowerCase();
251
267
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.56.1",
3
+ "version": "0.57.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -239,9 +239,12 @@ export interface SupabaseOptions {
239
239
  /**
240
240
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
241
241
  * daemon's CA-trusted TLS reverse proxy (in addition to the plain
242
- * `http://<name>:8000`). Set this only when the app under test hardcodes a
243
- * specific Supabase URL it can't be told to override — otherwise leave it and
244
- * point the app at `sb.url`.
242
+ * `http://<name>:8000`). `sb.url` and `sb.appEnv` then point at the HTTPS
243
+ * address. Use TLS wherever you can: set this when a browser reaches the
244
+ * gateway, because a page that is open over `https://` cannot call an
245
+ * `http://` origin (Chromium blocks the request before it leaves the
246
+ * browser, and the server logs show nothing). Also set it when the app under
247
+ * test hardcodes a Supabase URL it cannot be told to override.
245
248
  */
246
249
  hostname?: string;
247
250
 
package/src/daemon.ts CHANGED
@@ -32,8 +32,18 @@ import {
32
32
  dnsName as makeDnsDecl,
33
33
  isWildcard,
34
34
  proxy as makeProxyDecl,
35
+ validateCoverage,
35
36
  } from "./index.js";
36
37
  import type { DnsTarget, LoweredIngress } from "./index.js";
38
+ import {
39
+ COVERAGE_CONTAINER_DIR,
40
+ coverageBundleRef,
41
+ coverageHostDir,
42
+ encodeCoverageBundle,
43
+ readCoverageDir,
44
+ type CoverageBundleRef,
45
+ type ServiceCoverageCapture,
46
+ } from "./harness/coverage.js";
37
47
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
38
48
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
39
49
  // Pure ingress hostname matching, ported out of this file (see
@@ -756,6 +766,23 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
756
766
  return flags;
757
767
  }
758
768
 
769
+ /**
770
+ * The coverage directory for an opted-in service: one host dir per service
771
+ * under the workspace (so the delta-restore teardown wipes it), mounted
772
+ * writable at `/spectest/coverage/`. World-writable because the container
773
+ * may run as any user and spectest creates the dir as root. The reports in
774
+ * it ride snapshots and forks like everything else on the rootfs, which is
775
+ * what makes each read cumulative along the test's ancestor chain.
776
+ */
777
+ async function ensureCoverage(svc: NamedService): Promise<string[]> {
778
+ if (svc.coverage === undefined) return [];
779
+ validateCoverage(svc.name, svc.coverage);
780
+ const host = coverageHostDir(WORKSPACE, svc.name);
781
+ await fs.mkdir(host, { recursive: true });
782
+ await fs.chmod(host, 0o777);
783
+ return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
784
+ }
785
+
759
786
  /** A `user`/`group` pair resolved to the numeric ids the kernel wants.
760
787
  * `-1` is chown(2)'s "leave this one alone". */
761
788
  interface OwnerIds {
@@ -2780,6 +2807,7 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2780
2807
  ...(await ensureVolumes(svc)),
2781
2808
  ...(await ensureFiles(svc, tag)),
2782
2809
  ...(await ensureCertificates(svc, tag)),
2810
+ ...(await ensureCoverage(svc)),
2783
2811
  ];
2784
2812
  await runContainer(svc, tag, flags, aliases);
2785
2813
  await waitForReady(svc);
@@ -3228,6 +3256,7 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3228
3256
  ...(await ensureVolumes(svc)),
3229
3257
  ...(await ensureFiles(svc, tag)),
3230
3258
  ...(await ensureCertificates(svc, tag)),
3259
+ ...(await ensureCoverage(svc)),
3231
3260
  ];
3232
3261
  const tRun = Date.now();
3233
3262
  progressService(svc.name, { status: "starting", detail: undefined });
@@ -3369,6 +3398,16 @@ interface RunResult {
3369
3398
  * failing case's own delta. See {captureServiceLogDeltas}.
3370
3399
  */
3371
3400
  serviceLogDeltas: ServiceLogDelta[];
3401
+ /**
3402
+ * Per-service coverage for the services that opted in (`coverage` on
3403
+ * the service): their lcov reports, verbatim, read after this case.
3404
+ * Cumulative along the ancestor chain by construction. Like
3405
+ * `browserSessions` this never rides the `run` reply — the handler
3406
+ * parks a gzipped bundle and replies with a ref the control plane pulls
3407
+ * through `coverageChunk`. An entry with `error` fails the case. See
3408
+ * {captureServiceCoverage}.
3409
+ */
3410
+ coverageCaptures: ServiceCoverageCapture[];
3372
3411
  error?: { message: string; stack?: string };
3373
3412
  }
3374
3413
 
@@ -3391,19 +3430,42 @@ interface RunResult {
3391
3430
  // bundle in guest RAM — entries older than the last few are always
3392
3431
  // already-fetched leftovers frozen into some snapshot.
3393
3432
  const REPLAY_BUNDLES = new Map<string, Buffer>();
3394
- const REPLAY_BUNDLES_MAX = 8;
3395
-
3396
- function stashReplayBundle(caseId: string, gz: Buffer): void {
3433
+ /** Coverage bundles, same side-channel and same lifetime rules; pulled
3434
+ * through `coverageChunk`. The env-setup baseline parks under the
3435
+ * control plane's synthetic `__setup__` case id. */
3436
+ const COVERAGE_BUNDLES = new Map<string, Buffer>();
3437
+ const PARKED_BUNDLES_MAX = 8;
3438
+ /** The control plane's synthetic case id for env-setup captures
3439
+ * (`storage::SETUP_CASE_ID`). Not a real case id — those are kebab slugs. */
3440
+ const SETUP_CASE_ID = "__setup__";
3441
+
3442
+ function stashBundle(map: Map<string, Buffer>, caseId: string, gz: Buffer): void {
3397
3443
  // Re-insert to refresh recency (Map iterates in insertion order).
3398
- REPLAY_BUNDLES.delete(caseId);
3399
- REPLAY_BUNDLES.set(caseId, gz);
3400
- while (REPLAY_BUNDLES.size > REPLAY_BUNDLES_MAX) {
3401
- const oldest = REPLAY_BUNDLES.keys().next().value;
3444
+ map.delete(caseId);
3445
+ map.set(caseId, gz);
3446
+ while (map.size > PARKED_BUNDLES_MAX) {
3447
+ const oldest = map.keys().next().value;
3402
3448
  if (oldest === undefined) break;
3403
- REPLAY_BUNDLES.delete(oldest);
3449
+ map.delete(oldest);
3404
3450
  }
3405
3451
  }
3406
3452
 
3453
+ function stashReplayBundle(caseId: string, gz: Buffer): void {
3454
+ stashBundle(REPLAY_BUNDLES, caseId, gz);
3455
+ }
3456
+
3457
+ /** Encode + park a case's coverage captures; returns the ref for the reply.
3458
+ * `undefined` when no service opted in, so uncovered projects ship nothing. */
3459
+ function parkCoverageBundle(
3460
+ caseId: string,
3461
+ captures: ServiceCoverageCapture[],
3462
+ ): CoverageBundleRef | undefined {
3463
+ if (captures.length === 0) return undefined;
3464
+ const gz = encodeCoverageBundle(caseId, captures);
3465
+ stashBundle(COVERAGE_BUNDLES, caseId, gz);
3466
+ return coverageBundleRef(gz, captures);
3467
+ }
3468
+
3407
3469
  // ────────────────────────────────────────────────────────────────────────
3408
3470
  // Cumulative service logs (per-case deltas → S3, reconstructed on the web)
3409
3471
  // ────────────────────────────────────────────────────────────────────────
@@ -3526,6 +3588,48 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
3526
3588
  );
3527
3589
  }
3528
3590
 
3591
+ // ────────────────────────────────────────────────────────────────────────
3592
+ // Coverage capture (opt-in per service; see harness/coverage.ts)
3593
+ // ────────────────────────────────────────────────────────────────────────
3594
+ //
3595
+ // Read after bring-up and after every test, on every case. The reports are
3596
+ // cumulative from process start and live on the rootfs, so what a child
3597
+ // fork reads already includes everything its ancestors ran — no markers,
3598
+ // no deltas (unlike the service logs above). A `command` runs in the
3599
+ // container first so the service writes a fresh report. The reports ship
3600
+ // verbatim; the server derives what it needs from the stored bytes.
3601
+
3602
+ const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
3603
+
3604
+ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
3605
+ const l = loaded;
3606
+ if (!l) return [];
3607
+ const byName = new Map<string, NamedService>();
3608
+ for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
3609
+ for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
3610
+ const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3611
+ return Promise.all(
3612
+ services.map(async (svc): Promise<ServiceCoverageCapture> => {
3613
+ const cov = svc.coverage;
3614
+ if (typeof cov === "object" && cov !== null) {
3615
+ const r = await docker(
3616
+ ["exec", svc.name, "sh", "-c", cov.command],
3617
+ COVERAGE_COMMAND_TIMEOUT_MS,
3618
+ );
3619
+ if (r.code !== 0) {
3620
+ const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
3621
+ return {
3622
+ service: svc.name,
3623
+ reports: [],
3624
+ error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
3625
+ };
3626
+ }
3627
+ }
3628
+ return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3629
+ }),
3630
+ );
3631
+ }
3632
+
3529
3633
  /** Per-Browser rrweb session shipped to the control plane. */
3530
3634
  interface BrowserSessionRecord {
3531
3635
  sessionId: string;
@@ -5020,6 +5124,32 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5020
5124
  // eslint-disable-next-line no-console
5021
5125
  console.warn("[service-logs] delta capture failed:", err);
5022
5126
  }
5127
+ // Coverage for the opted-in services, read after the clock stops like
5128
+ // the log deltas. Unlike them it is NOT best-effort: a service that opted
5129
+ // in and produced nothing readable fails the case, so a hole in the
5130
+ // coverage data is visible where it happened rather than silently
5131
+ // widening every later subset run.
5132
+ let coverageCaptures: ServiceCoverageCapture[] = [];
5133
+ try {
5134
+ coverageCaptures = await captureServiceCoverage();
5135
+ } catch (err) {
5136
+ coverageCaptures = [
5137
+ {
5138
+ service: "*",
5139
+ reports: [],
5140
+ error: `coverage capture failed: ${err instanceof Error ? err.message : String(err)}`,
5141
+ },
5142
+ ];
5143
+ }
5144
+ const coverageErrors = coverageCaptures
5145
+ .filter((c) => c.error)
5146
+ .map((c) => c.error as string);
5147
+ if (coverageErrors.length > 0 && outcome.status === "passed") {
5148
+ outcome = {
5149
+ status: "failed",
5150
+ error: { message: coverageErrors.join("\n") },
5151
+ };
5152
+ }
5023
5153
  const events = stopRecording();
5024
5154
  // Drop sessions whose linking event didn't survive — an exec/terminal
5025
5155
  // inside a failed `ctx.poll` iteration has its events removed by
@@ -5038,6 +5168,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5038
5168
  browserSessions: sessions.map((s) => s.record),
5039
5169
  terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
5040
5170
  serviceLogDeltas,
5171
+ coverageCaptures,
5041
5172
  error: outcome.error,
5042
5173
  };
5043
5174
  }
@@ -5940,7 +6071,19 @@ export function harnessMethods(state: RouteState): MethodTable {
5940
6071
  // eslint-disable-next-line no-console
5941
6072
  console.warn("[service-logs] baseline capture failed:", err);
5942
6073
  }
5943
- return { serviceLogDeltas };
6074
+ // The coverage baseline rides the same call: what the opted-in
6075
+ // services ran during bring-up + project setup, parked under the
6076
+ // synthetic `__setup__` case for `coverageChunk`. A capture error is
6077
+ // reported in the ref, not thrown — there is no case to fail here,
6078
+ // the control plane logs it.
6079
+ let coverage: CoverageBundleRef | undefined;
6080
+ try {
6081
+ coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
6082
+ } catch (err) {
6083
+ // eslint-disable-next-line no-console
6084
+ console.warn("[coverage] baseline capture failed:", err);
6085
+ }
6086
+ return { serviceLogDeltas, coverage };
5944
6087
  },
5945
6088
 
5946
6089
  run: async (params) => {
@@ -5954,7 +6097,10 @@ export function harnessMethods(state: RouteState): MethodTable {
5954
6097
  // (with a warning) rather than failing the case — and never falls
5955
6098
  // back to inlining, which is exactly the oversized reply this path
5956
6099
  // exists to avoid.
5957
- const { browserSessions, ...wire } = result;
6100
+ const { browserSessions, coverageCaptures, ...wire } = result;
6101
+ // Coverage reports take the same side channel: park, reply with a
6102
+ // ref, let the control plane pull through `coverageChunk`.
6103
+ const coverage = parkCoverageBundle(caseId, coverageCaptures);
5958
6104
  let replay: { bytes: number } | undefined;
5959
6105
  if (browserSessions.length > 0) {
5960
6106
  try {
@@ -5966,7 +6112,7 @@ export function harnessMethods(state: RouteState): MethodTable {
5966
6112
  console.warn("[replay] bundle encode failed; dropping recordings:", err);
5967
6113
  }
5968
6114
  }
5969
- return { ...wire, replay };
6115
+ return { ...wire, replay, coverage };
5970
6116
  });
5971
6117
  },
5972
6118
 
@@ -5978,6 +6124,14 @@ export function harnessMethods(state: RouteState): MethodTable {
5978
6124
  if (!gz) throw notFound(`no replay bundle for case: ${caseId}`);
5979
6125
  return replayChunk(gz, params.offset as number | undefined, params.limit as number | undefined);
5980
6126
  },
6127
+
6128
+ // One chunk of a parked coverage bundle (same shape as replayChunk).
6129
+ coverageChunk: async (params) => {
6130
+ const caseId = requireString(params, "caseId", "caseId");
6131
+ const gz = COVERAGE_BUNDLES.get(caseId);
6132
+ if (!gz) throw notFound(`no coverage bundle for case: ${caseId}`);
6133
+ return replayChunk(gz, params.offset as number | undefined, params.limit as number | undefined);
6134
+ },
5981
6135
  };
5982
6136
  }
5983
6137
 
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { gunzipSync } from "node:zlib";
6
+ import {
7
+ coverageBundleRef,
8
+ coverageHostDir,
9
+ encodeCoverageBundle,
10
+ hasLcovSourceFile,
11
+ listReportFiles,
12
+ readCoverageDir,
13
+ } from "./coverage";
14
+
15
+ async function tmp(): Promise<string> {
16
+ return fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
17
+ }
18
+
19
+ describe("hasLcovSourceFile", () => {
20
+ test("finds an SF record anywhere, tolerating CRLF", () => {
21
+ expect(hasLcovSourceFile("TN:\r\nSF:/app/server.js\r\nDA:1,1\n")).toBe(true);
22
+ expect(hasLcovSourceFile("TN:\nSF:\nend_of_record\n")).toBe(false);
23
+ expect(hasLcovSourceFile("{}")).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("readCoverageDir", () => {
28
+ test("ships every report verbatim, nested, with relative names", async () => {
29
+ const dir = await tmp();
30
+ await fs.mkdir(path.join(dir, "sub"));
31
+ await fs.writeFile(path.join(dir, "lcov.info"), "SF:/app/b.js\nDA:1,1\nend_of_record\n");
32
+ await fs.writeFile(path.join(dir, "sub", "more.info"), "SF:/app/c.js\n");
33
+ await fs.writeFile(path.join(dir, ".seq"), "3");
34
+ const r = await readCoverageDir("web", dir);
35
+ expect(r.error).toBeUndefined();
36
+ expect(r.reports).toEqual([
37
+ { name: "lcov.info", content: "SF:/app/b.js\nDA:1,1\nend_of_record\n" },
38
+ { name: "sub/more.info", content: "SF:/app/c.js\n" },
39
+ ]);
40
+ });
41
+
42
+ test("an empty directory is an error, not an empty capture", async () => {
43
+ const dir = await tmp();
44
+ const r = await readCoverageDir("web", dir);
45
+ expect(r.reports).toEqual([]);
46
+ expect(r.error).toContain("holds no report");
47
+ });
48
+
49
+ test("a missing directory is reported like an empty one", async () => {
50
+ const r = await readCoverageDir("web", "/nonexistent/spectest-cov");
51
+ expect(r.error).toContain("holds no report");
52
+ });
53
+
54
+ test("files with no SF record are an error, but still shipped", async () => {
55
+ const dir = await tmp();
56
+ await fs.writeFile(path.join(dir, "coverage.json"), "{}");
57
+ const r = await readCoverageDir("web", dir);
58
+ expect(r.reports.length).toBe(1);
59
+ expect(r.error).toContain("SF:");
60
+ });
61
+
62
+ test("listReportFiles skips dotfiles", async () => {
63
+ const dir = await tmp();
64
+ await fs.writeFile(path.join(dir, ".lock"), "");
65
+ await fs.writeFile(path.join(dir, "a.info"), "");
66
+ expect(await listReportFiles(dir)).toEqual(["a.info"]);
67
+ });
68
+ });
69
+
70
+ test("bundle round-trips and the ref summarises it", () => {
71
+ const services = [
72
+ { service: "web", reports: [{ name: "lcov.info", content: "SF:/app/a.js\n" }] },
73
+ { service: "api", reports: [], error: "no report" },
74
+ ];
75
+ const gz = encodeCoverageBundle("case-1", services);
76
+ expect(JSON.parse(gunzipSync(gz).toString())).toEqual({ caseId: "case-1", services });
77
+ expect(coverageBundleRef(gz, services)).toEqual({
78
+ bytes: gz.length,
79
+ services: [
80
+ { service: "web", reports: 1, bytes: 13 },
81
+ { service: "api", reports: 0, bytes: 0, error: "no report" },
82
+ ],
83
+ });
84
+ });
85
+
86
+ test("coverageHostDir sits under the workspace's .spectest", () => {
87
+ expect(coverageHostDir("/workspace", "api")).toBe("/workspace/.spectest/coverage/api");
88
+ });
@@ -0,0 +1,173 @@
1
+ // Coverage collection — the pure part.
2
+ //
3
+ // A service that opts in (`coverage: true | { command }`) gets a writable
4
+ // directory bind-mounted at `/spectest/coverage/` in its container. The
5
+ // service's own tool writes lcov reports there. The daemon reads the
6
+ // directory from the VM side (no `docker exec` to fetch) after bring-up
7
+ // and after each test, and ships the reports **verbatim** to the control
8
+ // plane as a gzipped bundle. Nothing is interpreted here beyond the one
9
+ // contract the harness must fail a case on: at least one report, and at
10
+ // least one lcov `SF:` record among them. The server derives whatever it
11
+ // needs (today the set of source files; later, finer things) from the
12
+ // stored bytes, so a change in that derivation is a re-read of stored
13
+ // runs, never a hole in the history.
14
+ //
15
+ // The container side (mount flag, `command` exec) is in `daemon.ts`.
16
+
17
+ import { promises as fs } from "node:fs";
18
+ import path from "node:path";
19
+ import { gzipSync } from "node:zlib";
20
+
21
+ /** Where the directory is mounted inside an opted-in container. */
22
+ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
23
+
24
+ /** Raw bytes of reports one service may ship per capture. Reports are
25
+ * cumulative and bounded by code size, so anything past this is a
26
+ * runaway (a tool writing a new file per request), not coverage. */
27
+ export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
28
+
29
+ /** Root of the per-service host directories, under the workspace so the
30
+ * delta-restore teardown wipes them with everything else. */
31
+ export function coverageHostDir(workspace: string, service: string): string {
32
+ return path.join(workspace, ".spectest", "coverage", service);
33
+ }
34
+
35
+ /** One report file, verbatim. `name` is its path relative to the
36
+ * coverage directory. */
37
+ export interface CoverageReport {
38
+ name: string;
39
+ content: string;
40
+ }
41
+
42
+ /** One service's capture: every report in its directory, verbatim. */
43
+ export interface ServiceCoverageCapture {
44
+ service: string;
45
+ reports: CoverageReport[];
46
+ /** Set when the capture failed: the command failed, the directory was
47
+ * empty, no file held an `SF:` record, or the reports were too large.
48
+ * A case with an error here fails — a hole in the coverage data must
49
+ * never be silent. */
50
+ error?: string;
51
+ }
52
+
53
+ /** The small ref that rides the `run` reply in place of the bundle. */
54
+ export interface CoverageBundleRef {
55
+ /** Gzipped bundle size; the control plane pulls exactly this many bytes
56
+ * through `coverageChunk`. */
57
+ bytes: number;
58
+ services: {
59
+ service: string;
60
+ reports: number;
61
+ /** Raw (uncompressed) bytes of this service's reports. */
62
+ bytes: number;
63
+ error?: string;
64
+ }[];
65
+ }
66
+
67
+ /** The gzipped JSON document `coverageChunk` serves. Mirrors
68
+ * `storage::CaseCoverageBundle` on the server. */
69
+ export interface CoverageBundle {
70
+ caseId: string;
71
+ services: ServiceCoverageCapture[];
72
+ }
73
+
74
+ /** True when `text` holds at least one lcov `SF:` record. */
75
+ export function hasLcovSourceFile(text: string): boolean {
76
+ return /^SF:\S/m.test(text);
77
+ }
78
+
79
+ /** Every regular file under `dir`, recursively, as paths relative to it.
80
+ * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
81
+ * a tmp file mid-write — is not a report). */
82
+ export async function listReportFiles(dir: string): Promise<string[]> {
83
+ const out: string[] = [];
84
+ async function walk(d: string, rel: string): Promise<void> {
85
+ let entries;
86
+ try {
87
+ entries = await fs.readdir(d, { withFileTypes: true });
88
+ } catch {
89
+ return;
90
+ }
91
+ for (const e of entries) {
92
+ if (e.name.startsWith(".")) continue;
93
+ const r = rel ? `${rel}/${e.name}` : e.name;
94
+ if (e.isDirectory()) await walk(path.join(d, e.name), r);
95
+ else if (e.isFile()) out.push(r);
96
+ }
97
+ }
98
+ await walk(dir, "");
99
+ return out.sort();
100
+ }
101
+
102
+ /**
103
+ * Read every report under `dir`, verbatim. An empty directory, or one with
104
+ * no `SF:` record anywhere, is an error rather than an empty capture: the
105
+ * contract is that an opted-in service always has a report to read.
106
+ */
107
+ export async function readCoverageDir(
108
+ service: string,
109
+ dir: string,
110
+ ): Promise<ServiceCoverageCapture> {
111
+ const names = await listReportFiles(dir);
112
+ if (names.length === 0) {
113
+ return {
114
+ service,
115
+ reports: [],
116
+ error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov file there`,
117
+ };
118
+ }
119
+ const reports: CoverageReport[] = [];
120
+ let total = 0;
121
+ let anyLcov = false;
122
+ for (const name of names) {
123
+ let content: string;
124
+ try {
125
+ content = await fs.readFile(path.join(dir, name), "utf8");
126
+ } catch {
127
+ continue;
128
+ }
129
+ total += content.length;
130
+ if (total > COVERAGE_MAX_BYTES_PER_SERVICE) {
131
+ return {
132
+ service,
133
+ reports: [],
134
+ error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — reports must be cumulative, not one file per dump`,
135
+ };
136
+ }
137
+ if (hasLcovSourceFile(content)) anyLcov = true;
138
+ reports.push({ name, content });
139
+ }
140
+ if (!anyLcov) {
141
+ return {
142
+ service,
143
+ reports,
144
+ error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none holds an lcov \`SF:\` record — only lcov reports are read`,
145
+ };
146
+ }
147
+ return { service, reports };
148
+ }
149
+
150
+ /** Gzip a case's captures into the bundle `coverageChunk` serves. */
151
+ export function encodeCoverageBundle(
152
+ caseId: string,
153
+ services: ServiceCoverageCapture[],
154
+ ): Buffer {
155
+ const doc: CoverageBundle = { caseId, services };
156
+ return gzipSync(Buffer.from(JSON.stringify(doc)));
157
+ }
158
+
159
+ /** The ref for a bundle: its size plus a per-service summary. */
160
+ export function coverageBundleRef(
161
+ gz: Buffer,
162
+ services: ServiceCoverageCapture[],
163
+ ): CoverageBundleRef {
164
+ return {
165
+ bytes: gz.length,
166
+ services: services.map((s) => ({
167
+ service: s.service,
168
+ reports: s.reports.length,
169
+ bytes: s.reports.reduce((n, r) => n + r.content.length, 0),
170
+ ...(s.error ? { error: s.error } : {}),
171
+ })),
172
+ };
173
+ }
package/src/index.ts CHANGED
@@ -252,6 +252,27 @@ export interface ServiceConfig {
252
252
  tls?: readonly ServiceTls[];
253
253
  /** Bind-mounted volumes for state that survives snapshot/fork. */
254
254
  volumes?: readonly VolumeMount[];
255
+ /**
256
+ * Opt this service in to **code coverage collection** (experimental).
257
+ *
258
+ * The service gets a writable directory bind-mounted at
259
+ * `/spectest/coverage/`. The service's own coverage tool writes **lcov**
260
+ * reports there — cumulative from process start, never reset. After
261
+ * bring-up and after every test, spectest runs `command` (if given) inside
262
+ * the container, then reads every file in the directory and records the
263
+ * set of source files that ran. Only the `SF:` records are read.
264
+ *
265
+ * - `true`: the service writes its reports by itself (a dump on a timer,
266
+ * an exit-time dump for short-lived processes).
267
+ * - `{ command }`: run this (via `sh -c`) in the container to make the
268
+ * service write a report, before the directory is read.
269
+ *
270
+ * The directory must hold at least one lcov report each time it is read;
271
+ * an empty directory or a failed command fails the test with a clear
272
+ * error, so a hole in the coverage data is never silent. See
273
+ * `spectest docs /services/coverage`.
274
+ */
275
+ coverage?: ServiceCoverage;
255
276
  /**
256
277
  * Files seeded into the container's filesystem **before it starts**.
257
278
  * Each entry's `content` is written to a VM-host staging path and
@@ -1017,6 +1038,13 @@ export type ServiceImage =
1017
1038
  exclude?: readonly string[];
1018
1039
  };
1019
1040
 
1041
+ /**
1042
+ * A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
1043
+ * `true` when the service writes its own reports; `{ command }` when
1044
+ * spectest must run a command in the container to produce one.
1045
+ */
1046
+ export type ServiceCoverage = true | { command: string };
1047
+
1020
1048
  export interface VolumeMount {
1021
1049
  /**
1022
1050
  * Named shared volume. Two services mounting the same `name` share one
@@ -1236,6 +1264,25 @@ export interface FakeContext {
1236
1264
  certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
1237
1265
  }
1238
1266
 
1267
+ /**
1268
+ * Check one service's `coverage` field. Exported so the daemon applies the
1269
+ * same rule to a runtime `startService` spec.
1270
+ */
1271
+ export function validateCoverage(service: string, cov: unknown): void {
1272
+ if (cov === undefined || cov === true) return;
1273
+ if (
1274
+ typeof cov === "object" &&
1275
+ cov !== null &&
1276
+ typeof (cov as { command?: unknown }).command === "string" &&
1277
+ (cov as { command: string }).command.trim().length > 0
1278
+ ) {
1279
+ return;
1280
+ }
1281
+ throw new Error(
1282
+ `service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``,
1283
+ );
1284
+ }
1285
+
1239
1286
  function validateEnvironmentConfig<S extends ServicesMap>(
1240
1287
  config: EnvironmentConfig<S>,
1241
1288
  ): void {
@@ -1281,6 +1328,7 @@ function validateEnvironmentConfig<S extends ServicesMap>(
1281
1328
  );
1282
1329
  }
1283
1330
  }
1331
+ validateCoverage(name, svc.coverage);
1284
1332
  for (const raw of svc.hostnames ?? []) {
1285
1333
  const h = raw.toLowerCase();
1286
1334
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {