@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.
- package/dist/coverage.d.ts +44 -14
- package/dist/coverage.js +160 -53
- package/package.json +1 -1
- package/src/coverage.test.ts +113 -50
- package/src/coverage.ts +154 -56
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,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
|
-
*
|
|
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
|
|
105
|
-
*
|
|
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
|
-
/**
|
|
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,26 +135,20 @@ 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
|
|
154
|
-
*
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
package/src/coverage.test.ts
CHANGED
|
@@ -6,11 +6,13 @@ 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,
|
|
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).
|
|
96
|
-
// socket with a fresh V8 report
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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
|
|
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
|
-
|
|
110
|
-
await fs.writeFile(
|
|
111
|
-
const child = spawn("node", [
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
180
|
+
const server = await start(dir, "server.js", 1);
|
|
132
181
|
try {
|
|
133
|
-
|
|
134
|
-
expect(reportsBefore).toEqual([]);
|
|
182
|
+
expect(await reportsIn(dir)).toEqual([]);
|
|
135
183
|
await node().capture!(fakeCtx(dir));
|
|
136
|
-
const reports =
|
|
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
|
-
|
|
144
|
-
|
|
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) =>
|
|
195
|
+
const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
|
|
151
196
|
expect(code).toBe(0);
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
} catch {
|
|
179
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
180
|
-
}
|
|
228
|
+
process.kill(pid, "SIGKILL");
|
|
229
|
+
} catch {}
|
|
181
230
|
}
|
|
182
|
-
|
|
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
|
-
/**
|
|
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,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
|
|
238
|
-
*
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
/**
|