@specific.dev/spectest 0.59.0 → 0.59.2

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,9 @@ 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
+ /** Prefix of the per-process sockets the node hook answers on, relative
7
+ * to the coverage dir: `.ctl-<pid>`. */
8
+ export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
8
9
  /** What `configure` learns about the service it rewrites. */
9
10
  export interface CoverageConfigureInfo {
10
11
  /** The services-map key. */
@@ -85,28 +86,57 @@ export declare function applyCoverageAdapters<S extends ServiceConfig>(key: stri
85
86
  /** `env` with `value` appended to `name` (space-separated), or set. */
86
87
  export declare function appendEnvFlag(env: Readonly<Record<string, string>> | undefined, name: string, value: string): Record<string, string>;
87
88
  /**
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.
89
+ * The hook `node()` mounts and `--require`s into every node process of
90
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
91
+ * when a process exits; a long-lived server never exits, so the hook
92
+ * binds a Unix socket in the coverage directory and calls
93
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
94
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
95
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
96
+ * `npm run`) whose child is the real server; a single socket on the
97
+ * wrapper dumped the wrapper and silently never the server (reported by
98
+ * a user 2026-08-27). At capture spectest asks every live socket and
99
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
100
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
101
+ * Temporal worker, restarts nodemon), a socket is nobody's.
98
102
  */
99
103
  export declare const NODE_COVERAGE_HOOK: string;
100
104
  /**
101
105
  * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
102
106
  * directory (every node process in the container then writes V8
103
107
  * 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
105
- * for the app to write. Source maps: with `--enable-source-maps` (or a
108
+ * ask every live node process for a dump at capture time — the server,
109
+ * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
110
+ * for the app to write. Each dump is compacted to the app's own scripts
111
+ * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
106
112
  * `sourceMappingURL` next to the file) Node records the map in the
107
113
  * report, which is what maps a TypeScript service back to its sources.
108
114
  */
109
115
  export declare function node(): CoverageAdapter;
116
+ /**
117
+ * Ask every node process that holds a hook socket in `dir` for a dump.
118
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
119
+ * OOM) is removed. Returns how many processes answered; a process that
120
+ * answered with an error throws.
121
+ */
122
+ export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
123
+ /** A script is the app's own when it is a file outside node_modules. */
124
+ export declare function isAppScriptUrl(url: string): boolean;
125
+ /**
126
+ * Compact one V8 coverage document to the app's own scripts. What Node
127
+ * writes is everything the process loaded: `node:` internals, every
128
+ * `node_modules` file, and — under `--enable-source-maps` — a
129
+ * `source-map-cache` with each file's full map **and its sources**,
130
+ * repeated in every dump. Measured on a real project: a 12–20 MiB dump
131
+ * per capture, of which the app's own coverage was under 0.5 MiB, and a
132
+ * suite that hit the 64 MiB cap on its third test. Kept: `file://`
133
+ * scripts outside `node_modules`, the map entries of exactly those
134
+ * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
135
+ * document — nothing is converted.
136
+ */
137
+ export declare function compactV8Document(doc: Record<string, unknown>): Record<string, unknown>;
138
+ /** Compact every not-yet-compacted V8 document in `dir`, in place. */
139
+ export declare function compactV8Reports(dir: string): Promise<void>;
110
140
  /**
111
141
  * Coverage for the frontend a service serves. The code runs in the guest
112
142
  * browser — spectest's own process — so no report can be written by the
package/dist/coverage.js CHANGED
@@ -29,8 +29,9 @@ 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
+ /** Prefix of the per-process sockets the node hook answers on, relative
33
+ * to the coverage dir: `.ctl-<pid>`. */
34
+ export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
34
35
  /** Type an inline adapter. Identity at runtime. */
35
36
  export function defineAdapter(adapter) {
36
37
  return adapter;
@@ -95,16 +96,19 @@ export function appendEnvFlag(env, name, value) {
95
96
  }
96
97
  // ── node ──────────────────────────────────────────────────────────
97
98
  /**
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.
99
+ * The hook `node()` mounts and `--require`s into every node process of
100
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
101
+ * when a process exits; a long-lived server never exits, so the hook
102
+ * binds a Unix socket in the coverage directory and calls
103
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
104
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
105
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
106
+ * `npm run`) whose child is the real server; a single socket on the
107
+ * wrapper dumped the wrapper and silently never the server (reported by
108
+ * a user 2026-08-27). At capture spectest asks every live socket and
109
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
110
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
111
+ * Temporal worker, restarts nodemon), a socket is nobody's.
108
112
  */
109
113
  export const NODE_COVERAGE_HOOK = `"use strict";
110
114
  // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
@@ -112,10 +116,9 @@ if (process.env.NODE_V8_COVERAGE) {
112
116
  const net = require("node:net");
113
117
  const fs = require("node:fs");
114
118
  const v8 = require("node:v8");
115
- const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
119
+ const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
116
120
  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.
121
+ // A dump is taken only on an explicit "dump" request.
119
122
  let buf = "";
120
123
  conn.on("data", (chunk) => {
121
124
  buf += chunk;
@@ -132,26 +135,20 @@ if (process.env.NODE_V8_COVERAGE) {
132
135
  });
133
136
  });
134
137
  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
- });
138
+ server.on("error", () => {});
139
+ try { fs.unlinkSync(SOCK); } catch {}
146
140
  server.listen(SOCK);
141
+ process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
147
142
  }
148
143
  `;
149
144
  /**
150
145
  * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
151
146
  * directory (every node process in the container then writes V8
152
147
  * 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
154
- * for the app to write. Source maps: with `--enable-source-maps` (or a
148
+ * ask every live node process for a dump at capture time — the server,
149
+ * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
150
+ * for the app to write. Each dump is compacted to the app's own scripts
151
+ * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
155
152
  * `sourceMappingURL` next to the file) Node records the map in the
156
153
  * report, which is what maps a TypeScript service back to its sources.
157
154
  */
@@ -168,34 +165,144 @@ export function node() {
168
165
  };
169
166
  },
170
167
  async capture(ctx) {
171
- const { connect } = await import("node:net");
172
- const path = await import("node:path");
173
- const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
174
- const reply = await new Promise((resolve, reject) => {
175
- const chunks = [];
176
- const c = connect(sock, () => c.write("dump\n"));
177
- const onAbort = () => {
178
- c.destroy();
179
- reject(new Error("timed out waiting for the node hook to write a report"));
180
- };
181
- ctx.signal.addEventListener("abort", onAbort, { once: true });
182
- c.on("data", (b) => chunks.push(b));
183
- c.on("error", (err) => {
184
- ctx.signal.removeEventListener("abort", onAbort);
185
- reject(new Error(err.code === "ENOENT" || err.code === "ECONNREFUSED"
186
- ? `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)?`
187
- : err.message));
188
- });
189
- c.on("close", () => {
190
- ctx.signal.removeEventListener("abort", onAbort);
191
- resolve(Buffer.concat(chunks).toString("utf8").trim());
192
- });
193
- });
194
- if (reply !== "ok")
195
- throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
168
+ const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
169
+ if (live === 0) {
170
+ throw new Error(`no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`);
171
+ }
172
+ await compactV8Reports(ctx.reportDir);
196
173
  },
197
174
  };
198
175
  }
