@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.
@@ -1,7 +1,7 @@
1
1
  /** Where the directory is mounted inside an opted-in container. */
2
2
  export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
3
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
4
+ * bounded by code size times chain depth, so anything past this is a
5
5
  * runaway (a tool writing a new file per request), not coverage. */
6
6
  export declare const COVERAGE_MAX_BYTES_PER_SERVICE: number;
7
7
  /** Root of the per-service host directories, under the workspace so the
@@ -44,14 +44,33 @@ export interface CoverageBundle {
44
44
  }
45
45
  /** True when `text` holds at least one lcov `SF:` record. */
46
46
  export declare function hasLcovSourceFile(text: string): boolean;
47
+ /** True when `text` is lcov-shaped: at least one line, every non-blank
48
+ * line an `XX:` record or `end_of_record`. An lcov with no `SF:` record
49
+ * at all (`TN:` alone) is well-formed — it is a report that says
50
+ * nothing ran. */
51
+ export declare function isLcovShaped(text: string): boolean;
52
+ /**
53
+ * True when `text` looks like a V8 coverage JSON document — what
54
+ * `NODE_V8_COVERAGE` / `v8.takeCoverage()` write: an object with a
55
+ * `result` array of scripts. A cheap shape check, not a parse (a report
56
+ * can be megabytes); the control plane parses for real.
57
+ */
58
+ export declare function isV8CoverageJson(text: string): boolean;
59
+ /** True when `text` is a report spectest stores: lcov or V8 JSON. */
60
+ export declare function isWellFormedReport(text: string): boolean;
61
+ /** True when a well-formed report names no source file at all (an lcov
62
+ * with no `SF:`, a V8 document with an empty `result`). */
63
+ export declare function isEmptyReport(text: string): boolean;
47
64
  /** Every regular file under `dir`, recursively, as paths relative to it.
48
65
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
49
66
  * a tmp file mid-write — is not a report). */
50
67
  export declare function listReportFiles(dir: string): Promise<string[]>;
51
68
  /**
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.
69
+ * Read every report under `dir`, verbatim. An empty directory, or a file
70
+ * that is neither lcov nor V8 JSON, is an error rather than an empty
71
+ * capture: the contract is that an opted-in service always has a
72
+ * well-formed report to read. A report that names no file is fine — it
73
+ * says nothing ran.
55
74
  */
56
75
  export declare function readCoverageDir(service: string, dir: string): Promise<ServiceCoverageCapture>;
57
76
  /** Gzip a case's captures into the bundle `coverageChunk` serves. */
@@ -2,12 +2,15 @@
2
2
  //
3
3
  // A service that opts in (`coverage: true | { command }`) gets a writable
4
4
  // directory bind-mounted at `/spectest/coverage/` in its container. The
5
- // service's own tool writes lcov reports there. The daemon reads the
5
+ // service's own tool, or its coverage adapters, write reports there —
6
+ // lcov, or the V8 coverage JSON that `NODE_V8_COVERAGE` writes. The daemon reads the
6
7
  // directory from the VM side (no `docker exec` to fetch) after bring-up
7
8
  // and after each test, and ships the reports **verbatim** to the control
8
9
  // 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
10
+ // contract the harness must fail a case on: at least one report, every
11
+ // one well-formed. Counters may reset between dumps and a dump may be a
12
+ // new file: the directory forks with the environment, so it accumulates
13
+ // along the ancestor chain by itself. The server derives whatever it
11
14
  // needs (today the set of source files; later, finer things) from the
12
15
  // stored bytes, so a change in that derivation is a re-read of stored
13
16
  // runs, never a hole in the history.
@@ -19,7 +22,7 @@ import { gzipSync } from "node:zlib";
19
22
  /** Where the directory is mounted inside an opted-in container. */
20
23
  export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
21
24
  /** 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
25
+ * bounded by code size times chain depth, so anything past this is a
23
26
  * runaway (a tool writing a new file per request), not coverage. */
