@specific.dev/spectest 0.59.1 → 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.
- package/dist/coverage.d.ts +25 -13
- package/dist/coverage.js +81 -52
- package/package.json +1 -1
- package/src/coverage.test.ts +72 -50
- package/src/coverage.ts +79 -55
package/dist/coverage.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
7
|
-
|
|
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,29 +86,40 @@ 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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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
|
|
105
110
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
106
111
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
107
112
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
108
113
|
* report, which is what maps a TypeScript service back to its sources.
|
|
109
114
|
*/
|
|
110
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>;
|
|
111
123
|
/** A script is the app's own when it is a file outside node_modules. */
|
|
112
124
|
export declare function isAppScriptUrl(url: string): boolean;
|
|
113
125
|
/**
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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(
|
|
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
|
|
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,25 +135,18 @@ if (process.env.NODE_V8_COVERAGE) {
|
|
|
132
135
|
});
|
|
133
136
|
});
|
|
134
137
|
server.unref();
|
|
135
|
-
server.on("error", (
|
|
136
|
-
|
|
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
|
|
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
|
|
154
150
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
155
151
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
156
152
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -169,35 +165,68 @@ export function node() {
|
|
|
169
165
|
};
|
|
170
166
|
},
|
|
171
167
|
async capture(ctx) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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)"}`);
|
|
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
|
+
}
|
|
197
172
|
await compactV8Reports(ctx.reportDir);
|
|
198
173
|
},
|
|
199
174
|
};
|
|
200
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
|
+
}
|
|
201
230
|
/** V8 files already compacted, by path. Module memory: forks with the
|
|
202
231
|
* environment, so a child never re-parses its ancestors' dumps. */
|
|
203
232
|
const COMPACTED_V8_REPORTS = new Set();
|
package/package.json
CHANGED
package/src/coverage.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
NODE_COVERAGE_HOOK,
|
|
8
8
|
NODE_COVERAGE_HOOK_PATH,
|
|
9
|
-
|
|
9
|
+
NODE_COVERAGE_SOCKET_PREFIX,
|
|
10
10
|
applyCoverageAdapters,
|
|
11
11
|
appendEnvFlag,
|
|
12
12
|
browser,
|
|
@@ -133,94 +133,116 @@ describe("browserCoverage", () => {
|
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
// The node hook against a real `node` (the box has one; skipped where it
|
|
136
|
-
// does not).
|
|
137
|
-
// socket with a fresh V8 report
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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.
|
|
140
141
|
const hasNode = await new Promise<boolean>((resolve) => {
|
|
141
142
|
const p = spawn("node", ["--version"]);
|
|
142
143
|
p.on("error", () => resolve(false));
|
|
143
144
|
p.on("exit", (code) => resolve(code === 0));
|
|
144
145
|
});
|
|
145
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
|
+
|
|
146
155
|
describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
147
|
-
async function
|
|
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> {
|
|
148
163
|
const hook = path.join(dir, "hook.cjs");
|
|
149
164
|
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
150
|
-
|
|
151
|
-
await fs.writeFile(
|
|
152
|
-
const child = spawn("node", [
|
|
153
|
-
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}` },
|
|
154
169
|
stdio: ["ignore", "pipe", "inherit"],
|
|
155
170
|
});
|
|
156
171
|
await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
}
|
|
172
|
+
for (let i = 0; i < 100 && (await socketsIn(dir)).length < expectSockets; i++) {
|
|
173
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
166
174
|
}
|
|
167
175
|
return child;
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
test("capture asks the live server
|
|
178
|
+
test("capture asks the live server; a short-lived process exits and unlinks its socket", async () => {
|
|
171
179
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
172
|
-
const server = await
|
|
180
|
+
const server = await start(dir, "server.js", 1);
|
|
173
181
|
try {
|
|
174
|
-
|
|
175
|
-
expect(reportsBefore).toEqual([]);
|
|
182
|
+
expect(await reportsIn(dir)).toEqual([]);
|
|
176
183
|
await node().capture!(fakeCtx(dir));
|
|
177
|
-
const reports =
|
|
184
|
+
const reports = await reportsIn(dir);
|
|
178
185
|
expect(reports.length).toBe(1);
|
|
179
186
|
const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
|
|
180
187
|
result: { url: string }[];
|
|
181
188
|
};
|
|
182
189
|
expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
|
|
183
190
|
|
|
184
|
-
|
|
185
|
-
|
|
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 },
|
|
191
|
+
const short = spawn("node", ["-e", "1"], {
|
|
192
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${path.join(dir, "hook.cjs")}` },
|
|
189
193
|
stdio: "ignore",
|
|
190
194
|
});
|
|
191
|
-
const code = await new Promise<number | null>((resolve) =>
|
|
195
|
+
const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
|
|
192
196
|
expect(code).toBe(0);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
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
|
|
196
199
|
|
|
197
|
-
// The live server still answers.
|
|
198
200
|
await node().capture!(fakeCtx(dir));
|
|
199
|
-
expect((await
|
|
201
|
+
expect((await reportsIn(dir)).length).toBe(3);
|
|
200
202
|
} finally {
|
|
201
203
|
server.kill("SIGKILL");
|
|
202
204
|
}
|
|
203
205
|
});
|
|
204
206
|
|
|
205
|
-
test("a
|
|
207
|
+
test("a server behind a wrapper process is dumped too (pnpm exec / tsx shape)", async () => {
|
|
206
208
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
207
|
-
const
|
|
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);
|
|
209
|
+
const wrapper = await start(dir, "wrapper.js", 2);
|
|
212
210
|
try {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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) {
|
|
216
227
|
try {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
} catch {
|
|
220
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
221
|
-
}
|
|
228
|
+
process.kill(pid, "SIGKILL");
|
|
229
|
+
} catch {}
|
|
222
230
|
}
|
|
223
|
-
|
|
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);
|
|
224
246
|
} finally {
|
|
225
247
|
second.kill("SIGKILL");
|
|
226
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
|
-
/**
|
|
38
|
-
|
|
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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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(
|
|
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
|
|
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", (
|
|
219
|
-
|
|
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,7 +229,8 @@ 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
|
|
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
|
|
238
234
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
239
235
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
240
236
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -253,39 +249,67 @@ export function node(): CoverageAdapter {
|
|
|
253
249
|
};
|
|
254
250
|
},
|
|
255
251
|
async capture(ctx) {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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)"}`);
|
|
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
|
+
}
|
|
284
258
|
await compactV8Reports(ctx.reportDir);
|
|
285
259
|
},
|
|
286
260
|
};
|
|
287
261
|
}
|
|
288
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
|
+
|
|
289
313
|
/** V8 files already compacted, by path. Module memory: forks with the
|
|
290
314
|
* environment, so a child never re-parses its ancestors' dumps. */
|
|
291
315
|
const COMPACTED_V8_REPORTS = new Set<string>();
|