176
+ /**
177
+ * Ask every node process that holds a hook socket in `dir` for a dump.
178
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
179
+ * OOM) is removed. Returns how many processes answered; a process that
180
+ * answered with an error throws.
181
+ */
182
+ export async function dumpAllNodeProcesses(dir, signal) {
183
+ const fs = await import("node:fs/promises");
184
+ const path = await import("node:path");
185
+ const { connect } = await import("node:net");
186
+ let names;
187
+ try {
188
+ names = (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX));
189
+ }
190
+ catch {
191
+ return 0;
192
+ }
193
+ let live = 0;
194
+ for (const name of names) {
195
+ const sock = path.join(dir, name);
196
+ const reply = await new Promise((resolve, reject) => {
197
+ const chunks = [];
198
+ const c = connect(sock, () => c.write("dump\n"));
199
+ const onAbort = () => {
200
+ c.destroy();
201
+ reject(new Error(`timed out waiting for the node hook (${name}) to write a report`));
202
+ };
203
+ signal.addEventListener("abort", onAbort, { once: true });
204
+ c.on("data", (b) => chunks.push(b));
205
+ c.on("error", (err) => {
206
+ signal.removeEventListener("abort", onAbort);
207
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED")
208
+ resolve(null);
209
+ else
210
+ reject(err);
211
+ });
212
+ c.on("close", (hadError) => {
213
+ signal.removeEventListener("abort", onAbort);
214
+ if (!hadError)
215
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
216
+ });
217
+ });
218
+ if (reply === null) {
219
+ // Nobody home: the process is gone. Clean up so the next capture
220
+ // does not knock again.
221
+ await fs.unlink(sock).catch(() => { });
222
+ continue;
223
+ }
224
+ if (reply !== "ok")
225
+ throw new Error(`the node hook (${name}) answered: ${reply || "(nothing)"}`);
226
+ live++;
227
+ }
228
+ return live;
229
+ }
230
+ /** V8 files already compacted, by path. Module memory: forks with the
231
+ * environment, so a child never re-parses its ancestors' dumps. */
232
+ const COMPACTED_V8_REPORTS = new Set();
233
+ /** A script is the app's own when it is a file outside node_modules. */
234
+ export function isAppScriptUrl(url) {
235
+ return url.startsWith("file://") && !url.includes("/node_modules/");
236
+ }
237
+ /**
238
+ * Compact one V8 coverage document to the app's own scripts. What Node
239
+ * writes is everything the process loaded: `node:` internals, every
240
+ * `node_modules` file, and — under `--enable-source-maps` — a
241
+ * `source-map-cache` with each file's full map **and its sources**,
242
+ * repeated in every dump. Measured on a real project: a 12–20 MiB dump
243
+ * per capture, of which the app's own coverage was under 0.5 MiB, and a
244
+ * suite that hit the 64 MiB cap on its third test. Kept: `file://`
245
+ * scripts outside `node_modules`, the map entries of exactly those
246
+ * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
247
+ * document — nothing is converted.
248
+ */
249
+ export function compactV8Document(doc) {
250
+ const result = Array.isArray(doc.result) ? doc.result : [];
251
+ const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
252
+ const out = { ...doc, result: kept };
253
+ const cache = doc["source-map-cache"];
254
+ if (cache && typeof cache === "object") {
255
+ const urls = new Set(kept.map((s) => s.url));
256
+ const slim = {};
257
+ for (const [url, entry] of Object.entries(cache)) {
258
+ if (!urls.has(url) || !entry || typeof entry !== "object")
259
+ continue;
260
+ const e = { ...entry };
261
+ if (e.data && typeof e.data === "object") {
262
+ const { sourcesContent: _dropped, ...data } = e.data;
263
+ e.data = data;
264
+ }
265
+ slim[url] = e;
266
+ }
267
+ if (Object.keys(slim).length > 0)
268
+ out["source-map-cache"] = slim;
269
+ else
270
+ delete out["source-map-cache"];
271
+ }
272
+ return out;
273
+ }
274
+ /** Compact every not-yet-compacted V8 document in `dir`, in place. */
275
+ export async function compactV8Reports(dir) {
276
+ const fs = await import("node:fs/promises");
277
+ const path = await import("node:path");
278
+ let names;
279
+ try {
280
+ names = await fs.readdir(dir);
281
+ }
282
+ catch {
283
+ return;
284
+ }
285
+ for (const name of names) {
286
+ if (!name.startsWith("coverage-") || !name.endsWith(".json"))
287
+ continue;
288
+ const file = path.join(dir, name);
289
+ if (COMPACTED_V8_REPORTS.has(file))
290
+ continue;
291
+ let doc;
292
+ try {
293
+ doc = JSON.parse(await fs.readFile(file, "utf8"));
294
+ }
295
+ catch {
296
+ continue; // a dump mid-write, or not ours; the read path judges it
297
+ }
298
+ if (!Array.isArray(doc.result))
299
+ continue;
300
+ const tmp = path.join(dir, `.${name}.compact`);
301
+ await fs.writeFile(tmp, JSON.stringify(compactV8Document(doc)));
302
+ await fs.rename(tmp, file);
303
+ COMPACTED_V8_REPORTS.add(file);
304
+ }
305
+ }
199
306
  // ── browser ───────────────────────────────────────────────────────
