@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.
- package/dist/coverage.d.ts +34 -17
- package/dist/coverage.js +113 -57
- package/package.json +1 -1
- package/src/coverage.test.ts +101 -52
- package/src/coverage.ts +114 -60
package/dist/coverage.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
export declare const
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
33
|
-
export const
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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(
|
|
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
|
|
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", (
|
|
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
|
-
});
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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://") &&
|
|
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
package/src/coverage.test.ts
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
137
|
-
// socket with a fresh V8 report
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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
|
|
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
|
-
|
|
151
|
-
await fs.writeFile(
|
|
152
|
-
const child = spawn("node", [
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
181
|
+
const server = await start(dir, "server.js", 1);
|
|
173
182
|
try {
|
|
174
|
-
|
|
175
|
-
expect(reportsBefore).toEqual([]);
|
|
183
|
+
expect(await reportsIn(dir)).toEqual([]);
|
|
176
184
|
await node().capture!(fakeCtx(dir));
|
|
177
|
-
const reports =
|
|
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
|
-
|
|
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 },
|
|
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) =>
|
|
196
|
+
const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
|
|
192
197
|
expect(code).toBe(0);
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
} catch {
|
|
220
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
221
|
-
}
|
|
229
|
+
process.kill(pid, "SIGKILL");
|
|
230
|
+
} catch {}
|
|
222
231
|
}
|
|
223
|
-
|
|
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
|
|
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
|
|
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
|
|
38
|
-
export const
|
|
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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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(
|
|
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
|
|
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", (
|
|
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
|
-
});
|
|
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
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
/**
|