@specific.dev/spectest 0.59.1 → 0.59.3

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.
@@ -3,8 +3,11 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
3
3
  export { COVERAGE_CONTAINER_DIR };
4
4
  /** Where `node()` mounts its hook inside the container. */
5
5
  export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
6
- /** The socket the node hook answers on, relative to the coverage dir. */
7
- export declare const NODE_COVERAGE_SOCKET = ".ctl";
6
+ /** The report `node()` writes when nothing has run yet on a branch. */
7
+ export declare const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
8
+ /** Prefix of the per-process sockets the node hook answers on, relative
9
+ * to the coverage dir: `.ctl-<pid>`. */
10
+ export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
8
11
  /** What `configure` learns about the service it rewrites. */
9
12
  export interface CoverageConfigureInfo {
10
13
  /** The services-map key. */
@@ -85,30 +88,44 @@ export declare function applyCoverageAdapters<S extends ServiceConfig>(key: stri
85
88
  /** `env` with `value` appended to `name` (space-separated), or set. */
86
89
  export declare function appendEnvFlag(env: Readonly<Record<string, string>> | undefined, name: string, value: string): Record<string, string>;
87
90
  /**
88
- * The hook `node()` mounts and `--require`s into every node
89
- * process of the container. Node writes V8 coverage JSON into
90
- * `NODE_V8_COVERAGE` when a process exits; a long-lived server never
91
- * exits, so the hook binds a Unix socket in the coverage directory and
92
- * calls `v8.takeCoverage()` for each connection. The first process to
93
- * start owns the socket (a stale socket left by a dead process is
94
- * reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
95
- * alone and writes at its own exit. No signal is used: signals are
96
- * claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
97
- * nodemon), a socket is nobody's.
91
+ * The hook `node()` mounts and `--require`s into every node process of
92
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
93
+ * when a process exits; a long-lived server never exits, so the hook
94
+ * binds a Unix socket in the coverage directory and calls
95
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
96
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
97
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
98
+ * `npm run`) whose child is the real server; a single socket on the
99
+ * wrapper dumped the wrapper and silently never the server (reported by
100
+ * a user 2026-08-27). At capture spectest asks every live socket and
101
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
102
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
103
+ * Temporal worker, restarts nodemon), a socket is nobody's.
98
104
  */
99
105
  export declare const NODE_COVERAGE_HOOK: string;
100
106
  /**
101
- * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
102
- * directory (every node process in the container then writes V8
103
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
104
- * ask the long-lived server process for a dump at capture time. Nothing
107
+ * Coverage for a Node service — a long-lived server, or a container whose
108
+ * node processes are short-lived CLIs run by `ctx.exec`. Sets
109
+ * `NODE_V8_COVERAGE` to the coverage directory (every node process in the
110
+ * container then writes V8 coverage JSON when it exits) and `--require`s
111
+ * a hook that lets spectest ask every live node process for a dump at
112
+ * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
113
+ * `tsx`). Nothing
105
114
  * for the app to write. Each dump is compacted to the app's own scripts
106
115
  * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
107
116
  * `sourceMappingURL` next to the file) Node records the map in the
108
117
  * report, which is what maps a TypeScript service back to its sources.
109
118
  */
110
119
  export declare function node(): CoverageAdapter;
111
- /** A script is the app's own when it is a file outside node_modules. */
120
+ /**
121
+ * Ask every node process that holds a hook socket in `dir` for a dump.
122
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
123
+ * OOM) is removed. Returns how many processes answered; a process that
124
+ * answered with an error throws.
125
+ */
126
+ export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
127
+ /** A script is the app's own when it is a file outside node_modules —
128
+ * and not our hook, which every dump would otherwise carry. */
112
129
  export declare function isAppScriptUrl(url: string): boolean;
113
130
  /**
114
131
  * Compact one V8 coverage document to the app's own scripts. What Node
package/dist/coverage.js CHANGED
@@ -29,8 +29,11 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
29
29
  export { COVERAGE_CONTAINER_DIR };
30
30
  /** Where `node()` mounts its hook inside the container. */
31
31
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
32
- /** The socket the node hook answers on, relative to the coverage dir. */
33
- export const NODE_COVERAGE_SOCKET = ".ctl";
32
+ /** The report `node()` writes when nothing has run yet on a branch. */
33
+ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
34
+ /** Prefix of the per-process sockets the node hook answers on, relative
35
+ * to the coverage dir: `.ctl-<pid>`. */
36
+ export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
34
37
  /** Type an inline adapter. Identity at runtime. */
35
38
  export function defineAdapter(adapter) {
36
39
  return adapter;
@@ -95,16 +98,19 @@ export function appendEnvFlag(env, name, value) {
95
98
  }
96
99
  // ── node ──────────────────────────────────────────────────────────
97
100
  /**
98
- * The hook `node()` mounts and `--require`s into every node
99
- * process of the container. Node writes V8 coverage JSON into
100
- * `NODE_V8_COVERAGE` when a process exits; a long-lived server never
101
- * exits, so the hook binds a Unix socket in the coverage directory and
102
- * calls `v8.takeCoverage()` for each connection. The first process to
103
- * start owns the socket (a stale socket left by a dead process is
104
- * reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
105
- * alone and writes at its own exit. No signal is used: signals are
106
- * claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
107
- * nodemon), a socket is nobody's.
101
+ * The hook `node()` mounts and `--require`s into every node process of
102
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
103
+ * when a process exits; a long-lived server never exits, so the hook
104
+ * binds a Unix socket in the coverage directory and calls
105
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
106
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
107
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
108
+ * `npm run`) whose child is the real server; a single socket on the
109
+ * wrapper dumped the wrapper and silently never the server (reported by
110
+ * a user 2026-08-27). At capture spectest asks every live socket and
111
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
112
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
113
+ * Temporal worker, restarts nodemon), a socket is nobody's.
108
114
  */
109
115
  export const NODE_COVERAGE_HOOK = `"use strict";
110
116
  // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
@@ -112,10 +118,9 @@ if (process.env.NODE_V8_COVERAGE) {
112
118
  const net = require("node:net");
113
119
  const fs = require("node:fs");
114
120
  const v8 = require("node:v8");
115
- const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
121
+ const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
116
122
  const server = net.createServer((conn) => {
117
- // A dump is taken only on an explicit "dump" request: a bystander's
118
- // liveness probe connects and closes without one.
123
+ // A dump is taken only on an explicit "dump" request.
119
124
  let buf = "";
120
125
  conn.on("data", (chunk) => {
121
126
  buf += chunk;
@@ -132,25 +137,20 @@ if (process.env.NODE_V8_COVERAGE) {
132
137
  });
133
138
  });
134
139
  server.unref();
135
- server.on("error", (err) => {
136
- if (err.code !== "EADDRINUSE") return;
137
- // Someone holds the socket. If it answers, it is the live server and
138
- // this process is a bystander; if not, it is a stale file.
139
- const probe = net.connect(SOCK);
140
- probe.on("connect", () => probe.destroy());
141
- probe.on("error", () => {
142
- try { fs.unlinkSync(SOCK); } catch {}
143
- server.listen(SOCK);
144
- });
145
- });
140
+ server.on("error", () => {});
141
+ try { fs.unlinkSync(SOCK); } catch {}
146
142
  server.listen(SOCK);
143
+ process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
147
144
  }
148
145
  `;
149
146
  /**
150
- * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
151
- * directory (every node process in the container then writes V8
152
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
153
- * ask the long-lived server process for a dump at capture time. Nothing
147
+ * Coverage for a Node service — a long-lived server, or a container whose
148
+ * node processes are short-lived CLIs run by `ctx.exec`. Sets
149
+ * `NODE_V8_COVERAGE` to the coverage directory (every node process in the
150
+ * container then writes V8 coverage JSON when it exits) and `--require`s
151
+ * a hook that lets spectest ask every live node process for a dump at
152
+ * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
153
+ * `tsx`). Nothing
154
154
  * for the app to write. Each dump is compacted to the app's own scripts
155
155
  * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
156
156
  * `sourceMappingURL` next to the file) Node records the map in the
@@ -169,41 +169,97 @@ export function node() {
169
169
  };
170
170
  },
171
171
  async capture(ctx) {
172
- const { connect } = await import("node:net");
173
- const path = await import("node:path");
174
- const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
175
- const reply = await new Promise((resolve, reject) => {
176
- const chunks = [];
177
- const c = connect(sock, () => c.write("dump\n"));
178
- const onAbort = () => {
179
- c.destroy();
180
- reject(new Error("timed out waiting for the node hook to write a report"));
181
- };
182
- ctx.signal.addEventListener("abort", onAbort, { once: true });
183
- c.on("data", (b) => chunks.push(b));
184
- c.on("error", (err) => {
185
- ctx.signal.removeEventListener("abort", onAbort);
186
- reject(new Error(err.code === "ENOENT" || err.code === "ECONNREFUSED"
187
- ? `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)?`
188
- : err.message));
189
- });
190
- c.on("close", () => {
191
- ctx.signal.removeEventListener("abort", onAbort);
192
- resolve(Buffer.concat(chunks).toString("utf8").trim());
193
- });
194
- });
195
- if (reply !== "ok")
196
- throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
172
+ // Two shapes, one adapter: a long-lived server answers its socket
173
+ // here; a service whose node processes are short-lived (a CLI run
174
+ // by `ctx.exec` from a `sleep infinity` container) has no socket to
175
+ // answer and its coverage is the exit-time dumps already on disk.
176
+ // So no live process is not an error. No report at all — nothing
177
+ // has run yet on this branch — is recorded as an empty document
178
+ // rather than failed: an empty report is a report. A server whose
179
+ // hook never loaded (NODE_OPTIONS not reaching it) then shows as
180
+ // empty reports after bring-up, which the boot log warns about.
181
+ await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
197
182
  await compactV8Reports(ctx.reportDir);
183
+ if (!(await hasV8Reports(ctx.reportDir))) {
184
+ await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
185
+ }
198
186
  },
199
187
  };
200
188
  }
189
+ /**
190
+ * Ask every node process that holds a hook socket in `dir` for a dump.
191
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
192
+ * OOM) is removed. Returns how many processes answered; a process that
193
+ * answered with an error throws.
194
+ */
195
+ export async function dumpAllNodeProcesses(dir, signal) {
196
+ const fs = await import("node:fs/promises");
197
+ const path = await import("node:path");
198
+ const { connect } = await import("node:net");
199
+ let names;
200
+ try {
201
+ names = (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX));
202
+ }
203
+ catch {
204
+ return 0;
205
+ }
206
+ let live = 0;
207
+ for (const name of names) {
208
+ const sock = path.join(dir, name);
209
+ const reply = await new Promise((resolve, reject) => {
210
+ const chunks = [];
211
+ const c = connect(sock, () => c.write("dump\n"));
212
+ const onAbort = () => {
213
+ c.destroy();
214
+ reject(new Error(`timed out waiting for the node hook (${name}) to write a report`));
215
+ };
216
+ signal.addEventListener("abort", onAbort, { once: true });
217
+ c.on("data", (b) => chunks.push(b));
218
+ c.on("error", (err) => {
219
+ signal.removeEventListener("abort", onAbort);
220
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED")
221
+ resolve(null);
222
+ else
223
+ reject(err);
224
+ });
225
+ c.on("close", (hadError) => {
226
+ signal.removeEventListener("abort", onAbort);
227
+ if (!hadError)
228
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
229
+ });
230
+ });
231
+ if (reply === null) {
232
+ // Nobody home: the process is gone. Clean up so the next capture
233
+ // does not knock again.
234
+ await fs.unlink(sock).catch(() => { });
235
+ continue;
236
+ }
237
+ if (reply !== "ok")
238
+ throw new Error(`the node hook (${name}) answered: ${reply || "(nothing)"}`);
239
+ live++;
240
+ }
241
+ return live;
242
+ }
243
+ /** True when `dir` holds at least one `coverage-*.json` other than the
244
+ * empty placeholder. */
245
+ async function hasV8Reports(dir) {
246
+ const fs = await import("node:fs/promises");
247
+ try {
248
+ return (await fs.readdir(dir)).some((n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT);
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ }
201
254
  /** V8 files already compacted, by path. Module memory: forks with the
202
255
  * environment, so a child never re-parses its ancestors' dumps. */
203
256
  const COMPACTED_V8_REPORTS = new Set();
204
- /** A script is the app's own when it is a file outside node_modules. */
257
+ /** A script is the app's own when it is a file outside node_modules —
258
+ * and not our hook, which every dump would otherwise carry. */
205
259
  export function isAppScriptUrl(url) {
206
- return url.startsWith("file://") && !url.includes("/node_modules/");
260
+ return (url.startsWith("file://") &&
261
+ !url.includes("/node_modules/") &&
262
+ !url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
207
263
  }
208
264
  /**
209
265
  * Compact one V8 coverage document to the app's own scripts. What Node
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.1",
3
+ "version": "0.59.3",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -5,8 +5,9 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import {
7
7
  NODE_COVERAGE_HOOK,
8
+ NODE_COVERAGE_EMPTY_REPORT,
8
9
  NODE_COVERAGE_HOOK_PATH,
9
- NODE_COVERAGE_SOCKET,
10
+ NODE_COVERAGE_SOCKET_PREFIX,
10
11
  applyCoverageAdapters,
11
12
  appendEnvFlag,
12
13
  browser,
@@ -133,101 +134,149 @@ describe("browserCoverage", () => {
133
134
  });
134
135
 
135
136
  // The node hook against a real `node` (the box has one; skipped where it
136
- // does not). A long-lived process armed with the hook must answer the
137
- // socket with a fresh V8 report; a second process must stay a bystander
138
- // and exit on its own; the socket path must work across a bind mount —
139
- // which is what a plain directory path stands in for here.
137
+ // does not). Every process armed with the hook must answer its own
138
+ // socket with a fresh V8 report — including a server that sits behind a
139
+ // wrapper process, the pnpm/tsx shape; stale sockets must be cleaned up;
140
+ // the socket path must work across a bind mount — which is what a plain
141
+ // directory path stands in for here.
140
142
  const hasNode = await new Promise<boolean>((resolve) => {
141
143
  const p = spawn("node", ["--version"]);
142
144
  p.on("error", () => resolve(false));
143
145
  p.on("exit", (code) => resolve(code === 0));
144
146
  });
145
147
 
148
+ const SERVER_JS = "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n";
149
+ // A wrapper that spawns the real server as a child node process and
150
+ // waits on it — what `pnpm exec`, `npm run` and the `tsx` binary do.
151
+ const WRAPPER_JS =
152
+ "const { spawn } = require('node:child_process');\n" +
153
+ "const c = spawn(process.execPath, [require('node:path').join(__dirname, 'server.js')], { stdio: 'inherit' });\n" +
154
+ "c.on('exit', (code) => process.exit(code ?? 1));\n";
155
+
146
156
  describe.if(hasNode)("nodeCoverage hook (real node)", () => {
147
- async function startServer(dir: string): Promise<ChildProcess> {
157
+ async function socketsIn(dir: string): Promise<string[]> {
158
+ return (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX)).sort();
159
+ }
160
+ async function reportsIn(dir: string): Promise<string[]> {
161
+ return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-") && n.endsWith(".json"));
162
+ }
163
+ async function start(dir: string, entry: string, expectSockets: number): Promise<ChildProcess> {
148
164
  const hook = path.join(dir, "hook.cjs");
149
165
  await fs.writeFile(hook, NODE_COVERAGE_HOOK);
150
- const server = path.join(dir, "server.js");
151
- await fs.writeFile(server, "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n");
152
- const child = spawn("node", ["--require", hook, server], {
153
- env: { ...process.env, NODE_V8_COVERAGE: dir },
166
+ await fs.writeFile(path.join(dir, "server.js"), SERVER_JS);
167
+ await fs.writeFile(path.join(dir, "wrapper.js"), WRAPPER_JS);
168
+ const child = spawn("node", [path.join(dir, entry)], {
169
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
154
170
  stdio: ["ignore", "pipe", "inherit"],
155
171
  });
156
172
  await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
157
- // The socket is bound after the module graph runs; wait for it.
158
- const sock = path.join(dir, NODE_COVERAGE_SOCKET);
159
- for (let i = 0; i < 100; i++) {
160
- try {
161
- await fs.stat(sock);
162
- break;
163
- } catch {
164
- await new Promise((r) => setTimeout(r, 20));
165
- }
173
+ for (let i = 0; i < 100 && (await socketsIn(dir)).length < expectSockets; i++) {
174
+ await new Promise((r) => setTimeout(r, 20));
166
175
  }
167
176
  return child;
168
177
  }
169
178
 
170
- test("capture asks the live server for a dump; a bystander exits on its own", async () => {
179
+ test("capture asks the live server; a short-lived process exits and unlinks its socket", async () => {
171
180
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
172
- const server = await startServer(dir);
181
+ const server = await start(dir, "server.js", 1);
173
182
  try {
174
- const reportsBefore = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
175
- expect(reportsBefore).toEqual([]);
183
+ expect(await reportsIn(dir)).toEqual([]);
176
184
  await node().capture!(fakeCtx(dir));
177
- const reports = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
185
+ const reports = await reportsIn(dir);
178
186
  expect(reports.length).toBe(1);
179
187
  const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
180
188
  result: { url: string }[];
181
189
  };
182
190
  expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
183
191
 
184
- // A second process with the same env: the socket is held, so it must
185
- // not try to serve, and it must exit (the server's socket is unref'd,
186
- // so a bystander is never kept alive by it).
187
- const bystander = spawn("node", ["--require", path.join(dir, "hook.cjs"), "-e", "1"], {
188
- env: { ...process.env, NODE_V8_COVERAGE: dir },
192
+ const short = spawn("node", ["-e", "1"], {
193
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${path.join(dir, "hook.cjs")}` },
189
194
  stdio: "ignore",
190
195
  });
191
- const code = await new Promise<number | null>((resolve) => bystander.on("exit", resolve));
196
+ const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
192
197
  expect(code).toBe(0);
193
- // …and it wrote its own exit-time report, as every node process does.
194
- const after = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
195
- expect(after.length).toBe(2);
198
+ expect((await socketsIn(dir)).length).toBe(1); // its socket is gone with it
199
+ expect((await reportsIn(dir)).length).toBe(2); // its exit-time report is there
196
200
 
197
- // The live server still answers.
198
201
  await node().capture!(fakeCtx(dir));
199
- expect((await fs.readdir(dir)).filter((n) => n.startsWith("coverage-")).length).toBe(3);
202
+ expect((await reportsIn(dir)).length).toBe(3);
200
203
  } finally {
201
204
  server.kill("SIGKILL");
202
205
  }
203
206
  });
204
207
 
205
- test("a stale socket left by a dead process is reclaimed", async () => {
208
+ test("a server behind a wrapper process is dumped too (pnpm exec / tsx shape)", async () => {
206
209
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
207
- const first = await startServer(dir);
208
- first.kill("SIGKILL");
209
- await new Promise<void>((resolve) => first.on("exit", () => resolve()));
210
- expect(await fs.stat(path.join(dir, NODE_COVERAGE_SOCKET))).toBeTruthy(); // stale file
211
- const second = await startServer(dir);
210
+ const wrapper = await start(dir, "wrapper.js", 2);
212
211
  try {
213
- // Give the EADDRINUSE → probe → unlink → listen dance a moment.
214
- let ok = false;
215
- for (let i = 0; i < 50 && !ok; i++) {
212
+ expect((await socketsIn(dir)).length).toBe(2);
213
+ await node().capture!(fakeCtx(dir));
214
+ const reports = await reportsIn(dir);
215
+ expect(reports.length).toBe(2);
216
+ const urls = new Set<string>();
217
+ for (const r of reports) {
218
+ const doc = JSON.parse(await fs.readFile(path.join(dir, r), "utf8")) as { result: { url: string }[] };
219
+ for (const s of doc.result) urls.add(s.url);
220
+ }
221
+ expect([...urls].some((u) => u.endsWith("/server.js"))).toBe(true);
222
+ expect([...urls].some((u) => u.endsWith("/wrapper.js"))).toBe(true);
223
+ } finally {
224
+ wrapper.kill("SIGKILL");
225
+ // The wrapper's child is orphaned by SIGKILL; find and kill it too.
226
+ const pids = (await socketsIn(dir)).map((n) => Number(n.slice(NODE_COVERAGE_SOCKET_PREFIX.length)));
227
+ for (const pid of pids) {
216
228
  try {
217
- await node().capture!(fakeCtx(dir));
218
- ok = true;
219
- } catch {
220
- await new Promise((r) => setTimeout(r, 50));
221
- }
229
+ process.kill(pid, "SIGKILL");
230
+ } catch {}
222
231
  }
223
- expect(ok).toBe(true);
232
+ }
233
+ });
234
+
235
+ test("short-lived CLI processes: exit dumps are the coverage, no live process needed", async () => {
236
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
237
+ const hook = path.join(dir, "coverage-hook.cjs");
238
+ await fs.writeFile(hook, NODE_COVERAGE_HOOK);
239
+ await fs.writeFile(path.join(dir, "cli.js"), "process.stdout.write('v1');\n");
240
+ // Nothing has run yet: an empty report, not a failure.
241
+ await node().capture!(fakeCtx(dir));
242
+ expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
243
+ // Two CLI runs, each its own process, each dumping at exit.
244
+ for (let i = 0; i < 2; i++) {
245
+ const cli = spawn("node", [path.join(dir, "cli.js")], {
246
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
247
+ stdio: "ignore",
248
+ });
249
+ expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
250
+ }
251
+ expect((await socketsIn(dir)).length).toBe(0);
252
+ await node().capture!(fakeCtx(dir));
253
+ const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
254
+ expect(reports.length).toBe(2);
255
+ const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
256
+ expect(doc.result.map((x) => x.url)).toEqual([`file://${path.join(dir, "cli.js")}`]); // compacted
257
+ });
258
+
259
+ test("a stale socket left by a killed process is removed at capture", async () => {
260
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
261
+ const first = await start(dir, "server.js", 1);
262
+ first.kill("SIGKILL");
263
+ await new Promise<void>((resolve) => first.on("exit", () => resolve()));
264
+ expect((await socketsIn(dir)).length).toBe(1); // stale
265
+ await node().capture!(fakeCtx(dir));
266
+ expect((await socketsIn(dir)).length).toBe(0); // cleaned up
267
+ const second = await start(dir, "server.js", 1);
268
+ try {
269
+ await node().capture!(fakeCtx(dir));
270
+ expect((await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT).length).toBe(1);
224
271
  } finally {
225
272
  second.kill("SIGKILL");
226
273
  }
227
274
  });
228
275
 
229
- test("no server: capture fails naming the socket", async () => {
276
+ test("no process and no dump: an empty report is written, once", async () => {
230
277
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
231
- await expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
278
+ await node().capture!(fakeCtx(dir));
279
+ await node().capture!(fakeCtx(dir));
280
+ expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
232
281
  });
233
282
  });
package/src/coverage.ts CHANGED
@@ -34,8 +34,12 @@ export { COVERAGE_CONTAINER_DIR };
34
34
  /** Where `node()` mounts its hook inside the container. */
35
35
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
36
36
 
37
- /** The socket the node hook answers on, relative to the coverage dir. */
38
- export const NODE_COVERAGE_SOCKET = ".ctl";
37
+ /** The report `node()` writes when nothing has run yet on a branch. */
38
+ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
39
+
40
+ /** Prefix of the per-process sockets the node hook answers on, relative
41
+ * to the coverage dir: `.ctl-<pid>`. */
42
+ export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
39
43
 
40
44
  /** What `configure` learns about the service it rewrites. */
41
45
  export interface CoverageConfigureInfo {
@@ -178,16 +182,19 @@ export function appendEnvFlag(
178
182
  // ── node ──────────────────────────────────────────────────────────
179
183
 
180
184
  /**
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.
185
+ * The hook `node()` mounts and `--require`s into every node process of
186
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
187
+ * when a process exits; a long-lived server never exits, so the hook
188
+ * binds a Unix socket in the coverage directory and calls
189
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
190
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
191
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
192
+ * `npm run`) whose child is the real server; a single socket on the
193
+ * wrapper dumped the wrapper and silently never the server (reported by
194
+ * a user 2026-08-27). At capture spectest asks every live socket and
195
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
196
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
197
+ * Temporal worker, restarts nodemon), a socket is nobody's.
191
198
  */
192
199
  export const NODE_COVERAGE_HOOK = `"use strict";
193
200
  // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
@@ -195,10 +202,9 @@ if (process.env.NODE_V8_COVERAGE) {
195
202
  const net = require("node:net");
196
203
  const fs = require("node:fs");
197
204
  const v8 = require("node:v8");
198
- const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
205
+ const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
199
206
  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.
207
+ // A dump is taken only on an explicit "dump" request.
202
208
  let buf = "";
203
209
  conn.on("data", (chunk) => {
204
210
  buf += chunk;
@@ -215,26 +221,21 @@ if (process.env.NODE_V8_COVERAGE) {
215
221
  });
216
222
  });
217
223
  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
- });
224
+ server.on("error", () => {});
225
+ try { fs.unlinkSync(SOCK); } catch {}
229
226
  server.listen(SOCK);
227
+ process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
230
228
  }
231
229
  `;
232
230
 
233
231
  /**
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
232
+ * Coverage for a Node service — a long-lived server, or a container whose
233
+ * node processes are short-lived CLIs run by `ctx.exec`. Sets
234
+ * `NODE_V8_COVERAGE` to the coverage directory (every node process in the
235
+ * container then writes V8 coverage JSON when it exits) and `--require`s
236
+ * a hook that lets spectest ask every live node process for a dump at
237
+ * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
238
+ * `tsx`). Nothing
238
239
  * for the app to write. Each dump is compacted to the app's own scripts
239
240
  * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
240
241
  * `sourceMappingURL` next to the file) Node records the map in the
@@ -253,46 +254,99 @@ export function node(): CoverageAdapter {
253
254
  };
254
255
  },
255
256
  async capture(ctx) {
256
- const { connect } = await import("node:net");
257
- const path = await import("node:path");
258
- const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
259
- const reply = await new Promise<string>((resolve, reject) => {
260
- const chunks: Buffer[] = [];
261
- const c = connect(sock, () => c.write("dump\n"));
262
- const onAbort = (): void => {
263
- c.destroy();
264
- reject(new Error("timed out waiting for the node hook to write a report"));
265
- };
266
- ctx.signal.addEventListener("abort", onAbort, { once: true });
267
- c.on("data", (b: Buffer) => chunks.push(b));
268
- c.on("error", (err: NodeJS.ErrnoException) => {
269
- ctx.signal.removeEventListener("abort", onAbort);
270
- reject(
271
- new Error(
272
- err.code === "ENOENT" || err.code === "ECONNREFUSED"
273
- ? `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)?`
274
- : err.message,
275
- ),
276
- );
277
- });
278
- c.on("close", () => {
279
- ctx.signal.removeEventListener("abort", onAbort);
280
- resolve(Buffer.concat(chunks).toString("utf8").trim());
281
- });
282
- });
283
- if (reply !== "ok") throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
257
+ // Two shapes, one adapter: a long-lived server answers its socket
258
+ // here; a service whose node processes are short-lived (a CLI run
259
+ // by `ctx.exec` from a `sleep infinity` container) has no socket to
260
+ // answer and its coverage is the exit-time dumps already on disk.
261
+ // So no live process is not an error. No report at all — nothing
262
+ // has run yet on this branch — is recorded as an empty document
263
+ // rather than failed: an empty report is a report. A server whose
264
+ // hook never loaded (NODE_OPTIONS not reaching it) then shows as
265
+ // empty reports after bring-up, which the boot log warns about.
266
+ await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
284
267
  await compactV8Reports(ctx.reportDir);
268
+ if (!(await hasV8Reports(ctx.reportDir))) {
269
+ await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
270
+ }
285
271
  },
286
272
  };
287
273
  }
288
274
 
275
+ /**
276
+ * Ask every node process that holds a hook socket in `dir` for a dump.
277
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
278
+ * OOM) is removed. Returns how many processes answered; a process that
279
+ * answered with an error throws.
280
+ */
281
+ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number> {
282
+ const fs = await import("node:fs/promises");
283
+ const path = await import("node:path");
284
+ const { connect } = await import("node:net");
285
+ let names: string[];
286
+ try {
287
+ names = (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX));
288
+ } catch {
289
+ return 0;
290
+ }
291
+ let live = 0;
292
+ for (const name of names) {
293
+ const sock = path.join(dir, name);
294
+ const reply = await new Promise<string | null>((resolve, reject) => {
295
+ const chunks: Buffer[] = [];
296
+ const c = connect(sock, () => c.write("dump\n"));
297
+ const onAbort = (): void => {
298
+ c.destroy();
299
+ reject(new Error(`timed out waiting for the node hook (${name}) to write a report`));
300
+ };
301
+ signal.addEventListener("abort", onAbort, { once: true });
302
+ c.on("data", (b: Buffer) => chunks.push(b));
303
+ c.on("error", (err: NodeJS.ErrnoException) => {
304
+ signal.removeEventListener("abort", onAbort);
305
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") resolve(null);
306
+ else reject(err);
307
+ });
308
+ c.on("close", (hadError: boolean) => {
309
+ signal.removeEventListener("abort", onAbort);
310
+ if (!hadError) resolve(Buffer.concat(chunks).toString("utf8").trim());
311
+ });
312
+ });
313
+ if (reply === null) {
314
+ // Nobody home: the process is gone. Clean up so the next capture
315
+ // does not knock again.
316
+ await fs.unlink(sock).catch(() => {});
317
+ continue;
318
+ }
319
+ if (reply !== "ok") throw new Error(`the node hook (${name}) answered: ${reply || "(nothing)"}`);
320
+ live++;
321
+ }
322
+ return live;
323
+ }
324
+
325
+ /** True when `dir` holds at least one `coverage-*.json` other than the
326
+ * empty placeholder. */
327
+ async function hasV8Reports(dir: string): Promise<boolean> {
328
+ const fs = await import("node:fs/promises");
329
+ try {
330
+ return (await fs.readdir(dir)).some(
331
+ (n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT,
332
+ );
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
337
+
289
338
  /** V8 files already compacted, by path. Module memory: forks with the
290
339
  * environment, so a child never re-parses its ancestors' dumps. */
291
340
  const COMPACTED_V8_REPORTS = new Set<string>();
292
341
 
293
- /** A script is the app's own when it is a file outside node_modules. */
342
+ /** A script is the app's own when it is a file outside node_modules —
343
+ * and not our hook, which every dump would otherwise carry. */
294
344
  export function isAppScriptUrl(url: string): boolean {
295
- return url.startsWith("file://") && !url.includes("/node_modules/");
345
+ return (
346
+ url.startsWith("file://") &&
347
+ !url.includes("/node_modules/") &&
348
+ !url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop())
349
+ );
296
350
  }
297
351
 
298
352
  /**