200
307
  /**
201
308
  * Coverage for the frontend a service serves. The code runs in the guest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.0",
3
+ "version": "0.59.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -6,11 +6,13 @@ import path from "node:path";
6
6
  import {
7
7
  NODE_COVERAGE_HOOK,
8
8
  NODE_COVERAGE_HOOK_PATH,
9
- NODE_COVERAGE_SOCKET,
9
+ NODE_COVERAGE_SOCKET_PREFIX,
10
10
  applyCoverageAdapters,
11
11
  appendEnvFlag,
12
12
  browser,
13
13
  command,
14
+ compactV8Document,
15
+ compactV8Reports,
14
16
  node,
15
17
  validateCoverage,
16
18
  type CoverageCaptureContext,
@@ -68,6 +70,45 @@ describe("applyCoverageAdapters", () => {
68
70
  });
69
71
  });
70
72
 
73
+ describe("compactV8Document", () => {
74
+ const doc = {
75
+ result: [
76
+ { scriptId: "1", url: "file:///app/src/a.ts", functions: [] },
77
+ { scriptId: "2", url: "file:///app/node_modules/x/index.js", functions: [] },
78
+ { scriptId: "3", url: "node:fs", functions: [] },
79
+ { scriptId: "4", url: "", functions: [] },
80
+ ],
81
+ "source-map-cache": {
82
+ "file:///app/src/a.ts": { lineLengths: [1], data: { mappings: "AAAA", sources: ["a.ts"], sourcesContent: ["x"] }, url: null },
83
+ "file:///app/node_modules/x/index.js": { lineLengths: [1], data: { mappings: "AAAA" }, url: null },
84
+ },
85
+ };
86
+ test("keeps app scripts and their maps, drops the rest and sourcesContent", () => {
87
+ const out = compactV8Document(doc) as typeof doc;
88
+ expect(out.result.map((s) => s.url)).toEqual(["file:///app/src/a.ts"]);
89
+ expect(Object.keys(out["source-map-cache"])).toEqual(["file:///app/src/a.ts"]);
90
+ expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
91
+ });
92
+ test("a dump with no app script keeps an empty result and no cache", () => {
93
+ const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
94
+ expect(out.result).toEqual([]);
95
+ expect("source-map-cache" in out).toBe(false);
96
+ });
97
+ test("compactV8Reports rewrites coverage-*.json once and leaves other files alone", async () => {
98
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
99
+ await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), JSON.stringify(doc));
100
+ await fs.writeFile(path.join(dir, "browser.lcov"), "TN:\n");
101
+ await compactV8Reports(dir);
102
+ const back = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")) as { result: unknown[] };
103
+ expect(back.result.length).toBe(1);
104
+ expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
105
+ // Idempotent and cheap: a second pass is a no-op on the same file.
106
+ await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), "not json");
107
+ await compactV8Reports(dir);
108
+ expect(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")).toBe("not json");
109
+ });
110
+ });
111
+
71
112
  /** A capture context over a real directory, no container. */
