@specific.dev/spectest 0.81.0 → 0.82.0
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 +87 -137
- package/dist/coverage.js +321 -367
- package/dist/daemon.js +11 -24
- package/dist/harness/coverage.d.ts +6 -1
- package/dist/harness/coverage.js +11 -3
- package/package.json +1 -1
- package/src/coverage.test.ts +163 -336
- package/src/coverage.ts +331 -399
- package/src/daemon.ts +11 -19
- package/src/harness/coverage.ts +12 -3
package/dist/coverage.js
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
// a report in the directory.
|
|
14
14
|
//
|
|
15
15
|
// After every adapter ran, the harness reads the directory the same way
|
|
16
|
-
// it does for `coverage: true`: at least one well-formed report (lcov
|
|
17
|
-
// V8 JSON), shipped verbatim. Nothing is
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// needs from the stored bytes, off the test run.
|
|
16
|
+
// it does for `coverage: true`: at least one well-formed report (lcov,
|
|
17
|
+
// V8 JSON, or a node() script record), shipped verbatim. Nothing is
|
|
18
|
+
// converted, parsed or looked up in the VM: `node()`'s hook collects
|
|
19
|
+
// inside the process and writes what ships. The control plane derives
|
|
20
|
+
// what it needs from the stored bytes, off the test run.
|
|
21
21
|
//
|
|
22
22
|
// Imported from `@specific.dev/spectest/coverage`. The three shipped
|
|
23
23
|
// adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
//
|
|
27
27
|
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
28
28
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
29
|
-
import { createHash } from "node:crypto";
|
|
30
29
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
31
30
|
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
32
31
|
export { COVERAGE_CONTAINER_DIR };
|
|
@@ -37,11 +36,6 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
|
37
36
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
38
37
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
39
38
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
40
|
-
/** Key of the content hash (`sha256:<hex>`) a shipped `source-map-cache`
|
|
41
|
-
* entry carries, and key of the stub that stands in for an entry that
|
|
42
|
-
* shipped earlier on the same branch. See {@link compactV8Document}. */
|
|
43
|
-
export const SOURCE_MAP_HASH_KEY = "spectestHash";
|
|
44
|
-
export const SOURCE_MAP_REF_KEY = "spectestRef";
|
|
45
39
|
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
46
40
|
* wrote (the program's own, a `command`'s). */
|
|
47
41
|
export function coverageReportsMode(cov) {
|
|
@@ -119,89 +113,285 @@ export function appendEnvFlag(env, name, value) {
|
|
|
119
113
|
return { ...env, [name]: prior ? `${prior} ${value}` : value };
|
|
120
114
|
}
|
|
121
115
|
// ── node ──────────────────────────────────────────────────────────
|
|
116
|
+
/** Env var the hook reads: the coverage directory inside the container. */
|
|
117
|
+
export const NODE_COVERAGE_DIR_ENV = "SPECTEST_COVERAGE_DIR";
|
|
118
|
+
/** Subdirectory of the coverage dir where the hook writes one record per
|
|
119
|
+
* script (`scripts/<id>.json`, {@link NODE_COVERAGE_HOOK}). Shipped
|
|
120
|
+
* once, like a dump. */
|
|
121
|
+
export const NODE_COVERAGE_SCRIPTS_SUBDIR = "scripts";
|
|
122
|
+
/** Hidden subdirectory holding one empty marker per script record ever
|
|
123
|
+
* written on this branch. The hook's "written already?" check, which
|
|
124
|
+
* must survive the harness shipping and removing the record; hidden,
|
|
125
|
+
* so the harness never reads it. */
|
|
126
|
+
export const NODE_COVERAGE_MARKERS_SUBDIR = ".spectest/scripts";
|
|
127
|
+
/** Name prefix of the dumps the hook writes: `node-<pid>-<thread>-<ms>-<n>.json`. */
|
|
128
|
+
export const NODE_COVERAGE_DUMP_PREFIX = "node-";
|
|
122
129
|
/**
|
|
123
|
-
* The hook `node()` mounts
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* `
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
130
|
+
* The hook `node()` mounts at {@link NODE_COVERAGE_HOOK_PATH} and
|
|
131
|
+
* `--require`s into every node process of the service. **It is the
|
|
132
|
+
* collector**: nothing outside the process reads, converts or looks
|
|
133
|
+
* anything up. Runs in every thread (a worker thread inherits
|
|
134
|
+
* `NODE_OPTIONS`); reads one env var, {@link NODE_COVERAGE_DIR_ENV}; uses
|
|
135
|
+
* node built-ins only, so it runs in any image that runs node.
|
|
136
|
+
*
|
|
137
|
+
* What it does:
|
|
138
|
+
*
|
|
139
|
+
* - Starts V8 precise (block, counted) coverage through the in-process
|
|
140
|
+
* inspector session. `NODE_V8_COVERAGE` is **not** set: Node would then
|
|
141
|
+
* write everything the process loaded at every exit — `node:`
|
|
142
|
+
* internals, every `node_modules` file, and the whole source map of
|
|
143
|
+
* every script that carries a `sourceMappingURL` comment, with or
|
|
144
|
+
* without `--enable-source-maps`. For a bundled CLI that was 12 MB per
|
|
145
|
+
* invocation, inside the test's own time, parsed again at capture.
|
|
146
|
+
* - **Takes** on request — the main thread binds a Unix socket
|
|
147
|
+
* `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
|
|
148
|
+
* process binds its own (the first node process is often a wrapper,
|
|
149
|
+
* `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
|
|
150
|
+
* handler, which is how a short-lived CLI and a worker thread report.
|
|
151
|
+
* The in-process session answers synchronously, so the exit handler
|
|
152
|
+
* has the result before the process is gone. A take resets V8's
|
|
153
|
+
* counters: every dump is the delta since the previous take. No
|
|
154
|
+
* signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
155
|
+
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
156
|
+
* - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
|
|
157
|
+
* scripts (`file://`, outside `node_modules` and `.cache`, not the
|
|
158
|
+
* hook) in which some range count is above zero, and of each only the
|
|
159
|
+
* functions that ran, ranges and counts as V8 reported them. Nothing
|
|
160
|
+
* when nothing ran. Still a V8 coverage document (`result`), plus
|
|
161
|
+
* `spectestScript` on each script naming its record.
|
|
162
|
+
* - Writes, **once per script per branch**, a **script record**
|
|
163
|
+
* `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
|
|
164
|
+
* line of the source V8 compiled (`Debugger.getScriptSource`, so a
|
|
165
|
+
* transpiled module — tsx, ts-node, a bundler's register hook — is
|
|
166
|
+
* described by the text V8 ran, not the file on disk), every function
|
|
167
|
+
* with its extent (the skeleton: what a dump's "ran" functions are a
|
|
168
|
+
* subset of, so a never-run function is known to exist), and the
|
|
169
|
+
* source map: the inline `data:` map in the text, the file the last
|
|
170
|
+
* `sourceMappingURL` comment names, or `<script>.map` next to the
|
|
171
|
+
* script; `sourcesContent` dropped (the sources are the repository).
|
|
172
|
+
* "Once per branch" is a marker file under `.spectest/scripts/`: the
|
|
173
|
+
* directory is shared by every process of the service and forks with
|
|
174
|
+
* the environment, so a record written at bring-up or by an ancestor
|
|
175
|
+
* is never written again, whichever process comes next.
|
|
176
|
+
*
|
|
177
|
+
* The harness ships new dumps and new records as they are. It parses
|
|
178
|
+
* nothing and touches no container; the control plane derives lines
|
|
179
|
+
* from the stored documents (`SUBSET_RUNS.md`).
|
|
140
180
|
*/
|
|
141
181
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
142
182
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
(function () {
|
|
184
|
+
const dir = process.env[${JSON.stringify(NODE_COVERAGE_DIR_ENV)}];
|
|
185
|
+
if (!dir) return;
|
|
186
|
+
let inspector, fs, path, crypto, url, wt, net;
|
|
187
|
+
try {
|
|
188
|
+
inspector = require("node:inspector");
|
|
189
|
+
fs = require("node:fs");
|
|
190
|
+
path = require("node:path");
|
|
191
|
+
crypto = require("node:crypto");
|
|
192
|
+
url = require("node:url");
|
|
193
|
+
wt = require("node:worker_threads");
|
|
194
|
+
net = require("node:net");
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const session = new inspector.Session();
|
|
199
|
+
try {
|
|
200
|
+
session.connect();
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// An in-process session answers before post() returns.
|
|
205
|
+
function post(method, params) {
|
|
206
|
+
let out = null;
|
|
207
|
+
session.post(method, params || {}, function (err, res) {
|
|
208
|
+
out = err ? null : res;
|
|
209
|
+
});
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
if (post("Profiler.enable") === null) return;
|
|
213
|
+
post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
214
|
+
|
|
215
|
+
const SELF = url.pathToFileURL(__filename).href;
|
|
216
|
+
const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
|
|
217
|
+
const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
|
|
218
|
+
const known = new Set();
|
|
219
|
+
let seq = 0;
|
|
220
|
+
|
|
221
|
+
function isApp(u) {
|
|
222
|
+
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
223
|
+
}
|
|
224
|
+
function ran(f) {
|
|
225
|
+
return f.ranges.some(function (r) { return r.count > 0; });
|
|
226
|
+
}
|
|
227
|
+
function idOf(u) {
|
|
228
|
+
return crypto.createHash("sha256").update(u).digest("hex").slice(0, 32);
|
|
229
|
+
}
|
|
230
|
+
function lineLengths(text) {
|
|
231
|
+
const out = [];
|
|
232
|
+
let len = 0;
|
|
233
|
+
for (let i = 0; i < text.length; i++, len++) {
|
|
234
|
+
const c = text.charCodeAt(i);
|
|
235
|
+
if (c === 0x0a || c === 0x2028 || c === 0x2029) {
|
|
236
|
+
out.push(len);
|
|
237
|
+
len = -1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
out.push(len);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
function readMap(scriptPath, text) {
|
|
244
|
+
const re = /\\/\\/[#@]\\s*sourceMappingURL=(\\S+)/g;
|
|
245
|
+
let ref = null;
|
|
246
|
+
let m;
|
|
247
|
+
while ((m = re.exec(text)) !== null) ref = m[1];
|
|
248
|
+
let mapText = null;
|
|
249
|
+
let mapUrl = null;
|
|
250
|
+
try {
|
|
251
|
+
if (ref === null) {
|
|
252
|
+
const p = scriptPath + ".map";
|
|
253
|
+
mapText = fs.readFileSync(p, "utf8");
|
|
254
|
+
mapUrl = url.pathToFileURL(p).href;
|
|
255
|
+
} else if (ref.startsWith("data:")) {
|
|
256
|
+
const c = ref.indexOf(",");
|
|
257
|
+
if (c < 0) return null;
|
|
258
|
+
const head = ref.slice(5, c);
|
|
259
|
+
const body = ref.slice(c + 1);
|
|
260
|
+
mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
|
|
261
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith("file:")) {
|
|
262
|
+
return null;
|
|
263
|
+
} else {
|
|
264
|
+
const p = ref.startsWith("file:") ? url.fileURLToPath(ref) : path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
|
|
265
|
+
mapText = fs.readFileSync(p, "utf8");
|
|
266
|
+
mapUrl = url.pathToFileURL(p).href;
|
|
267
|
+
}
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
let data;
|
|
272
|
+
try {
|
|
273
|
+
data = JSON.parse(mapText);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (!data || typeof data !== "object" || !("mappings" in data)) return null;
|
|
278
|
+
delete data.sourcesContent;
|
|
279
|
+
return { url: mapUrl, data: data };
|
|
280
|
+
}
|
|
281
|
+
function writeAtomic(file, text) {
|
|
282
|
+
const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
|
|
283
|
+
fs.writeFileSync(tmp, text);
|
|
284
|
+
fs.renameSync(tmp, file);
|
|
285
|
+
}
|
|
286
|
+
function describe(s, source) {
|
|
287
|
+
const id = idOf(s.url);
|
|
288
|
+
let scriptPath = null;
|
|
289
|
+
try {
|
|
290
|
+
scriptPath = url.fileURLToPath(s.url);
|
|
291
|
+
} catch (e) {}
|
|
292
|
+
const text = typeof source === "string" ? source : null;
|
|
293
|
+
const record = {
|
|
294
|
+
url: s.url,
|
|
295
|
+
lineLengths: text !== null ? lineLengths(text) : null,
|
|
296
|
+
functions: s.functions.map(function (f) {
|
|
297
|
+
return [f.functionName, f.ranges[0].startOffset, f.ranges[0].endOffset];
|
|
298
|
+
}),
|
|
299
|
+
sourceMap: text !== null && scriptPath !== null ? readMap(scriptPath, text) : null,
|
|
300
|
+
};
|
|
301
|
+
writeAtomic(path.join(SCRIPTS, id + ".json"), JSON.stringify({ spectestScript: record }));
|
|
302
|
+
try {
|
|
303
|
+
fs.writeFileSync(path.join(MARKERS, id), "");
|
|
304
|
+
} catch (e) {}
|
|
305
|
+
}
|
|
306
|
+
function take() {
|
|
307
|
+
const res = post("Profiler.takePreciseCoverage");
|
|
308
|
+
if (!res || !Array.isArray(res.result)) return;
|
|
309
|
+
const kept = [];
|
|
310
|
+
for (const s of res.result) {
|
|
311
|
+
if (isApp(s.url) && Array.isArray(s.functions) && s.functions.some(ran)) kept.push(s);
|
|
312
|
+
}
|
|
313
|
+
if (kept.length === 0) return;
|
|
314
|
+
const fresh = [];
|
|
315
|
+
for (const s of kept) {
|
|
316
|
+
if (known.has(s.url)) continue;
|
|
317
|
+
known.add(s.url);
|
|
318
|
+
if (!fs.existsSync(path.join(MARKERS, idOf(s.url)))) fresh.push(s);
|
|
319
|
+
}
|
|
320
|
+
if (fresh.length > 0) {
|
|
159
321
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
322
|
+
fs.mkdirSync(SCRIPTS, { recursive: true });
|
|
323
|
+
fs.mkdirSync(MARKERS, { recursive: true });
|
|
324
|
+
} catch (e) {}
|
|
325
|
+
post("Debugger.enable");
|
|
326
|
+
for (const s of fresh) {
|
|
327
|
+
const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
|
|
328
|
+
try {
|
|
329
|
+
describe(s, r && r.scriptSource);
|
|
330
|
+
} catch (e) {}
|
|
165
331
|
}
|
|
166
|
-
|
|
332
|
+
post("Debugger.disable");
|
|
333
|
+
}
|
|
334
|
+
const doc = {
|
|
335
|
+
spectestVersion: 1,
|
|
336
|
+
result: kept.map(function (s) {
|
|
337
|
+
return { url: s.url, scriptId: s.scriptId, spectestScript: idOf(s.url), functions: s.functions.filter(ran) };
|
|
338
|
+
}),
|
|
339
|
+
};
|
|
340
|
+
const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + process.pid + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
|
|
341
|
+
writeAtomic(path.join(dir, name), JSON.stringify(doc));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (wt.isMainThread) {
|
|
345
|
+
const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
|
|
346
|
+
const server = net.createServer(function (conn) {
|
|
347
|
+
let buf = "";
|
|
348
|
+
conn.on("data", function (chunk) {
|
|
349
|
+
buf += chunk;
|
|
350
|
+
if (buf.indexOf("\\n") < 0) return;
|
|
351
|
+
let reply;
|
|
352
|
+
try {
|
|
353
|
+
if (!buf.startsWith("dump")) throw new Error("unknown request");
|
|
354
|
+
take();
|
|
355
|
+
reply = "ok\\n";
|
|
356
|
+
} catch (err) {
|
|
357
|
+
reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
|
|
358
|
+
}
|
|
359
|
+
conn.end(reply);
|
|
360
|
+
});
|
|
167
361
|
});
|
|
362
|
+
server.unref();
|
|
363
|
+
server.on("error", function () {});
|
|
364
|
+
try {
|
|
365
|
+
fs.unlinkSync(SOCK);
|
|
366
|
+
} catch (e) {}
|
|
367
|
+
server.listen(SOCK);
|
|
368
|
+
process.on("exit", function () {
|
|
369
|
+
try {
|
|
370
|
+
fs.unlinkSync(SOCK);
|
|
371
|
+
} catch (e) {}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
process.on("exit", function () {
|
|
375
|
+
try {
|
|
376
|
+
take();
|
|
377
|
+
} catch (e) {}
|
|
168
378
|
});
|
|
169
|
-
|
|
170
|
-
server.on("error", () => {});
|
|
171
|
-
try { fs.unlinkSync(SOCK); } catch {}
|
|
172
|
-
server.listen(SOCK);
|
|
173
|
-
process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
|
|
174
|
-
}
|
|
379
|
+
})();
|
|
175
380
|
`;
|
|
176
381
|
/**
|
|
177
382
|
* Coverage for a Node service — a long-lived server, or a container whose
|
|
178
383
|
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* without `--enable-source-maps`), else from `<script>.map` next to the
|
|
191
|
-
* script, read out of the container once
|
|
192
|
-
* ({@link resolveSourceMapFromContainer}). The second way is the one to
|
|
193
|
-
* build a short-lived bundled process in: a map Node finds is serialized
|
|
194
|
-
* whole into every exit dump — 12 MB per invocation for a bundled CLI,
|
|
195
|
-
* inside the test's own time — and parsed again by the harness; a map
|
|
196
|
-
* with no comment costs nothing there. Nothing is converted in the VM.
|
|
197
|
-
* A dump is a **delta** by construction: V8 resets its counters at every
|
|
198
|
-
* `takeCoverage()`, so a live server's dump after a test is that test's
|
|
199
|
-
* own execution, and the boot dump (everything loaded) lands in the
|
|
200
|
-
* bring-up capture and nowhere else. The control plane derives lines
|
|
201
|
-
* from the stored documents, off the test run. Converting to lcov in
|
|
202
|
-
* the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
|
|
203
|
-
* service on a real project, inside the test run; a real project turned
|
|
204
|
-
* coverage off because of it, and the conversion went.
|
|
384
|
+
* {@link NODE_COVERAGE_DIR_ENV} to the coverage directory and
|
|
385
|
+
* `--require`s {@link NODE_COVERAGE_HOOK}, which collects everything in
|
|
386
|
+
* the process. Nothing for the app to write, and nothing the harness
|
|
387
|
+
* reads out of the container: at capture it asks every live process for
|
|
388
|
+
* a take over its socket and lists what appeared in the directory —
|
|
389
|
+
* dumps and script records — for the harness to ship as they are. A
|
|
390
|
+
* dump is a delta by construction (V8 resets its counters at every
|
|
391
|
+
* take), so the report after a test is that test's own execution and
|
|
392
|
+
* the boot dump lands in the bring-up capture alone. Each capture ships
|
|
393
|
+
* only its own files: what an earlier capture on this branch shipped is
|
|
394
|
+
* removed first (module memory, forks with the environment).
|
|
205
395
|
*/
|
|
206
396
|
export function node() {
|
|
207
397
|
return {
|
|
@@ -209,7 +399,7 @@ export function node() {
|
|
|
209
399
|
reports: "delta",
|
|
210
400
|
configure(svc) {
|
|
211
401
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
212
|
-
env
|
|
402
|
+
env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
|
|
213
403
|
return {
|
|
214
404
|
...svc,
|
|
215
405
|
env,
|
|
@@ -226,23 +416,17 @@ export function node() {
|
|
|
226
416
|
// rather than failed: an empty report is a report. A server whose
|
|
227
417
|
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
228
418
|
// empty reports after bring-up, which the boot log warns about.
|
|
229
|
-
//
|
|
230
|
-
// Each capture ships only its own dumps: what an earlier capture on
|
|
231
|
-
// this branch shipped is removed first (module memory, forks with
|
|
232
|
-
// the environment).
|
|
233
419
|
const t0 = performance.now();
|
|
234
|
-
await
|
|
420
|
+
await removeShippedNodeReports(ctx.reportDir);
|
|
235
421
|
const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
236
422
|
const t1 = performance.now();
|
|
237
|
-
const stats = await
|
|
423
|
+
const stats = await collectNodeReports(ctx.reportDir);
|
|
238
424
|
if (stats.dumps === 0) {
|
|
239
425
|
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
240
|
-
|
|
426
|
+
SHIPPED_NODE_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
|
|
241
427
|
}
|
|
242
|
-
const t2 = performance.now();
|
|
243
428
|
console.log(`[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
|
|
244
|
-
`${stats.dumps} dump(s)
|
|
245
|
-
`${stats.mapsFromContainer} map(s) read from the container`);
|
|
429
|
+
`${stats.dumps} dump(s) (${stats.dumpBytes} B), ${stats.scripts} new script record(s) (${stats.scriptBytes} B)`);
|
|
246
430
|
},
|
|
247
431
|
};
|
|
248
432
|
}
|
|
@@ -300,303 +484,73 @@ export async function dumpAllNodeProcesses(dir, signal) {
|
|
|
300
484
|
}
|
|
301
485
|
return live;
|
|
302
486
|
}
|
|
303
|
-
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* its own.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
* of the entry. Module memory, forks with the environment. A later dump
|
|
310
|
-
* of the same script carries a stub that refers to the hash instead of
|
|
311
|
-
* the map again. */
|
|
312
|
-
const SHIPPED_SOURCE_MAPS = new Map();
|
|
313
|
-
/** Scripts looked up in the container and found to have no source map
|
|
314
|
-
* (no `sourceMappingURL`, or a map file that is not there). Module
|
|
315
|
-
* memory, forks with the environment: one lookup per script per branch. */
|
|
316
|
-
const NO_SOURCE_MAP = new Set();
|
|
487
|
+
/** Files shipped at an earlier capture on this branch, by host path:
|
|
488
|
+
* dumps, script records, the empty report. Module memory: forks with
|
|
489
|
+
* the environment, so a forked child removes its ancestors' files at
|
|
490
|
+
* its first capture and ships only its own. The hook's own "written
|
|
491
|
+
* already" markers are elsewhere and stay. */
|
|
492
|
+
const SHIPPED_NODE_REPORTS = new Set();
|
|
317
493
|
/** Forget the branch memory (tests). */
|
|
318
494
|
export function resetNodeCoverageMemory() {
|
|
319
|
-
|
|
320
|
-
SHIPPED_SOURCE_MAPS.clear();
|
|
321
|
-
NO_SOURCE_MAP.clear();
|
|
495
|
+
SHIPPED_NODE_REPORTS.clear();
|
|
322
496
|
}
|
|
323
|
-
/** Remove from `dir` the
|
|
324
|
-
export async function
|
|
497
|
+
/** Remove from `dir` the files an earlier capture shipped. */
|
|
498
|
+
export async function removeShippedNodeReports(dir) {
|
|
325
499
|
const fs = await import("node:fs/promises");
|
|
326
|
-
for (const file of [...
|
|
500
|
+
for (const file of [...SHIPPED_NODE_REPORTS]) {
|
|
327
501
|
if (!file.startsWith(dir + "/"))
|
|
328
502
|
continue;
|
|
329
503
|
await fs.unlink(file).catch(() => { });
|
|
330
|
-
|
|
504
|
+
SHIPPED_NODE_REPORTS.delete(file);
|
|
331
505
|
}
|
|
332
506
|
}
|
|
333
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
return (url.startsWith("file://") &&
|
|
337
|
-
!url.includes("/node_modules/") &&
|
|
338
|
-
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
339
|
-
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
340
|
-
!url.includes("/.cache/") &&
|
|
341
|
-
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
|
|
342
|
-
}
|
|
343
|
-
/** True when any range of any function of the script has a count above
|
|
344
|
-
* zero: something in it ran since the previous take. A script whose
|
|
345
|
-
* every count is zero is one V8 still lists after a reset; it says
|
|
346
|
-
* nothing and is dropped. */
|
|
347
|
-
export function scriptExecuted(script) {
|
|
348
|
-
const fns = Array.isArray(script.functions) ? script.functions : [];
|
|
349
|
-
return fns.some((f) => Array.isArray(f.ranges) && f.ranges.some((r) => typeof r.count === "number" && r.count > 0));
|
|
507
|
+
/** True for a dump the hook wrote (`node-*.json`). */
|
|
508
|
+
export function isNodeDumpName(name) {
|
|
509
|
+
return name.startsWith(NODE_COVERAGE_DUMP_PREFIX) && name.endsWith(".json");
|
|
350
510
|
}
|
|
351
511
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* repeated in every dump. Measured on a real project: a 12–20 MiB dump
|
|
357
|
-
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
358
|
-
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
359
|
-
* scripts outside `node_modules` in which something ran, and the map
|
|
360
|
-
* entries of exactly those scripts, minus `sourcesContent` (the sources
|
|
361
|
-
* are the repo). Each kept map entry carries its content hash under
|
|
362
|
-
* {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
|
|
363
|
-
* URL — the map went out with an earlier dump on this branch — a stub
|
|
364
|
-
* `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
|
|
365
|
-
* the map in an earlier capture of the branch. `shipped` is updated in
|
|
366
|
-
* place. Still a V8 document — nothing is converted here.
|
|
512
|
+
* Mark shipped every dump at the root of `dir` and every script record
|
|
513
|
+
* under `scripts/` that no earlier capture shipped, and count them.
|
|
514
|
+
* Nothing is read: the hook wrote each file whole (write + rename), and
|
|
515
|
+
* the harness ships them as they are.
|
|
367
516
|
*/
|
|
368
|
-
export function
|
|
369
|
-
const
|
|
370
|
-
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url) && scriptExecuted(s));
|
|
371
|
-
const out = { ...doc, result: kept };
|
|
372
|
-
const cache = doc["source-map-cache"];
|
|
373
|
-
if (cache && typeof cache === "object") {
|
|
374
|
-
const urls = new Set(kept.map((s) => s.url));
|
|
375
|
-
const slim = {};
|
|
376
|
-
for (const [url, entry] of Object.entries(cache)) {
|
|
377
|
-
if (!urls.has(url) || !entry || typeof entry !== "object")
|
|
378
|
-
continue;
|
|
379
|
-
const e = { ...entry };
|
|
380
|
-
if (e.data && typeof e.data === "object") {
|
|
381
|
-
const { sourcesContent: _dropped, ...data } = e.data;
|
|
382
|
-
e.data = data;
|
|
383
|
-
}
|
|
384
|
-
const hash = sourceMapEntryHash(e);
|
|
385
|
-
if (shipped?.get(url) === hash) {
|
|
386
|
-
slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
|
|
387
|
-
}
|
|
388
|
-
else {
|
|
389
|
-
slim[url] = { ...e, [SOURCE_MAP_HASH_KEY]: hash };
|
|
390
|
-
shipped?.set(url, hash);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
if (Object.keys(slim).length > 0)
|
|
394
|
-
out["source-map-cache"] = slim;
|
|
395
|
-
else
|
|
396
|
-
delete out["source-map-cache"];
|
|
397
|
-
}
|
|
398
|
-
return out;
|
|
399
|
-
}
|
|
400
|
-
/** The length of every line of `text`, the way Node computes it for
|
|
401
|
-
* `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
|
|
402
|
-
* last line included. Lengths in UTF-16 units, which is what V8's byte
|
|
403
|
-
* offsets count in. */
|
|
404
|
-
export function lineLengthsOf(text) {
|
|
405
|
-
const out = [];
|
|
406
|
-
let len = 0;
|
|
407
|
-
for (let i = 0; i < text.length; i++, len++) {
|
|
408
|
-
const c = text.charCodeAt(i);
|
|
409
|
-
if (c === 0x0a || c === 0x2028 || c === 0x2029) {
|
|
410
|
-
out.push(len);
|
|
411
|
-
len = -1;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
out.push(len);
|
|
415
|
-
return out;
|
|
416
|
-
}
|
|
417
|
-
/** The last `sourceMappingURL` comment of a script, or `null`. */
|
|
418
|
-
export function sourceMappingUrlOf(script) {
|
|
419
|
-
const re = /\/\/[#@]\s*sourceMappingURL=(\S+)/g;
|
|
420
|
-
let last = null;
|
|
421
|
-
let m;
|
|
422
|
-
while ((m = re.exec(script)) !== null)
|
|
423
|
-
last = m[1];
|
|
424
|
-
return last;
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* The source map of `scriptUrl`, read out of the container: the script
|
|
428
|
-
* itself (for its `sourceMappingURL` and its line lengths), then the map
|
|
429
|
-
* — inline as a `data:` URL, a file the comment names, or, with no
|
|
430
|
-
* comment, `<script>.map` next to it. `null` when the script is not a
|
|
431
|
-
* `file://` URL, cannot be read, or the map is not there or not JSON; a
|
|
432
|
-
* map's `sourcesContent` is dropped.
|
|
433
|
-
* One read per script per branch: the caller remembers the outcome.
|
|
434
|
-
*/
|
|
435
|
-
export async function resolveSourceMapFromContainer(ctx, scriptUrl) {
|
|
436
|
-
if (!ctx.readContainerFile || !scriptUrl.startsWith("file://"))
|
|
437
|
-
return null;
|
|
438
|
-
const { fileURLToPath, pathToFileURL } = await import("node:url");
|
|
517
|
+
export async function collectNodeReports(dir) {
|
|
518
|
+
const fs = await import("node:fs/promises");
|
|
439
519
|
const path = await import("node:path");
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
return null;
|
|
446
|
-
}
|
|
447
|
-
const script = await ctx.readContainerFile(scriptPath);
|
|
448
|
-
if (!script)
|
|
449
|
-
return null;
|
|
450
|
-
const text = script.toString("utf8");
|
|
451
|
-
// No comment: the map next to the script, by convention (`x.js.map`).
|
|
452
|
-
// That is the shape to build a short-lived bundled process in: Node
|
|
453
|
-
// caches a map it finds through a comment into every dump it writes
|
|
454
|
-
// under NODE_V8_COVERAGE, flag or no flag, and a bundled CLI's map is
|
|
455
|
-
// tens of MB per exit; a map it does not find costs nothing there and
|
|
456
|
-
// is read here once per branch.
|
|
457
|
-
const ref = sourceMappingUrlOf(text) ?? `${path.basename(scriptPath)}.map`;
|
|
458
|
-
let mapText;
|
|
459
|
-
let mapUrl;
|
|
460
|
-
if (ref.startsWith("data:")) {
|
|
461
|
-
const comma = ref.indexOf(",");
|
|
462
|
-
if (comma < 0)
|
|
463
|
-
return null;
|
|
464
|
-
const head = ref.slice(5, comma);
|
|
465
|
-
const body = ref.slice(comma + 1);
|
|
520
|
+
const stats = { dumps: 0, dumpBytes: 0, scripts: 0, scriptBytes: 0 };
|
|
521
|
+
const seen = async (d, keep, onNew) => {
|
|
522
|
+
let names;
|
|
466
523
|
try {
|
|
467
|
-
|
|
524
|
+
names = await fs.readdir(d);
|
|
468
525
|
}
|
|
469
526
|
catch {
|
|
470
|
-
return
|
|
527
|
+
return;
|
|
471
528
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
529
|
+
for (const name of names.sort()) {
|
|
530
|
+
if (!keep(name))
|
|
531
|
+
continue;
|
|
532
|
+
const file = path.join(d, name);
|
|
533
|
+
if (SHIPPED_NODE_REPORTS.has(file))
|
|
534
|
+
continue;
|
|
535
|
+
let size;
|
|
477
536
|
try {
|
|
478
|
-
|
|
537
|
+
size = (await fs.stat(file)).size;
|
|
479
538
|
}
|
|
480
539
|
catch {
|
|
481
|
-
|
|
540
|
+
continue;
|
|
482
541
|
}
|
|
542
|
+
SHIPPED_NODE_REPORTS.add(file);
|
|
543
|
+
onNew(size);
|
|
483
544
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
else {
|
|
488
|
-
mapPath = path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
|
|
489
|
-
}
|
|
490
|
-
const map = await ctx.readContainerFile(mapPath);
|
|
491
|
-
if (!map)
|
|
492
|
-
return null;
|
|
493
|
-
mapText = map.toString("utf8");
|
|
494
|
-
mapUrl = pathToFileURL(mapPath).href;
|
|
495
|
-
}
|
|
496
|
-
let data;
|
|
497
|
-
try {
|
|
498
|
-
data = JSON.parse(mapText);
|
|
499
|
-
}
|
|
500
|
-
catch {
|
|
501
|
-
return null;
|
|
502
|
-
}
|
|
503
|
-
if (!data || typeof data !== "object" || !("mappings" in data))
|
|
504
|
-
return null;
|
|
505
|
-
const { sourcesContent: _dropped, ...slim } = data;
|
|
506
|
-
return { url: mapUrl, data: slim, lineLengths: lineLengthsOf(text) };
|
|
507
|
-
}
|
|
508
|
-
/** The content hash of a map entry, over its JSON with the spectest
|
|
509
|
-
* keys removed. */
|
|
510
|
-
function sourceMapEntryHash(entry) {
|
|
511
|
-
const e = { ...entry };
|
|
512
|
-
delete e[SOURCE_MAP_HASH_KEY];
|
|
513
|
-
delete e[SOURCE_MAP_REF_KEY];
|
|
514
|
-
return "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Give every kept script of a compacted document a `source-map-cache`
|
|
518
|
-
* entry it lacks: a stub when the map shipped earlier on this branch,
|
|
519
|
-
* else the map `resolve` finds (shipped whole, with its hash, and
|
|
520
|
-
* remembered), else nothing — and that outcome is remembered too, so a
|
|
521
|
-
* script with no map is looked up once per branch. Returns how many maps
|
|
522
|
-
* `resolve` supplied.
|
|
523
|
-
*/
|
|
524
|
-
export async function attachSourceMaps(doc, resolve, shipped = SHIPPED_SOURCE_MAPS, missing = NO_SOURCE_MAP) {
|
|
525
|
-
const result = Array.isArray(doc.result) ? doc.result : [];
|
|
526
|
-
const cache = (doc["source-map-cache"] ?? {});
|
|
527
|
-
let found = 0;
|
|
528
|
-
for (const s of result) {
|
|
529
|
-
const url = s.url;
|
|
530
|
-
if (typeof url !== "string" || url in cache)
|
|
531
|
-
continue;
|
|
532
|
-
const prior = shipped.get(url);
|
|
533
|
-
if (prior) {
|
|
534
|
-
cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
|
|
535
|
-
continue;
|
|
536
|
-
}
|
|
537
|
-
if (missing.has(url))
|
|
538
|
-
continue;
|
|
539
|
-
const entry = await resolve(url);
|
|
540
|
-
if (!entry) {
|
|
541
|
-
missing.add(url);
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
const hash = sourceMapEntryHash(entry);
|
|
545
|
-
cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
|
|
546
|
-
shipped.set(url, hash);
|
|
547
|
-
found++;
|
|
548
|
-
}
|
|
549
|
-
if (Object.keys(cache).length > 0)
|
|
550
|
-
doc["source-map-cache"] = cache;
|
|
551
|
-
return found;
|
|
552
|
-
}
|
|
553
|
-
/**
|
|
554
|
-
* Compact, in place, every `coverage-*.json` in `dir` that no earlier
|
|
555
|
-
* capture shipped, attach the maps the dump lacks through `resolve`
|
|
556
|
-
* ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
|
|
557
|
-
* yet valid JSON) is left for the next capture.
|
|
558
|
-
*/
|
|
559
|
-
export async function prepareV8Reports(dir, resolve) {
|
|
560
|
-
const fs = await import("node:fs/promises");
|
|
561
|
-
const path = await import("node:path");
|
|
562
|
-
const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
|
|
563
|
-
let names;
|
|
564
|
-
try {
|
|
565
|
-
names = await fs.readdir(dir);
|
|
566
|
-
}
|
|
567
|
-
catch {
|
|
568
|
-
return stats;
|
|
569
|
-
}
|
|
570
|
-
for (const name of names.sort()) {
|
|
571
|
-
if (!name.startsWith("coverage-") || !name.endsWith(".json") || name === NODE_COVERAGE_EMPTY_REPORT)
|
|
572
|
-
continue;
|
|
573
|
-
const file = path.join(dir, name);
|
|
574
|
-
if (SHIPPED_V8_REPORTS.has(file))
|
|
575
|
-
continue;
|
|
576
|
-
let text;
|
|
577
|
-
let doc;
|
|
578
|
-
try {
|
|
579
|
-
text = await fs.readFile(file, "utf8");
|
|
580
|
-
doc = JSON.parse(text);
|
|
581
|
-
}
|
|
582
|
-
catch {
|
|
583
|
-
continue; // a dump mid-write, or not ours; the read path judges it
|
|
584
|
-
}
|
|
585
|
-
if (!Array.isArray(doc.result))
|
|
586
|
-
continue;
|
|
587
|
-
const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
|
|
588
|
-
if (resolve)
|
|
589
|
-
stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
|
|
590
|
-
const out = JSON.stringify(compact);
|
|
591
|
-
const tmp = path.join(dir, `.${name}.compact`);
|
|
592
|
-
await fs.writeFile(tmp, out);
|
|
593
|
-
await fs.rename(tmp, file);
|
|
594
|
-
SHIPPED_V8_REPORTS.add(file);
|
|
545
|
+
};
|
|
546
|
+
await seen(dir, isNodeDumpName, (b) => {
|
|
595
547
|
stats.dumps++;
|
|
596
|
-
stats.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
548
|
+
stats.dumpBytes += b;
|
|
549
|
+
});
|
|
550
|
+
await seen(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR), (n) => n.endsWith(".json"), (b) => {
|
|
551
|
+
stats.scripts++;
|
|
552
|
+
stats.scriptBytes += b;
|
|
553
|
+
});
|
|
600
554
|
return stats;
|
|
601
555
|
}
|
|
602
556
|
// ── browser ───────────────────────────────────────────────────────
|