24
27
  export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
25
28
  /** Root of the per-service host directories, under the workspace so the
@@ -31,6 +34,34 @@ export function coverageHostDir(workspace, service) {
31
34
  export function hasLcovSourceFile(text) {
32
35
  return /^SF:\S/m.test(text);
33
36
  }
37
+ /** True when `text` is lcov-shaped: at least one line, every non-blank
38
+ * line an `XX:` record or `end_of_record`. An lcov with no `SF:` record
39
+ * at all (`TN:` alone) is well-formed — it is a report that says
40
+ * nothing ran. */
41
+ export function isLcovShaped(text) {
42
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
43
+ return lines.length > 0 && lines.every((l) => /^[A-Z]{2,5}:/.test(l) || l.trim() === "end_of_record");
44
+ }
45
+ /**
46
+ * True when `text` looks like a V8 coverage JSON document — what
47
+ * `NODE_V8_COVERAGE` / `v8.takeCoverage()` write: an object with a
48
+ * `result` array of scripts. A cheap shape check, not a parse (a report
49
+ * can be megabytes); the control plane parses for real.
50
+ */
51
+ export function isV8CoverageJson(text) {
52
+ return /^\s*\{/.test(text) && /"result"\s*:\s*\[/.test(text);
53
+ }
54
+ /** True when `text` is a report spectest stores: lcov or V8 JSON. */
55
+ export function isWellFormedReport(text) {
56
+ return isLcovShaped(text) || isV8CoverageJson(text);
57
+ }
58
+ /** True when a well-formed report names no source file at all (an lcov
59
+ * with no `SF:`, a V8 document with an empty `result`). */
60
+ export function isEmptyReport(text) {
61
+ if (isV8CoverageJson(text))
62
+ return /"result"\s*:\s*\[\s*\]/.test(text);
63
+ return !hasLcovSourceFile(text);
64
+ }
34
65
  /** Every regular file under `dir`, recursively, as paths relative to it.
35
66
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
36
67
  * a tmp file mid-write — is not a report). */
@@ -58,9 +89,11 @@ export async function listReportFiles(dir) {
58
89
  return out.sort();
59
90
  }
60
91
  /**
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.
92
+ * Read every report under `dir`, verbatim. An empty directory, or a file
93
+ * that is neither lcov nor V8 JSON, is an error rather than an empty
94
+ * capture: the contract is that an opted-in service always has a
95
+ * well-formed report to read. A report that names no file is fine — it
96
+ * says nothing ran.
64
97
  */
65
98
  export async function readCoverageDir(service, dir) {
66
99
  const names = await listReportFiles(dir);
@@ -68,12 +101,13 @@ export async function readCoverageDir(service, dir) {
68
101
  return {
69
102
  service,
70
103
  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`,
104
+ error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov or V8 coverage JSON file there`,
72
105
  };
73
106
  }
74
107
  const reports = [];
75
108
  let total = 0;
76
- let anyLcov = false;
109
+ let anyReport = false;
110
+ const malformed = [];
77
111
  for (const name of names) {
78
112
  let content;
79
113
  try {
@@ -87,18 +121,20 @@ export async function readCoverageDir(service, dir) {
87
121
  return {
88
122
  service,
89
123
  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`,
124
+ error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — that is a runaway, not coverage`,
91
125
  };
92
126
  }
93
- if (hasLcovSourceFile(content))
94
- anyLcov = true;
127
+ if (isWellFormedReport(content))
128
+ anyReport = true;
129
+ else
130
+ malformed.push(name);
95
131
  reports.push({ name, content });
96
132
  }
97
- if (!anyLcov) {
133
+ if (!anyReport || malformed.length > 0) {
98
134
  return {
99
135
  service,
100
136
  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`,
137
+ error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but ${anyReport ? `${malformed.slice(0, 5).join(", ")} ${malformed.length === 1 ? "is" : "are"} neither` : "none is"} an lcov report or a V8 coverage JSON document — only those two formats are read`,
102
138
  };
103
139
  }
104
140
  return { service, reports };
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ export type { Mobile, MobileApp } from "./mobile.js";
20
20
  import type { Mobile, MobileApp } from "./mobile.js";
21
21
  export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
22
22
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
23
+ export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
24
+ import { type ServiceCoverage } from "./coverage.js";
23
25
  export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
24
26
  export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
25
27
  import type { DnsTarget } from "./ingress.js";
@@ -119,20 +121,24 @@ export interface ServiceConfig {
119
121
  * Opt this service in to **code coverage collection** (experimental).
120
122
  *
121
123
  * 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.
124
+ * `/spectest/coverage/`. After bring-up and after every test, spectest
125
+ * reads every file in it — **lcov** or **V8 coverage JSON**, verbatim —
126
+ * and stores them with the test; the control plane derives what ran.
127
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.
128
+ * - `true`: the program writes its own reports there (an exit-time dump
129
+ * for a short-lived process, a dump on a timer, its own hook).
130
+ * - `{ adapters: [...] }`: spectest gets the reports out. From
131
+ * `@specific.dev/spectest/coverage`: `node()` (V8 JSON from a node
132
+ * service, through a hook spectest mounts), `browser()` (the frontend
133
+ * this service serves, collected from the guest browser),
134
+ * `command(cmd)` (run a command in the container first), or your own
135
+ * via `defineAdapter`.
132
136
  *
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
137
+ * The directory must hold at least one well-formed report each time it
138
+ * is read; an empty directory or a failing adapter fails the test with a
139
+ * clear error, so a hole in the coverage data is never silent. Reports
140
+ * accumulate along the test's ancestor chain by themselves (the
141
+ * directory forks with the environment). See
136
142
  * `spectest docs /services/coverage`.
137
143
  */
138
144
  coverage?: ServiceCoverage;
@@ -663,29 +669,6 @@ export type ServiceImage = {
663
669
  /** Extra glob patterns to exclude from the build context. */
664
670
  exclude?: readonly string[];
665
671
  };
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
- /** Run this (via `sh -c`) in the container before the coverage
673
- * directory is read, so the service writes a fresh lcov report. */
674
- command?: string;
675
- /**
676
- * Collect **frontend** coverage for the app this service serves.
677
- * The code runs in the guest browser — spectest's own process — so
678
- * the user cannot write a report for it: spectest turns on V8
679
- * coverage for every script `ctx.browser()`/`ctx.mobile()` loads
680
- * from this service's origin, maps it back to original sources
681
- * through the served source maps, and adds one lcov report to this
682
- * service's capture. Scripts a source map can't resolve are
683
- * attributed to their served path (which the repo mapping then
684
- * treats as unknown — the safe answer). Needs the app to serve
685
- * source maps to the test environment.
686
- */
687
- browser?: boolean;
688
- };
689
672
  export interface VolumeMount {
690
673
  /**
691
674
  * Named shared volume. Two services mounting the same `name` share one
@@ -898,17 +881,6 @@ export interface FakeContext {
898
881
  * for a TLS-provisioning provider hands back to the app under test. */
899
882
  certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
900
883
  }
901
- /**
902
- * Check one service's `coverage` field. Exported so the daemon applies the
903
- * same rule to a runtime `startService` spec.
904
- */
905
- export declare function validateCoverage(service: string, cov: unknown): void;
906
- /** Whether a `coverage` value collects reports from the container's own
907
- * filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
908
- * any object that isn't browser-only. */
909
- export declare function coverageUsesContainer(cov: ServiceCoverage | undefined): boolean;
910
- /** Whether a `coverage` value collects frontend (browser) coverage. */
911
- export declare function coverageUsesBrowser(cov: ServiceCoverage | undefined): boolean;
912
884
  /**
913
885
  * A single test step. Created via `test(...)` or `createTest(services)`.
914
886
  * Tests are referenced (not named by string) when one test depends on
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
40
40
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
41
41
  import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
42
42
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
43
+ import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
43
44
  // Low-level ingress primitives + the framework lowering that the friendly
44
45
  // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
45
46
  // `ingress.ts`.
@@ -215,37 +216,6 @@ function expandServiceGroups(input) {
215
216
  }
216
217
  return out;
217
218
  }
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" && cov !== null) {
226
- const o = cov;
227
- const cmdOk = o.command === undefined ||
228
- (typeof o.command === "string" && o.command.trim().length > 0);
229
- const browserOk = o.browser === undefined || typeof o.browser === "boolean";
230
- if (cmdOk && browserOk && (o.command !== undefined || o.browser === true))
231
- return;
232
- }
233
- throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\`, \`{ command: "<shell command>" }\`, or \`{ browser: true }\``);
234
- }
235
- /** Whether a `coverage` value collects reports from the container's own
236
- * filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
237
- * any object that isn't browser-only. */
238
- export function coverageUsesContainer(cov) {
239
- if (cov === undefined)
240
- return false;
241
- if (cov === true)
242
- return true;
243
- return cov.command !== undefined || cov.browser !== true;
244
- }
245
- /** Whether a `coverage` value collects frontend (browser) coverage. */
246
- export function coverageUsesBrowser(cov) {
247
- return typeof cov === "object" && cov.browser === true;
248
- }
249
219
  function validateEnvironmentConfig(config) {
250
220
  const entries = Object.entries(config.services);
251
221
  const serviceNames = new Set(entries.map(([n]) => n));
@@ -353,9 +323,13 @@ export function defineEnvironment(input) {
353
323
  // { name, services, timeoutSecs } — with plain, fully-expanded services —
354
324
  // is stored as `config` and shipped to the control plane.
355
325
  const { fakes, services: rawServices, ...rest } = input;
326
+ const expanded = expandServiceGroups(rawServices);
327
+ for (const [key, svc] of Object.entries(expanded)) {
328
+ expanded[key] = applyCoverageAdapters(key, svc);
329
+ }
356
330
  const config = {
357
331
  ...rest,
358
- services: expandServiceGroups(rawServices),
332
+ services: expanded,
359
333
  };
360
334
  validateEnvironmentConfig(config);
361
335
  if (fakes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,11 @@
16
16
  "bun": "./src/components/index.ts",
17
17
  "default": "./dist/components/index.js"
18
18
  },
19
+ "./coverage": {
20
+ "types": "./dist/coverage.d.ts",
21
+ "bun": "./src/coverage.ts",
22
+ "default": "./dist/coverage.js"
23
+ },
19
24
  "./browser": {
20
25
  "types": "./dist/browser.d.ts",
21
26
  "bun": "./src/browser.ts",
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import { promises as fs } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import {
7
+ NODE_COVERAGE_HOOK,
8
+ NODE_COVERAGE_HOOK_PATH,
9
+ NODE_COVERAGE_SOCKET,
10
+ applyCoverageAdapters,
11
+ appendEnvFlag,
12
+ browser,
13
+ command,
14
+ node,
15
+ validateCoverage,
16
+ type CoverageCaptureContext,
17
+ } from "./coverage.js";
18
+ import type { ServiceConfig } from "./index.js";
19
+
20
+ const base: ServiceConfig = {
21
+ image: { type: "registry", reference: "node:20" },
22
+ command: "node server.js",
23
+ env: { NODE_OPTIONS: "--max-old-space-size=512" },
24
+ };
25
+
26
+ describe("validateCoverage", () => {
27
+ test("accepts true, a list of adapters, nothing else", () => {
28
+ validateCoverage("a", undefined);
29
+ validateCoverage("a", true);
30
+ validateCoverage("a", { adapters: [node(), { name: "x" }] });
31
+ expect(() => validateCoverage("a", false)).toThrow(/invalid `coverage` value/);
32
+ expect(() => validateCoverage("a", { command: "x" })).toThrow(/invalid `coverage` value/);
33
+ expect(() => validateCoverage("a", [node()])).toThrow(/invalid `coverage` value/);
34
+ expect(() => validateCoverage("a", { adapters: [{ name: "" }] })).toThrow(/invalid coverage adapter/);
35
+ expect(() => validateCoverage("a", { adapters: [{ name: "x", capture: 1 }] })).toThrow(/invalid coverage adapter/);
36
+ });
37
+ });
38
+
39
+ describe("applyCoverageAdapters", () => {
40
+ test("node: appends NODE_OPTIONS, sets NODE_V8_COVERAGE, mounts the hook", () => {
41
+ const out = applyCoverageAdapters("api", { ...base, coverage: { adapters: [node()] } });
42
+ expect(out.env?.NODE_OPTIONS).toBe(`--max-old-space-size=512 --require ${NODE_COVERAGE_HOOK_PATH}`);
43
+ expect(out.env?.NODE_V8_COVERAGE).toBe("/spectest/coverage");
44
+ expect(out.files?.map((f) => f.path)).toEqual([NODE_COVERAGE_HOOK_PATH]);
45
+ expect(out.files?.[0]?.content).toBe(NODE_COVERAGE_HOOK);
46
+ });
47
+ test("the adapters stay on the config for the harness, and serialize as names", () => {
48
+ const out = applyCoverageAdapters("api", { ...base, coverage: { adapters: [node(), browser()] } });
49
+ const list = (out.coverage as { adapters: readonly { name: string; capture?: unknown; load?: unknown }[] }).adapters;
50
+ expect(list.map((a) => a.name)).toEqual(["node", "browser"]);
51
+ expect(typeof list[0]?.capture).toBe("function");
52
+ expect(typeof list[1]?.load).toBe("function");
53
+ expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({
54
+ adapters: [{ adapter: "node" }, { adapter: "browser" }],
55
+ });
56
+ });
57
+ test("true and undefined pass through untouched", () => {
58
+ const t = { ...base, coverage: true as const };
59
+ expect(applyCoverageAdapters("api", t)).toBe(t);
60
+ expect(applyCoverageAdapters("api", base)).toBe(base);
61
+ });
62
+ test("appendEnvFlag sets when absent", () => {
63
+ expect(appendEnvFlag(undefined, "X", "a")).toEqual({ X: "a" });
64
+ expect(appendEnvFlag({ X: " b " }, "X", "a")).toEqual({ X: "b a" });
65
+ });
66
+ test("coverage.command refuses an empty command", () => {
67
+ expect(() => command(" ")).toThrow(/non-empty/);
68
+ });
69
+ });
70
+
71
+ /** A capture context over a real directory, no container. */
72
+ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCaptureContext {
73
+ return {
74
+ service: "api",
75
+ reportDir: dir,
76
+ signal,
77
+ async exec() {
78
+ throw new Error("no container");
79
+ },
80
+ async writeReport(name, content) {
81
+ await fs.writeFile(path.join(dir, name), content);
82
+ },
83
+ };
84
+ }
85
+
86
+ describe("browserCoverage", () => {
87
+ test("writes an empty lcov when nothing ran for the service", async () => {
88
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
89
+ await browser().capture!(fakeCtx(dir));
90
+ expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
91
+ });
92
+ });
93
+
94
+ // The node hook against a real `node` (the box has one; skipped where it
95
+ // does not). A long-lived process armed with the hook must answer the
96
+ // socket with a fresh V8 report; a second process must stay a bystander
97
+ // and exit on its own; the socket path must work across a bind mount —
98
+ // which is what a plain directory path stands in for here.
99
+ const hasNode = await new Promise<boolean>((resolve) => {
100
+ const p = spawn("node", ["--version"]);
101
+ p.on("error", () => resolve(false));
102
+ p.on("exit", (code) => resolve(code === 0));
103
+ });
104
+
105
+ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
106
+ async function startServer(dir: string): Promise<ChildProcess> {
107
+ const hook = path.join(dir, "hook.cjs");
108
+ await fs.writeFile(hook, NODE_COVERAGE_HOOK);
109
+ const server = path.join(dir, "server.js");
110
+ await fs.writeFile(server, "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n");
111
+ const child = spawn("node", ["--require", hook, server], {
112
+ env: { ...process.env, NODE_V8_COVERAGE: dir },
113
+ stdio: ["ignore", "pipe", "inherit"],
114
+ });
115
+ await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
116
+ // The socket is bound after the module graph runs; wait for it.
117
+ const sock = path.join(dir, NODE_COVERAGE_SOCKET);
118
+ for (let i = 0; i < 100; i++) {
119
+ try {
120
+ await fs.stat(sock);
121
+ break;
122
+ } catch {
123
+ await new Promise((r) => setTimeout(r, 20));
124
+ }
125
+ }
126
+ return child;
127
+ }
128
+
129
+ test("capture asks the live server for a dump; a bystander exits on its own", async () => {
130
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
131
+ const server = await startServer(dir);
132
+ try {
133
+ const reportsBefore = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
134
+ expect(reportsBefore).toEqual([]);
135
+ await node().capture!(fakeCtx(dir));
136
+ const reports = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
137
+ expect(reports.length).toBe(1);
138
+ const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
139
+ result: { url: string }[];
140
+ };
141
+ expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
142
+
143
+ // A second process with the same env: the socket is held, so it must
144
+ // not try to serve, and it must exit (the server's socket is unref'd,
145
+ // so a bystander is never kept alive by it).
146
+ const bystander = spawn("node", ["--require", path.join(dir, "hook.cjs"), "-e", "1"], {
147
+ env: { ...process.env, NODE_V8_COVERAGE: dir },
148
+ stdio: "ignore",
149
+ });
150
+ const code = await new Promise<number | null>((resolve) => bystander.on("exit", resolve));
151
+ expect(code).toBe(0);
152
+ // …and it wrote its own exit-time report, as every node process does.
153
+ const after = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
154
+ expect(after.length).toBe(2);
155
+
156
+ // The live server still answers.
157
+ await node().capture!(fakeCtx(dir));
158
+ expect((await fs.readdir(dir)).filter((n) => n.startsWith("coverage-")).length).toBe(3);
159
+ } finally {
160
+ server.kill("SIGKILL");
161
+ }
162
+ });
163
+
164
+ test("a stale socket left by a dead process is reclaimed", async () => {
165
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
166
+ const first = await startServer(dir);
167
+ first.kill("SIGKILL");
168
+ await new Promise<void>((resolve) => first.on("exit", () => resolve()));
169
+ expect(await fs.stat(path.join(dir, NODE_COVERAGE_SOCKET))).toBeTruthy(); // stale file
170
+ const second = await startServer(dir);
171
+ try {
172
+ // Give the EADDRINUSE → probe → unlink → listen dance a moment.
173
+ let ok = false;
174
+ for (let i = 0; i < 50 && !ok; i++) {
175
+ try {
176
+ await node().capture!(fakeCtx(dir));
177
+ ok = true;
178
+ } catch {
179
+ await new Promise((r) => setTimeout(r, 50));
180
+ }
181
+ }
182
+ expect(ok).toBe(true);
183
+ } finally {
184
+ second.kill("SIGKILL");
185
+ }
186
+ });
187
+
188
+ test("no server: capture fails naming the socket", async () => {
189
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
190
+ await expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
191
+ });
192
+ });