@specific.dev/spectest 0.81.1 → 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 -153
- package/dist/coverage.js +319 -419
- 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 +165 -358
- package/src/coverage.ts +330 -449
- package/src/daemon.ts +11 -19
- package/src/harness/coverage.ts +12 -3
package/src/coverage.ts
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()`
|
|
@@ -27,8 +27,6 @@
|
|
|
27
27
|
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
28
28
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
29
29
|
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
|
-
|
|
32
30
|
import type { ServiceConfig } from "./index.js";
|
|
33
31
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
34
32
|
|
|
@@ -45,11 +43,6 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
|
45
43
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
46
44
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
47
45
|
|
|
48
|
-
/** Key of the content hash (`sha256:<hex>`) a shipped `source-map-cache`
|
|
49
|
-
* entry carries, and key of the stub that stands in for an entry that
|
|
50
|
-
* shipped earlier on the same branch. See {@link compactV8Document}. */
|
|
51
|
-
export const SOURCE_MAP_HASH_KEY = "spectestHash";
|
|
52
|
-
export const SOURCE_MAP_REF_KEY = "spectestRef";
|
|
53
46
|
|
|
54
47
|
/** What `configure` learns about the service it rewrites. */
|
|
55
48
|
export interface CoverageConfigureInfo {
|
|
@@ -90,9 +83,6 @@ export interface CoverageCaptureContext {
|
|
|
90
83
|
exec(command: string): Promise<{ stdout: string; stderr: string }>;
|
|
91
84
|
/** Write one report file (name relative to the directory). */
|
|
92
85
|
writeReport(name: string, content: string): Promise<void>;
|
|
93
|
-
/** Read one file out of the container (`docker cp`). `null` when it
|
|
94
|
-
* does not exist. Absent on a context that has no container. */
|
|
95
|
-
readContainerFile?(containerPath: string): Promise<Buffer | null>;
|
|
96
86
|
/** Aborts when the per-service capture budget runs out. */
|
|
97
87
|
signal: AbortSignal;
|
|
98
88
|
}
|
|
@@ -221,90 +211,290 @@ export function appendEnvFlag(
|
|
|
221
211
|
|
|
222
212
|
// ── node ──────────────────────────────────────────────────────────
|
|
223
213
|
|
|
214
|
+
/** Env var the hook reads: the coverage directory inside the container. */
|
|
215
|
+
export const NODE_COVERAGE_DIR_ENV = "SPECTEST_COVERAGE_DIR";
|
|
216
|
+
|
|
217
|
+
/** Subdirectory of the coverage dir where the hook writes one record per
|
|
218
|
+
* script (`scripts/<id>.json`, {@link NODE_COVERAGE_HOOK}). Shipped
|
|
219
|
+
* once, like a dump. */
|
|
220
|
+
export const NODE_COVERAGE_SCRIPTS_SUBDIR = "scripts";
|
|
221
|
+
|
|
222
|
+
/** Hidden subdirectory holding one empty marker per script record ever
|
|
223
|
+
* written on this branch. The hook's "written already?" check, which
|
|
224
|
+
* must survive the harness shipping and removing the record; hidden,
|
|
225
|
+
* so the harness never reads it. */
|
|
226
|
+
export const NODE_COVERAGE_MARKERS_SUBDIR = ".spectest/scripts";
|
|
227
|
+
|
|
228
|
+
/** Name prefix of the dumps the hook writes: `node-<pid>-<thread>-<ms>-<n>.json`. */
|
|
229
|
+
export const NODE_COVERAGE_DUMP_PREFIX = "node-";
|
|
230
|
+
|
|
224
231
|
/**
|
|
225
|
-
* The hook `node()` mounts
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* `
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
232
|
+
* The hook `node()` mounts at {@link NODE_COVERAGE_HOOK_PATH} and
|
|
233
|
+
* `--require`s into every node process of the service. **It is the
|
|
234
|
+
* collector**: nothing outside the process reads, converts or looks
|
|
235
|
+
* anything up. Runs in every thread (a worker thread inherits
|
|
236
|
+
* `NODE_OPTIONS`); reads one env var, {@link NODE_COVERAGE_DIR_ENV}; uses
|
|
237
|
+
* node built-ins only, so it runs in any image that runs node.
|
|
238
|
+
*
|
|
239
|
+
* What it does:
|
|
240
|
+
*
|
|
241
|
+
* - Starts V8 precise (block, counted) coverage through the in-process
|
|
242
|
+
* inspector session. `NODE_V8_COVERAGE` is **not** set: Node would then
|
|
243
|
+
* write everything the process loaded at every exit — `node:`
|
|
244
|
+
* internals, every `node_modules` file, and the whole source map of
|
|
245
|
+
* every script that carries a `sourceMappingURL` comment, with or
|
|
246
|
+
* without `--enable-source-maps`. For a bundled CLI that was 12 MB per
|
|
247
|
+
* invocation, inside the test's own time, parsed again at capture.
|
|
248
|
+
* - **Takes** on request — the main thread binds a Unix socket
|
|
249
|
+
* `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
|
|
250
|
+
* process binds its own (the first node process is often a wrapper,
|
|
251
|
+
* `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
|
|
252
|
+
* handler, which is how a short-lived CLI and a worker thread report.
|
|
253
|
+
* The in-process session answers synchronously, so the exit handler
|
|
254
|
+
* has the result before the process is gone. A take resets V8's
|
|
255
|
+
* counters: every dump is the delta since the previous take. No
|
|
256
|
+
* signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
257
|
+
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
258
|
+
* - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
|
|
259
|
+
* scripts (`file://`, outside `node_modules` and `.cache`, not the
|
|
260
|
+
* hook) in which some range count is above zero, and of each only the
|
|
261
|
+
* functions that ran, ranges and counts as V8 reported them. Nothing
|
|
262
|
+
* when nothing ran. Still a V8 coverage document (`result`), plus
|
|
263
|
+
* `spectestScript` on each script naming its record.
|
|
264
|
+
* - Writes, **once per script per branch**, a **script record**
|
|
265
|
+
* `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
|
|
266
|
+
* line of the source V8 compiled (`Debugger.getScriptSource`, so a
|
|
267
|
+
* transpiled module — tsx, ts-node, a bundler's register hook — is
|
|
268
|
+
* described by the text V8 ran, not the file on disk), every function
|
|
269
|
+
* with its extent (the skeleton: what a dump's "ran" functions are a
|
|
270
|
+
* subset of, so a never-run function is known to exist), and the
|
|
271
|
+
* source map: the inline `data:` map in the text, the file the last
|
|
272
|
+
* `sourceMappingURL` comment names, or `<script>.map` next to the
|
|
273
|
+
* script; `sourcesContent` dropped (the sources are the repository).
|
|
274
|
+
* "Once per branch" is a marker file under `.spectest/scripts/`: the
|
|
275
|
+
* directory is shared by every process of the service and forks with
|
|
276
|
+
* the environment, so a record written at bring-up or by an ancestor
|
|
277
|
+
* is never written again, whichever process comes next.
|
|
278
|
+
*
|
|
279
|
+
* The harness ships new dumps and new records as they are. It parses
|
|
280
|
+
* nothing and touches no container; the control plane derives lines
|
|
281
|
+
* from the stored documents (`SUBSET_RUNS.md`).
|
|
242
282
|
*/
|
|
243
283
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
244
284
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
285
|
+
(function () {
|
|
286
|
+
const dir = process.env[${JSON.stringify(NODE_COVERAGE_DIR_ENV)}];
|
|
287
|
+
if (!dir) return;
|
|
288
|
+
let inspector, fs, path, crypto, url, wt, net;
|
|
289
|
+
try {
|
|
290
|
+
inspector = require("node:inspector");
|
|
291
|
+
fs = require("node:fs");
|
|
292
|
+
path = require("node:path");
|
|
293
|
+
crypto = require("node:crypto");
|
|
294
|
+
url = require("node:url");
|
|
295
|
+
wt = require("node:worker_threads");
|
|
296
|
+
net = require("node:net");
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const session = new inspector.Session();
|
|
301
|
+
try {
|
|
302
|
+
session.connect();
|
|
303
|
+
} catch (e) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// An in-process session answers before post() returns.
|
|
307
|
+
function post(method, params) {
|
|
308
|
+
let out = null;
|
|
309
|
+
session.post(method, params || {}, function (err, res) {
|
|
310
|
+
out = err ? null : res;
|
|
311
|
+
});
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
if (post("Profiler.enable") === null) return;
|
|
315
|
+
post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
316
|
+
|
|
317
|
+
const SELF = url.pathToFileURL(__filename).href;
|
|
318
|
+
const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
|
|
319
|
+
const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
|
|
320
|
+
const known = new Set();
|
|
321
|
+
let seq = 0;
|
|
322
|
+
|
|
323
|
+
function isApp(u) {
|
|
324
|
+
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
325
|
+
}
|
|
326
|
+
function ran(f) {
|
|
327
|
+
return f.ranges.some(function (r) { return r.count > 0; });
|
|
328
|
+
}
|
|
329
|
+
function idOf(u) {
|
|
330
|
+
return crypto.createHash("sha256").update(u).digest("hex").slice(0, 32);
|
|
331
|
+
}
|
|
332
|
+
function lineLengths(text) {
|
|
333
|
+
const out = [];
|
|
334
|
+
let len = 0;
|
|
335
|
+
for (let i = 0; i < text.length; i++, len++) {
|
|
336
|
+
const c = text.charCodeAt(i);
|
|
337
|
+
if (c === 0x0a || c === 0x2028 || c === 0x2029) {
|
|
338
|
+
out.push(len);
|
|
339
|
+
len = -1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
out.push(len);
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
function readMap(scriptPath, text) {
|
|
346
|
+
const re = /\\/\\/[#@]\\s*sourceMappingURL=(\\S+)/g;
|
|
347
|
+
let ref = null;
|
|
348
|
+
let m;
|
|
349
|
+
while ((m = re.exec(text)) !== null) ref = m[1];
|
|
350
|
+
let mapText = null;
|
|
351
|
+
let mapUrl = null;
|
|
352
|
+
try {
|
|
353
|
+
if (ref === null) {
|
|
354
|
+
const p = scriptPath + ".map";
|
|
355
|
+
mapText = fs.readFileSync(p, "utf8");
|
|
356
|
+
mapUrl = url.pathToFileURL(p).href;
|
|
357
|
+
} else if (ref.startsWith("data:")) {
|
|
358
|
+
const c = ref.indexOf(",");
|
|
359
|
+
if (c < 0) return null;
|
|
360
|
+
const head = ref.slice(5, c);
|
|
361
|
+
const body = ref.slice(c + 1);
|
|
362
|
+
mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
|
|
363
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith("file:")) {
|
|
364
|
+
return null;
|
|
365
|
+
} else {
|
|
366
|
+
const p = ref.startsWith("file:") ? url.fileURLToPath(ref) : path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
|
|
367
|
+
mapText = fs.readFileSync(p, "utf8");
|
|
368
|
+
mapUrl = url.pathToFileURL(p).href;
|
|
369
|
+
}
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
let data;
|
|
374
|
+
try {
|
|
375
|
+
data = JSON.parse(mapText);
|
|
376
|
+
} catch (e) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
if (!data || typeof data !== "object" || !("mappings" in data)) return null;
|
|
380
|
+
delete data.sourcesContent;
|
|
381
|
+
return { url: mapUrl, data: data };
|
|
382
|
+
}
|
|
383
|
+
function writeAtomic(file, text) {
|
|
384
|
+
const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
|
|
385
|
+
fs.writeFileSync(tmp, text);
|
|
386
|
+
fs.renameSync(tmp, file);
|
|
387
|
+
}
|
|
388
|
+
function describe(s, source) {
|
|
389
|
+
const id = idOf(s.url);
|
|
390
|
+
let scriptPath = null;
|
|
391
|
+
try {
|
|
392
|
+
scriptPath = url.fileURLToPath(s.url);
|
|
393
|
+
} catch (e) {}
|
|
394
|
+
const text = typeof source === "string" ? source : null;
|
|
395
|
+
const record = {
|
|
396
|
+
url: s.url,
|
|
397
|
+
lineLengths: text !== null ? lineLengths(text) : null,
|
|
398
|
+
functions: s.functions.map(function (f) {
|
|
399
|
+
return [f.functionName, f.ranges[0].startOffset, f.ranges[0].endOffset];
|
|
400
|
+
}),
|
|
401
|
+
sourceMap: text !== null && scriptPath !== null ? readMap(scriptPath, text) : null,
|
|
402
|
+
};
|
|
403
|
+
writeAtomic(path.join(SCRIPTS, id + ".json"), JSON.stringify({ spectestScript: record }));
|
|
404
|
+
try {
|
|
405
|
+
fs.writeFileSync(path.join(MARKERS, id), "");
|
|
406
|
+
} catch (e) {}
|
|
407
|
+
}
|
|
408
|
+
function take() {
|
|
409
|
+
const res = post("Profiler.takePreciseCoverage");
|
|
410
|
+
if (!res || !Array.isArray(res.result)) return;
|
|
411
|
+
const kept = [];
|
|
412
|
+
for (const s of res.result) {
|
|
413
|
+
if (isApp(s.url) && Array.isArray(s.functions) && s.functions.some(ran)) kept.push(s);
|
|
414
|
+
}
|
|
415
|
+
if (kept.length === 0) return;
|
|
416
|
+
const fresh = [];
|
|
417
|
+
for (const s of kept) {
|
|
418
|
+
if (known.has(s.url)) continue;
|
|
419
|
+
known.add(s.url);
|
|
420
|
+
if (!fs.existsSync(path.join(MARKERS, idOf(s.url)))) fresh.push(s);
|
|
421
|
+
}
|
|
422
|
+
if (fresh.length > 0) {
|
|
261
423
|
try {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
424
|
+
fs.mkdirSync(SCRIPTS, { recursive: true });
|
|
425
|
+
fs.mkdirSync(MARKERS, { recursive: true });
|
|
426
|
+
} catch (e) {}
|
|
427
|
+
post("Debugger.enable");
|
|
428
|
+
for (const s of fresh) {
|
|
429
|
+
const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
|
|
430
|
+
try {
|
|
431
|
+
describe(s, r && r.scriptSource);
|
|
432
|
+
} catch (e) {}
|
|
267
433
|
}
|
|
268
|
-
|
|
434
|
+
post("Debugger.disable");
|
|
435
|
+
}
|
|
436
|
+
const doc = {
|
|
437
|
+
spectestVersion: 1,
|
|
438
|
+
result: kept.map(function (s) {
|
|
439
|
+
return { url: s.url, scriptId: s.scriptId, spectestScript: idOf(s.url), functions: s.functions.filter(ran) };
|
|
440
|
+
}),
|
|
441
|
+
};
|
|
442
|
+
const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + process.pid + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
|
|
443
|
+
writeAtomic(path.join(dir, name), JSON.stringify(doc));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (wt.isMainThread) {
|
|
447
|
+
const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
|
|
448
|
+
const server = net.createServer(function (conn) {
|
|
449
|
+
let buf = "";
|
|
450
|
+
conn.on("data", function (chunk) {
|
|
451
|
+
buf += chunk;
|
|
452
|
+
if (buf.indexOf("\\n") < 0) return;
|
|
453
|
+
let reply;
|
|
454
|
+
try {
|
|
455
|
+
if (!buf.startsWith("dump")) throw new Error("unknown request");
|
|
456
|
+
take();
|
|
457
|
+
reply = "ok\\n";
|
|
458
|
+
} catch (err) {
|
|
459
|
+
reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
|
|
460
|
+
}
|
|
461
|
+
conn.end(reply);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
server.unref();
|
|
465
|
+
server.on("error", function () {});
|
|
466
|
+
try {
|
|
467
|
+
fs.unlinkSync(SOCK);
|
|
468
|
+
} catch (e) {}
|
|
469
|
+
server.listen(SOCK);
|
|
470
|
+
process.on("exit", function () {
|
|
471
|
+
try {
|
|
472
|
+
fs.unlinkSync(SOCK);
|
|
473
|
+
} catch (e) {}
|
|
269
474
|
});
|
|
475
|
+
}
|
|
476
|
+
process.on("exit", function () {
|
|
477
|
+
try {
|
|
478
|
+
take();
|
|
479
|
+
} catch (e) {}
|
|
270
480
|
});
|
|
271
|
-
|
|
272
|
-
server.on("error", () => {});
|
|
273
|
-
try { fs.unlinkSync(SOCK); } catch {}
|
|
274
|
-
server.listen(SOCK);
|
|
275
|
-
process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
|
|
276
|
-
}
|
|
481
|
+
})();
|
|
277
482
|
`;
|
|
278
483
|
|
|
279
484
|
/**
|
|
280
485
|
* Coverage for a Node service — a long-lived server, or a container whose
|
|
281
486
|
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* without `--enable-source-maps`), else from `<script>.map` next to the
|
|
294
|
-
* script, read out of the container once
|
|
295
|
-
* ({@link resolveSourceMapsFromContainer}). The second way is the one to
|
|
296
|
-
* build a short-lived bundled process in: a map Node finds is serialized
|
|
297
|
-
* whole into every exit dump — 12 MB per invocation for a bundled CLI,
|
|
298
|
-
* inside the test's own time — and parsed again by the harness; a map
|
|
299
|
-
* with no comment costs nothing there. Nothing is converted in the VM.
|
|
300
|
-
* A dump is a **delta** by construction: V8 resets its counters at every
|
|
301
|
-
* `takeCoverage()`, so a live server's dump after a test is that test's
|
|
302
|
-
* own execution, and the boot dump (everything loaded) lands in the
|
|
303
|
-
* bring-up capture and nowhere else. The control plane derives lines
|
|
304
|
-
* from the stored documents, off the test run. Converting to lcov in
|
|
305
|
-
* the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
|
|
306
|
-
* service on a real project, inside the test run; a real project turned
|
|
307
|
-
* coverage off because of it, and the conversion went.
|
|
487
|
+
* {@link NODE_COVERAGE_DIR_ENV} to the coverage directory and
|
|
488
|
+
* `--require`s {@link NODE_COVERAGE_HOOK}, which collects everything in
|
|
489
|
+
* the process. Nothing for the app to write, and nothing the harness
|
|
490
|
+
* reads out of the container: at capture it asks every live process for
|
|
491
|
+
* a take over its socket and lists what appeared in the directory —
|
|
492
|
+
* dumps and script records — for the harness to ship as they are. A
|
|
493
|
+
* dump is a delta by construction (V8 resets its counters at every
|
|
494
|
+
* take), so the report after a test is that test's own execution and
|
|
495
|
+
* the boot dump lands in the bring-up capture alone. Each capture ships
|
|
496
|
+
* only its own files: what an earlier capture on this branch shipped is
|
|
497
|
+
* removed first (module memory, forks with the environment).
|
|
308
498
|
*/
|
|
309
499
|
export function node(): CoverageAdapter {
|
|
310
500
|
return {
|
|
@@ -312,7 +502,7 @@ export function node(): CoverageAdapter {
|
|
|
312
502
|
reports: "delta",
|
|
313
503
|
configure(svc) {
|
|
314
504
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
315
|
-
env
|
|
505
|
+
env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
|
|
316
506
|
return {
|
|
317
507
|
...svc,
|
|
318
508
|
env,
|
|
@@ -329,24 +519,18 @@ export function node(): CoverageAdapter {
|
|
|
329
519
|
// rather than failed: an empty report is a report. A server whose
|
|
330
520
|
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
331
521
|
// empty reports after bring-up, which the boot log warns about.
|
|
332
|
-
//
|
|
333
|
-
// Each capture ships only its own dumps: what an earlier capture on
|
|
334
|
-
// this branch shipped is removed first (module memory, forks with
|
|
335
|
-
// the environment).
|
|
336
522
|
const t0 = performance.now();
|
|
337
|
-
await
|
|
523
|
+
await removeShippedNodeReports(ctx.reportDir);
|
|
338
524
|
const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
339
525
|
const t1 = performance.now();
|
|
340
|
-
const stats = await
|
|
526
|
+
const stats = await collectNodeReports(ctx.reportDir);
|
|
341
527
|
if (stats.dumps === 0) {
|
|
342
528
|
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
343
|
-
|
|
529
|
+
SHIPPED_NODE_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
|
|
344
530
|
}
|
|
345
|
-
const t2 = performance.now();
|
|
346
531
|
console.log(
|
|
347
532
|
`[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
|
|
348
|
-
`${stats.dumps} dump(s)
|
|
349
|
-
`${stats.mapsFromContainer} map(s) read from the container`,
|
|
533
|
+
`${stats.dumps} dump(s) (${stats.dumpBytes} B), ${stats.scripts} new script record(s) (${stats.scriptBytes} B)`,
|
|
350
534
|
);
|
|
351
535
|
},
|
|
352
536
|
};
|
|
@@ -402,387 +586,84 @@ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Pr
|
|
|
402
586
|
return live;
|
|
403
587
|
}
|
|
404
588
|
|
|
405
|
-
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* its own.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
/** Source-map entries shipped earlier on this branch: script URL → hash
|
|
412
|
-
* of the entry. Module memory, forks with the environment. A later dump
|
|
413
|
-
* of the same script carries a stub that refers to the hash instead of
|
|
414
|
-
* the map again. */
|
|
415
|
-
const SHIPPED_SOURCE_MAPS = new Map<string, string>();
|
|
416
|
-
|
|
417
|
-
/** Scripts looked up in the container and found to have no source map
|
|
418
|
-
* (no `sourceMappingURL`, or a map file that is not there). Module
|
|
419
|
-
* memory, forks with the environment: one lookup per script per branch. */
|
|
420
|
-
const NO_SOURCE_MAP = new Set<string>();
|
|
589
|
+
/** Files shipped at an earlier capture on this branch, by host path:
|
|
590
|
+
* dumps, script records, the empty report. Module memory: forks with
|
|
591
|
+
* the environment, so a forked child removes its ancestors' files at
|
|
592
|
+
* its first capture and ships only its own. The hook's own "written
|
|
593
|
+
* already" markers are elsewhere and stay. */
|
|
594
|
+
const SHIPPED_NODE_REPORTS = new Set<string>();
|
|
421
595
|
|
|
422
596
|
/** Forget the branch memory (tests). */
|
|
423
597
|
export function resetNodeCoverageMemory(): void {
|
|
424
|
-
|
|
425
|
-
SHIPPED_SOURCE_MAPS.clear();
|
|
426
|
-
NO_SOURCE_MAP.clear();
|
|
598
|
+
SHIPPED_NODE_REPORTS.clear();
|
|
427
599
|
}
|
|
428
600
|
|
|
429
|
-
/** Remove from `dir` the
|
|
430
|
-
export async function
|
|
601
|
+
/** Remove from `dir` the files an earlier capture shipped. */
|
|
602
|
+
export async function removeShippedNodeReports(dir: string): Promise<void> {
|
|
431
603
|
const fs = await import("node:fs/promises");
|
|
432
|
-
for (const file of [...
|
|
604
|
+
for (const file of [...SHIPPED_NODE_REPORTS]) {
|
|
433
605
|
if (!file.startsWith(dir + "/")) continue;
|
|
434
606
|
await fs.unlink(file).catch(() => {});
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** A script is the app's own when it is a file outside node_modules —
|
|
440
|
-
* and not our hook, which every dump would otherwise carry. */
|
|
441
|
-
export function isAppScriptUrl(url: string): boolean {
|
|
442
|
-
return (
|
|
443
|
-
url.startsWith("file://") &&
|
|
444
|
-
!url.includes("/node_modules/") &&
|
|
445
|
-
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
446
|
-
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
447
|
-
!url.includes("/.cache/") &&
|
|
448
|
-
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop())
|
|
449
|
-
);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
interface V8Range {
|
|
453
|
-
count?: unknown;
|
|
454
|
-
}
|
|
455
|
-
interface V8Function {
|
|
456
|
-
ranges?: V8Range[];
|
|
457
|
-
}
|
|
458
|
-
interface V8Script {
|
|
459
|
-
url?: unknown;
|
|
460
|
-
functions?: V8Function[];
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/** True when any range of any function of the script has a count above
|
|
464
|
-
* zero: something in it ran since the previous take. A script whose
|
|
465
|
-
* every count is zero is one V8 still lists after a reset; it says
|
|
466
|
-
* nothing and is dropped. */
|
|
467
|
-
export function scriptExecuted(script: V8Script): boolean {
|
|
468
|
-
const fns = Array.isArray(script.functions) ? script.functions : [];
|
|
469
|
-
return fns.some((f) => Array.isArray(f.ranges) && f.ranges.some((r) => typeof r.count === "number" && r.count > 0));
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Compact one V8 coverage document to what the derivation needs. What
|
|
474
|
-
* Node writes is everything the process loaded: `node:` internals, every
|
|
475
|
-
* `node_modules` file, and — under `--enable-source-maps` — a
|
|
476
|
-
* `source-map-cache` with each file's full map **and its sources**,
|
|
477
|
-
* repeated in every dump. Measured on a real project: a 12–20 MiB dump
|
|
478
|
-
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
479
|
-
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
480
|
-
* scripts outside `node_modules` in which something ran, and the map
|
|
481
|
-
* entries of exactly those scripts, minus `sourcesContent` (the sources
|
|
482
|
-
* are the repo). Each kept map entry carries its content hash under
|
|
483
|
-
* {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
|
|
484
|
-
* URL — the map went out with an earlier dump on this branch — a stub
|
|
485
|
-
* `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
|
|
486
|
-
* the map in an earlier capture of the branch. `shipped` is updated in
|
|
487
|
-
* place. Still a V8 document — nothing is converted here.
|
|
488
|
-
*/
|
|
489
|
-
export function compactV8Document(
|
|
490
|
-
doc: Record<string, unknown>,
|
|
491
|
-
shipped?: Map<string, string>,
|
|
492
|
-
): Record<string, unknown> {
|
|
493
|
-
const result = Array.isArray(doc.result) ? (doc.result as V8Script[]) : [];
|
|
494
|
-
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url) && scriptExecuted(s));
|
|
495
|
-
const out: Record<string, unknown> = { ...doc, result: kept };
|
|
496
|
-
const cache = doc["source-map-cache"];
|
|
497
|
-
if (cache && typeof cache === "object") {
|
|
498
|
-
const urls = new Set(kept.map((s) => s.url as string));
|
|
499
|
-
const slim: Record<string, unknown> = {};
|
|
500
|
-
for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
|
|
501
|
-
if (!urls.has(url) || !entry || typeof entry !== "object") continue;
|
|
502
|
-
const e = { ...(entry as Record<string, unknown>) };
|
|
503
|
-
if (e.data && typeof e.data === "object") {
|
|
504
|
-
const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
|
|
505
|
-
e.data = data;
|
|
506
|
-
}
|
|
507
|
-
const hash = sourceMapEntryHash(e);
|
|
508
|
-
if (shipped?.get(url) === hash) {
|
|
509
|
-
slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
|
|
510
|
-
} else {
|
|
511
|
-
slim[url] = { ...e, [SOURCE_MAP_HASH_KEY]: hash };
|
|
512
|
-
shipped?.set(url, hash);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
if (Object.keys(slim).length > 0) out["source-map-cache"] = slim;
|
|
516
|
-
else delete out["source-map-cache"];
|
|
607
|
+
SHIPPED_NODE_REPORTS.delete(file);
|
|
517
608
|
}
|
|
518
|
-
return out;
|
|
519
609
|
}
|
|
520
610
|
|
|
521
|
-
/** What {@link
|
|
522
|
-
export interface
|
|
611
|
+
/** What {@link collectNodeReports} found, for the capture's log line. */
|
|
612
|
+
export interface NodeReportStats {
|
|
523
613
|
dumps: number;
|
|
614
|
+
dumpBytes: number;
|
|
524
615
|
scripts: number;
|
|
525
|
-
|
|
526
|
-
bytesOut: number;
|
|
527
|
-
/** Source maps read out of the container this capture. */
|
|
528
|
-
mapsFromContainer: number;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** One `source-map-cache` entry in the shape Node writes: the map (its
|
|
532
|
-
* `sourcesContent` dropped), the generated script's line lengths, and
|
|
533
|
-
* the map's own URL. */
|
|
534
|
-
export interface SourceMapEntry {
|
|
535
|
-
url: string | null;
|
|
536
|
-
data: Record<string, unknown>;
|
|
537
|
-
lineLengths: number[];
|
|
616
|
+
scriptBytes: number;
|
|
538
617
|
}
|
|
539
618
|
|
|
540
|
-
/**
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
/** Shell-quote one argument for `sh -c`. */
|
|
545
|
-
function shQuote(s: string): string {
|
|
546
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
619
|
+
/** True for a dump the hook wrote (`node-*.json`). */
|
|
620
|
+
export function isNodeDumpName(name: string): boolean {
|
|
621
|
+
return name.startsWith(NODE_COVERAGE_DUMP_PREFIX) && name.endsWith(".json");
|
|
547
622
|
}
|
|
548
623
|
|
|
549
624
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
* capture per service, whatever the number of scripts: a Next.js server
|
|
555
|
-
* loads hundreds of chunk scripts with no map, and a `docker cp` per
|
|
556
|
-
* script (the first cut) took a capture past its budget on a busy host.
|
|
625
|
+
* Mark shipped every dump at the root of `dir` and every script record
|
|
626
|
+
* under `scripts/` that no earlier capture shipped, and count them.
|
|
627
|
+
* Nothing is read: the hook wrote each file whole (write + rename), and
|
|
628
|
+
* the harness ships them as they are.
|
|
557
629
|
*/
|
|
558
|
-
export function
|
|
559
|
-
const
|
|
560
|
-
'for f in "$@"; do ' +
|
|
561
|
-
'if [ -f "$f" ]; then ' +
|
|
562
|
-
"ref=$(grep -o 'sourceMappingURL=[^[:space:]]*' -- \"$f\" 2>/dev/null | tail -n 1 | cut -c18-); " +
|
|
563
|
-
'if [ -n "$ref" ]; then printf \'%s\\t%s\\n\' "$f" "$ref"; ' +
|
|
564
|
-
'elif [ -f "$f.map" ]; then printf \'%s\\t%s.map\\n\' "$f" "$f"; ' +
|
|
565
|
-
'else printf \'%s\\t-\\n\' "$f"; fi; ' +
|
|
566
|
-
'else printf \'%s\\t!\\n\' "$f"; fi; ' +
|
|
567
|
-
"done";
|
|
568
|
-
return `sh -c ${shQuote(script)} sh ${scriptPaths.map(shQuote).join(" ")}`;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** The length of every line of `text`, the way Node computes it for
|
|
572
|
-
* `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
|
|
573
|
-
* last line included. Lengths in UTF-16 units, which is what V8's byte
|
|
574
|
-
* offsets count in. */
|
|
575
|
-
export function lineLengthsOf(text: string): number[] {
|
|
576
|
-
const out: number[] = [];
|
|
577
|
-
let len = 0;
|
|
578
|
-
for (let i = 0; i < text.length; i++, len++) {
|
|
579
|
-
const c = text.charCodeAt(i);
|
|
580
|
-
if (c === 0x0a || c === 0x2028 || c === 0x2029) {
|
|
581
|
-
out.push(len);
|
|
582
|
-
len = -1;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
out.push(len);
|
|
586
|
-
return out;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* The source maps of `scriptUrls`, read out of the container. One probe
|
|
591
|
-
* exec ({@link sourceMapProbeCommand}) finds, per script, the map a
|
|
592
|
-
* `sourceMappingURL` comment names or `<script>.map` next to it; only a
|
|
593
|
-
* script that has one is then read (`docker cp`, the script for its line
|
|
594
|
-
* lengths and the map file, or the inline `data:` map decoded). A map's
|
|
595
|
-
* `sourcesContent` is dropped. Scripts that are not `file://` URLs, have
|
|
596
|
-
* no map, or whose map is not there or not JSON are absent from the
|
|
597
|
-
* result. The caller remembers every outcome, so this runs once per
|
|
598
|
-
* script per branch.
|
|
599
|
-
*
|
|
600
|
-
* With no comment the map next to the script is the shape to build a
|
|
601
|
-
* short-lived bundled process in: Node caches a map it finds through a
|
|
602
|
-
* comment into every dump it writes under NODE_V8_COVERAGE, flag or no
|
|
603
|
-
* flag, and a bundled CLI's map is tens of MB per exit; a map it does not
|
|
604
|
-
* find costs nothing there.
|
|
605
|
-
*/
|
|
606
|
-
export async function resolveSourceMapsFromContainer(
|
|
607
|
-
ctx: CoverageCaptureContext,
|
|
608
|
-
scriptUrls: string[],
|
|
609
|
-
): Promise<Map<string, SourceMapEntry>> {
|
|
610
|
-
const out = new Map<string, SourceMapEntry>();
|
|
611
|
-
if (!ctx.readContainerFile) return out;
|
|
612
|
-
const { fileURLToPath, pathToFileURL } = await import("node:url");
|
|
630
|
+
export async function collectNodeReports(dir: string): Promise<NodeReportStats> {
|
|
631
|
+
const fs = await import("node:fs/promises");
|
|
613
632
|
const path = await import("node:path");
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
633
|
+
const stats: NodeReportStats = { dumps: 0, dumpBytes: 0, scripts: 0, scriptBytes: 0 };
|
|
634
|
+
const seen = async (d: string, keep: (name: string) => boolean, onNew: (bytes: number) => void): Promise<void> => {
|
|
635
|
+
let names: string[];
|
|
617
636
|
try {
|
|
618
|
-
|
|
637
|
+
names = await fs.readdir(d);
|
|
619
638
|
} catch {
|
|
620
|
-
|
|
639
|
+
return;
|
|
621
640
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
} catch {
|
|
628
|
-
return out; // no shell, no grep: no maps from this container
|
|
629
|
-
}
|
|
630
|
-
for (const line of probe.split("\n")) {
|
|
631
|
-
const tab = line.indexOf("\t");
|
|
632
|
-
if (tab < 0) continue;
|
|
633
|
-
const scriptPath = line.slice(0, tab);
|
|
634
|
-
const ref = line.slice(tab + 1).trim();
|
|
635
|
-
const url = byPath.get(scriptPath);
|
|
636
|
-
if (!url || ref === "-" || ref === "!" || ref === "") continue;
|
|
637
|
-
let mapText: string;
|
|
638
|
-
let mapUrl: string | null;
|
|
639
|
-
if (ref.startsWith("data:")) {
|
|
640
|
-
const comma = ref.indexOf(",");
|
|
641
|
-
if (comma < 0) continue;
|
|
642
|
-
const head = ref.slice(5, comma);
|
|
643
|
-
const body = ref.slice(comma + 1);
|
|
641
|
+
for (const name of names.sort()) {
|
|
642
|
+
if (!keep(name)) continue;
|
|
643
|
+
const file = path.join(d, name);
|
|
644
|
+
if (SHIPPED_NODE_REPORTS.has(file)) continue;
|
|
645
|
+
let size: number;
|
|
644
646
|
try {
|
|
645
|
-
|
|
647
|
+
size = (await fs.stat(file)).size;
|
|
646
648
|
} catch {
|
|
647
649
|
continue;
|
|
648
650
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
let mapPath: string;
|
|
652
|
-
if (ref.startsWith("file://")) {
|
|
653
|
-
try {
|
|
654
|
-
mapPath = fileURLToPath(ref);
|
|
655
|
-
} catch {
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
|
-
} else if (/^[a-z]+:/i.test(ref)) {
|
|
659
|
-
continue; // an http(s) map is not in the container
|
|
660
|
-
} else {
|
|
661
|
-
let rel = ref;
|
|
662
|
-
try {
|
|
663
|
-
rel = decodeURIComponent(ref);
|
|
664
|
-
} catch {
|
|
665
|
-
// keep as written
|
|
666
|
-
}
|
|
667
|
-
mapPath = path.resolve(path.dirname(scriptPath), rel);
|
|
668
|
-
}
|
|
669
|
-
const map = await ctx.readContainerFile(mapPath);
|
|
670
|
-
if (!map) continue;
|
|
671
|
-
mapText = map.toString("utf8");
|
|
672
|
-
mapUrl = pathToFileURL(mapPath).href;
|
|
673
|
-
}
|
|
674
|
-
let data: Record<string, unknown>;
|
|
675
|
-
try {
|
|
676
|
-
data = JSON.parse(mapText) as Record<string, unknown>;
|
|
677
|
-
} catch {
|
|
678
|
-
continue;
|
|
679
|
-
}
|
|
680
|
-
if (!data || typeof data !== "object" || !("mappings" in data)) continue;
|
|
681
|
-
const script = await ctx.readContainerFile(scriptPath);
|
|
682
|
-
if (!script) continue;
|
|
683
|
-
const { sourcesContent: _dropped, ...slim } = data;
|
|
684
|
-
out.set(url, { url: mapUrl, data: slim, lineLengths: lineLengthsOf(script.toString("utf8")) });
|
|
685
|
-
}
|
|
686
|
-
return out;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/** The content hash of a map entry, over its JSON with the spectest
|
|
690
|
-
* keys removed. */
|
|
691
|
-
function sourceMapEntryHash(entry: Record<string, unknown>): string {
|
|
692
|
-
const e = { ...entry };
|
|
693
|
-
delete e[SOURCE_MAP_HASH_KEY];
|
|
694
|
-
delete e[SOURCE_MAP_REF_KEY];
|
|
695
|
-
return "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Give every kept script of a compacted document a `source-map-cache`
|
|
700
|
-
* entry it lacks: a stub when the map shipped earlier on this branch,
|
|
701
|
-
* else the map `resolve` finds (asked once for all such scripts; shipped
|
|
702
|
-
* whole, with its hash, and remembered), else nothing — and that outcome
|
|
703
|
-
* is remembered too, so a script with no map is looked up once per
|
|
704
|
-
* branch. Returns how many maps `resolve` supplied.
|
|
705
|
-
*/
|
|
706
|
-
export async function attachSourceMaps(
|
|
707
|
-
doc: Record<string, unknown>,
|
|
708
|
-
resolve: SourceMapResolver,
|
|
709
|
-
shipped: Map<string, string> = SHIPPED_SOURCE_MAPS,
|
|
710
|
-
missing: Set<string> = NO_SOURCE_MAP,
|
|
711
|
-
): Promise<number> {
|
|
712
|
-
const result = Array.isArray(doc.result) ? (doc.result as V8Script[]) : [];
|
|
713
|
-
const cache = ((doc["source-map-cache"] as Record<string, unknown> | undefined) ?? {}) as Record<string, unknown>;
|
|
714
|
-
const unknown: string[] = [];
|
|
715
|
-
for (const s of result) {
|
|
716
|
-
const url = s.url;
|
|
717
|
-
if (typeof url !== "string" || url in cache) continue;
|
|
718
|
-
const prior = shipped.get(url);
|
|
719
|
-
if (prior) {
|
|
720
|
-
cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
|
|
721
|
-
continue;
|
|
722
|
-
}
|
|
723
|
-
if (missing.has(url) || unknown.includes(url)) continue;
|
|
724
|
-
unknown.push(url);
|
|
725
|
-
}
|
|
726
|
-
let found = 0;
|
|
727
|
-
if (unknown.length > 0) {
|
|
728
|
-
const entries = await resolve(unknown);
|
|
729
|
-
for (const url of unknown) {
|
|
730
|
-
const entry = entries.get(url);
|
|
731
|
-
if (!entry) {
|
|
732
|
-
missing.add(url);
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
const hash = sourceMapEntryHash(entry as unknown as Record<string, unknown>);
|
|
736
|
-
cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
|
|
737
|
-
shipped.set(url, hash);
|
|
738
|
-
found++;
|
|
651
|
+
SHIPPED_NODE_REPORTS.add(file);
|
|
652
|
+
onNew(size);
|
|
739
653
|
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
return found;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* Compact, in place, every `coverage-*.json` in `dir` that no earlier
|
|
747
|
-
* capture shipped, attach the maps the dump lacks through `resolve`
|
|
748
|
-
* ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
|
|
749
|
-
* yet valid JSON) is left for the next capture.
|
|
750
|
-
*/
|
|
751
|
-
export async function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats> {
|
|
752
|
-
const fs = await import("node:fs/promises");
|
|
753
|
-
const path = await import("node:path");
|
|
754
|
-
const stats: PrepareV8Stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
|
|
755
|
-
let names: string[];
|
|
756
|
-
try {
|
|
757
|
-
names = await fs.readdir(dir);
|
|
758
|
-
} catch {
|
|
759
|
-
return stats;
|
|
760
|
-
}
|
|
761
|
-
for (const name of names.sort()) {
|
|
762
|
-
if (!name.startsWith("coverage-") || !name.endsWith(".json") || name === NODE_COVERAGE_EMPTY_REPORT) continue;
|
|
763
|
-
const file = path.join(dir, name);
|
|
764
|
-
if (SHIPPED_V8_REPORTS.has(file)) continue;
|
|
765
|
-
let text: string;
|
|
766
|
-
let doc: Record<string, unknown>;
|
|
767
|
-
try {
|
|
768
|
-
text = await fs.readFile(file, "utf8");
|
|
769
|
-
doc = JSON.parse(text) as Record<string, unknown>;
|
|
770
|
-
} catch {
|
|
771
|
-
continue; // a dump mid-write, or not ours; the read path judges it
|
|
772
|
-
}
|
|
773
|
-
if (!Array.isArray(doc.result)) continue;
|
|
774
|
-
const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
|
|
775
|
-
if (resolve) stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
|
|
776
|
-
const out = JSON.stringify(compact);
|
|
777
|
-
const tmp = path.join(dir, `.${name}.compact`);
|
|
778
|
-
await fs.writeFile(tmp, out);
|
|
779
|
-
await fs.rename(tmp, file);
|
|
780
|
-
SHIPPED_V8_REPORTS.add(file);
|
|
654
|
+
};
|
|
655
|
+
await seen(dir, isNodeDumpName, (b) => {
|
|
781
656
|
stats.dumps++;
|
|
782
|
-
stats.
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
657
|
+
stats.dumpBytes += b;
|
|
658
|
+
});
|
|
659
|
+
await seen(
|
|
660
|
+
path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR),
|
|
661
|
+
(n) => n.endsWith(".json"),
|
|
662
|
+
(b) => {
|
|
663
|
+
stats.scripts++;
|
|
664
|
+
stats.scriptBytes += b;
|
|
665
|
+
},
|
|
666
|
+
);
|
|
786
667
|
return stats;
|
|
787
668
|
}
|
|
788
669
|
|