72
113
  function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCaptureContext {
73
114
  return {
@@ -92,94 +133,116 @@ describe("browserCoverage", () => {
92
133
  });
93
134
 
94
135
  // 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.
136
+ // does not). Every process armed with the hook must answer its own
137
+ // socket with a fresh V8 report — including a server that sits behind a
138
+ // wrapper process, the pnpm/tsx shape; stale sockets must be cleaned up;
139
+ // the socket path must work across a bind mount — which is what a plain
140
+ // directory path stands in for here.
99
141
  const hasNode = await new Promise<boolean>((resolve) => {
100
142
  const p = spawn("node", ["--version"]);
101
143
  p.on("error", () => resolve(false));
102
144
  p.on("exit", (code) => resolve(code === 0));
103
145
  });
104
146
 
147
+ const SERVER_JS = "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n";
148
+ // A wrapper that spawns the real server as a child node process and
149
+ // waits on it — what `pnpm exec`, `npm run` and the `tsx` binary do.
150
+ const WRAPPER_JS =
151
+ "const { spawn } = require('node:child_process');\n" +
152
+ "const c = spawn(process.execPath, [require('node:path').join(__dirname, 'server.js')], { stdio: 'inherit' });\n" +
153
+ "c.on('exit', (code) => process.exit(code ?? 1));\n";
154
+
105
155
  describe.if(hasNode)("nodeCoverage hook (real node)", () => {
106
- async function startServer(dir: string): Promise<ChildProcess> {
156
+ async function socketsIn(dir: string): Promise<string[]> {
157
+ return (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX)).sort();
158
+ }
159
+ async function reportsIn(dir: string): Promise<string[]> {
160
+ return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
161
+ }
162
+ async function start(dir: string, entry: string, expectSockets: number): Promise<ChildProcess> {
107
163
  const hook = path.join(dir, "hook.cjs");
108
164
  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 },
165
+ await fs.writeFile(path.join(dir, "server.js"), SERVER_JS);
166
+ await fs.writeFile(path.join(dir, "wrapper.js"), WRAPPER_JS);
167
+ const child = spawn("node", [path.join(dir, entry)], {
168
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
113
169
  stdio: ["ignore", "pipe", "inherit"],
114
170
  });
115
171
  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
- }
172
+ for (let i = 0; i < 100 && (await socketsIn(dir)).length < expectSockets; i++) {
173
+ await new Promise((r) => setTimeout(r, 20));
125
174
  }
126
175
  return child;
127
176
  }
128
177
 
129
- test("capture asks the live server for a dump; a bystander exits on its own", async () => {
178
+ test("capture asks the live server; a short-lived process exits and unlinks its socket", async () => {
130
179
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
131
- const server = await startServer(dir);
180
+ const server = await start(dir, "server.js", 1);
132
181
  try {
133
- const reportsBefore = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
134
- expect(reportsBefore).toEqual([]);
182
+ expect(await reportsIn(dir)).toEqual([]);
135
183
  await node().capture!(fakeCtx(dir));
136
- const reports = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
184
+ const reports = await reportsIn(dir);
137
185
  expect(reports.length).toBe(1);
138
186
  const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
139
187
  result: { url: string }[];
140
188
  };
141
189
  expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
142
190
 
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 },
191
+ const short = spawn("node", ["-e", "1"], {
192
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${path.join(dir, "hook.cjs")}` },
148
193
  stdio: "ignore",
149
194
  });
150
- const code = await new Promise<number | null>((resolve) => bystander.on("exit", resolve));
195
+ const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
151
196
  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);
197
+ expect((await socketsIn(dir)).length).toBe(1); // its socket is gone with it
198
+ expect((await reportsIn(dir)).length).toBe(2); // its exit-time report is there
155
199
 
156
- // The live server still answers.
157
200
  await node().capture!(fakeCtx(dir));
158
- expect((await fs.readdir(dir)).filter((n) => n.startsWith("coverage-")).length).toBe(3);
201
+ expect((await reportsIn(dir)).length).toBe(3);
159
202
  } finally {
160
203
  server.kill("SIGKILL");
161
204
  }
162
205
  });
163
206
 
164
- test("a stale socket left by a dead process is reclaimed", async () => {
207
+ test("a server behind a wrapper process is dumped too (pnpm exec / tsx shape)", async () => {
165
208
  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);
209
+ const wrapper = await start(dir, "wrapper.js", 2);
171
210
  try {
172
- // Give the EADDRINUSE → probe → unlink → listen dance a moment.
173
- let ok = false;
174
- for (let i = 0; i < 50 && !ok; i++) {
211
+ expect((await socketsIn(dir)).length).toBe(2);
212
+ await node().capture!(fakeCtx(dir));
213
+ const reports = await reportsIn(dir);
214
+ expect(reports.length).toBe(2);
215
+ const urls = new Set<string>();
216
+ for (const r of reports) {
217
+ const doc = JSON.parse(await fs.readFile(path.join(dir, r), "utf8")) as { result: { url: string }[] };
218
+ for (const s of doc.result) urls.add(s.url);
219
+ }
220
+ expect([...urls].some((u) => u.endsWith("/server.js"))).toBe(true);
221
+ expect([...urls].some((u) => u.endsWith("/wrapper.js"))).toBe(true);
222
+ } finally {
223
+ wrapper.kill("SIGKILL");
224
+ // The wrapper's child is orphaned by SIGKILL; find and kill it too.
225
+ const pids = (await socketsIn(dir)).map((n) => Number(n.slice(NODE_COVERAGE_SOCKET_PREFIX.length)));
226
+ for (const pid of pids) {
175
227
  try {
176
- await node().capture!(fakeCtx(dir));
177
- ok = true;
178
- } catch {
179
- await new Promise((r) => setTimeout(r, 50));
180
- }
228
+ process.kill(pid, "SIGKILL");
229
+ } catch {}
181
230
  }
182
- expect(ok).toBe(true);
231
+ }
232
+ });
233
+
234
+ test("a stale socket left by a killed process is removed at capture", async () => {
235
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
236
+ const first = await start(dir, "server.js", 1);
237
+ first.kill("SIGKILL");
238
+ await new Promise<void>((resolve) => first.on("exit", () => resolve()));
239
+ expect((await socketsIn(dir)).length).toBe(1); // stale
240
+ await expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
241
+ expect((await socketsIn(dir)).length).toBe(0); // cleaned up
242
+ const second = await start(dir, "server.js", 1);
243
+ try {
244
+ await node().capture!(fakeCtx(dir));
245
+ expect((await reportsIn(dir)).length).toBe(1);
183
246
  } finally {
184
247
  second.kill("SIGKILL");
185
248
  }
package/src/coverage.ts CHANGED
@@ -34,8 +34,9 @@ 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
+ /** Prefix of the per-process sockets the node hook answers on, relative
38
+ * to the coverage dir: `.ctl-<pid>`. */
39
+ export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
39
40
 
40
41
  /** What `configure` learns about the service it rewrites. */
41
42
  export interface CoverageConfigureInfo {
@@ -178,16 +179,19 @@ export function appendEnvFlag(
178
179
  // ── node ──────────────────────────────────────────────────────────
179
180
 
180
181
  /**
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.
182
+ * The hook `node()` mounts and `--require`s into every node process of
183
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
184
+ * when a process exits; a long-lived server never exits, so the hook
185
+ * binds a Unix socket in the coverage directory and calls
186
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
187
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
188
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
189
+ * `npm run`) whose child is the real server; a single socket on the
190
+ * wrapper dumped the wrapper and silently never the server (reported by
191
+ * a user 2026-08-27). At capture spectest asks every live socket and
192
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
193
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
194
+ * Temporal worker, restarts nodemon), a socket is nobody's.
191
195
  */
192
196
  export const NODE_COVERAGE_HOOK = `"use strict";
193
197
  // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
@@ -195,10 +199,9 @@ if (process.env.NODE_V8_COVERAGE) {
195
199
  const net = require("node:net");
196
200
  const fs = require("node:fs");
197
201
  const v8 = require("node:v8");
198
- const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
202
+ const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
199
203
  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.
204
+ // A dump is taken only on an explicit "dump" request.
202
205
  let buf = "";
203
206
  conn.on("data", (chunk) => {
204
207
  buf += chunk;
@@ -215,18 +218,10 @@ if (process.env.NODE_V8_COVERAGE) {
215
218
  });
216
219
  });
217
220
  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
- });
221
+ server.on("error", () => {});
222
+ try { fs.unlinkSync(SOCK); } catch {}
229
223
  server.listen(SOCK);
224
+ process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
230
225
  }
231
226
  `;
232
227
 
@@ -234,8 +229,10 @@ if (process.env.NODE_V8_COVERAGE) {
234
229
  * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
235
230
  * directory (every node process in the container then writes V8
236
231
  * coverage JSON when it exits) and `--require`s a hook that lets spectest
237
- * ask the long-lived server process for a dump at capture time. Nothing
238
- * for the app to write. Source maps: with `--enable-source-maps` (or a
232
+ * ask every live node process for a dump at capture time — the server,
233
+ * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
234
+ * for the app to write. Each dump is compacted to the app's own scripts
235
+ * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
239
236
  * `sourceMappingURL` next to the file) Node records the map in the
240
237
  * report, which is what maps a TypeScript service back to its sources.
241
238
  */
@@ -252,38 +249,139 @@ export function node(): CoverageAdapter {
252
249
  };
253
250
  },
254
251
  async capture(ctx) {
255
- const { connect } = await import("node:net");
256
- const path = await import("node:path");
257
- const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
258
- const reply = await new Promise<string>((resolve, reject) => {
259
- const chunks: Buffer[] = [];
260
- const c = connect(sock, () => c.write("dump\n"));
261
- const onAbort = (): void => {
262
- c.destroy();
263
- reject(new Error("timed out waiting for the node hook to write a report"));
264
- };
265
- ctx.signal.addEventListener("abort", onAbort, { once: true });
266
- c.on("data", (b: Buffer) => chunks.push(b));
267
- c.on("error", (err: NodeJS.ErrnoException) => {
268
- ctx.signal.removeEventListener("abort", onAbort);
269
- reject(
270
- new Error(
271
- err.code === "ENOENT" || err.code === "ECONNREFUSED"
272
- ? `no node process is serving ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET} — is the service's main process node, and does it run with the service's env (NODE_OPTIONS)?`
273
- : err.message,
274
- ),
275
- );
276
- });
277
- c.on("close", () => {
278
- ctx.signal.removeEventListener("abort", onAbort);
279
- resolve(Buffer.concat(chunks).toString("utf8").trim());
280
- });
281
- });
282
- if (reply !== "ok") throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
252
+ const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
253
+ if (live === 0) {
254
+ throw new Error(
255
+ `no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`,
256
+ );
257
+ }
258
+ await compactV8Reports(ctx.reportDir);
283
259
  },
284
260
  };
285
261
  }
286
262
 
263
+ /**
264
+ * Ask every node process that holds a hook socket in `dir` for a dump.
265
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
266
+ * OOM) is removed. Returns how many processes answered; a process that
267
+ * answered with an error throws.
268
+ */
269
+ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number> {
270
+ const fs = await import("node:fs/promises");
271
+ const path = await import("node:path");
272
+ const { connect } = await import("node:net");
273
+ let names: string[];
274
+ try {
275
+ names = (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX));
276
+ } catch {
277
+ return 0;
278
+ }
279
+ let live = 0;
280
+ for (const name of names) {
281
+ const sock = path.join(dir, name);
282
+ const reply = await new Promise<string | null>((resolve, reject) => {
283
+ const chunks: Buffer[] = [];
284
+ const c = connect(sock, () => c.write("dump\n"));
285
+ const onAbort = (): void => {
286
+ c.destroy();
287
+ reject(new Error(`timed out waiting for the node hook (${name}) to write a report`));
288
+ };
289
+ signal.addEventListener("abort", onAbort, { once: true });
290
+ c.on("data", (b: Buffer) => chunks.push(b));
291
+ c.on("error", (err: NodeJS.ErrnoException) => {
292
+ signal.removeEventListener("abort", onAbort);
293
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") resolve(null);
294
+ else reject(err);
295
+ });
296
+ c.on("close", (hadError: boolean) => {
297
+ signal.removeEventListener("abort", onAbort);
298
+ if (!hadError) resolve(Buffer.concat(chunks).toString("utf8").trim());
299
+ });
300
+ });
301
+ if (reply === null) {
302
+ // Nobody home: the process is gone. Clean up so the next capture
303
+ // does not knock again.
304
+ await fs.unlink(sock).catch(() => {});
305
+ continue;
306
+ }
307
+ if (reply !== "ok") throw new Error(`the node hook (${name}) answered: ${reply || "(nothing)"}`);
308
+ live++;
309
+ }
310
+ return live;
311
+ }
312
+
313
+ /** V8 files already compacted, by path. Module memory: forks with the
314
+ * environment, so a child never re-parses its ancestors' dumps. */
315
+ const COMPACTED_V8_REPORTS = new Set<string>();
316
+
317
+ /** A script is the app's own when it is a file outside node_modules. */
318
+ export function isAppScriptUrl(url: string): boolean {
319
+ return url.startsWith("file://") && !url.includes("/node_modules/");
320
+ }
321
+
322
+ /**
323
+ * Compact one V8 coverage document to the app's own scripts. What Node
324
+ * writes is everything the process loaded: `node:` internals, every
325
+ * `node_modules` file, and — under `--enable-source-maps` — a
326
+ * `source-map-cache` with each file's full map **and its sources**,
327
+ * repeated in every dump. Measured on a real project: a 12–20 MiB dump
328
+ * per capture, of which the app's own coverage was under 0.5 MiB, and a
329
+ * suite that hit the 64 MiB cap on its third test. Kept: `file://`
330
+ * scripts outside `node_modules`, the map entries of exactly those
331
+ * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
332
+ * document — nothing is converted.
333
+ */
334
+ export function compactV8Document(doc: Record<string, unknown>): Record<string, unknown> {
335
+ const result = Array.isArray(doc.result) ? (doc.result as { url?: unknown }[]) : [];
336
+ const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
337
+ const out: Record<string, unknown> = { ...doc, result: kept };
338
+ const cache = doc["source-map-cache"];
339
+ if (cache && typeof cache === "object") {
340
+ const urls = new Set(kept.map((s) => s.url as string));
341
+ const slim: Record<string, unknown> = {};
342
+ for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
343
+ if (!urls.has(url) || !entry || typeof entry !== "object") continue;
344
+ const e = { ...(entry as Record<string, unknown>) };
345
+ if (e.data && typeof e.data === "object") {
346
+ const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
347
+ e.data = data;
348
+ }
349
+ slim[url] = e;
350
+ }
351
+ if (Object.keys(slim).length > 0) out["source-map-cache"] = slim;
352
+ else delete out["source-map-cache"];
353
+ }
354
+ return out;
355
+ }
356
+
357
+ /** Compact every not-yet-compacted V8 document in `dir`, in place. */
358
+ export async function compactV8Reports(dir: string): Promise<void> {
359
+ const fs = await import("node:fs/promises");
360
+ const path = await import("node:path");
361
+ let names: string[];
362
+ try {
363
+ names = await fs.readdir(dir);
364
+ } catch {
365
+ return;
366
+ }
367
+ for (const name of names) {
368
+ if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
369
+ const file = path.join(dir, name);
370
+ if (COMPACTED_V8_REPORTS.has(file)) continue;
371
+ let doc: Record<string, unknown>;
372
+ try {
373
+ doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
374
+ } catch {
375
+ continue; // a dump mid-write, or not ours; the read path judges it
376
+ }
377
+ if (!Array.isArray(doc.result)) continue;
378
+ const tmp = path.join(dir, `.${name}.compact`);
379
+ await fs.writeFile(tmp, JSON.stringify(compactV8Document(doc)));
380
+ await fs.rename(tmp, file);
381
+ COMPACTED_V8_REPORTS.add(file);
382
+ }
383
+ }
384
+
287
385
  // ── browser ───────────────────────────────────────────────────────
288
386
 
289
387
  /**