@specific.dev/spectest 0.79.2 → 0.81.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/src/coverage.ts CHANGED
@@ -14,11 +14,10 @@
14
14
  //
15
15
  // After every adapter ran, the harness reads the directory the same way
16
16
  // it does for `coverage: true`: at least one well-formed report (lcov or
17
- // V8 JSON), shipped verbatim. The control plane derives what it needs
18
- // from the stored bytes. One adapter converts: `node()` turns the V8
19
- // dumps into lcov inside the container (with `c8`, which golden ships),
20
- // because the conversion needs the sources and the source maps, which
21
- // only the container has.
17
+ // V8 JSON), shipped verbatim. Nothing is converted in the VM: `node()`
18
+ // compacts its V8 dumps (the app's own scripts, each source map once per
19
+ // branch) and ships them as they are. The control plane derives what it
20
+ // needs from the stored bytes, off the test run.
22
21
  //
23
22
  // Imported from `@specific.dev/spectest/coverage`. The three shipped
24
23
  // adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
@@ -28,6 +27,8 @@
28
27
  // import * as coverage from "@specific.dev/spectest/coverage";
29
28
  // coverage: { adapters: [coverage.node(), coverage.browser()] }
30
29
 
30
+ import { createHash } from "node:crypto";
31
+
31
32
  import type { ServiceConfig } from "./index.js";
32
33
  import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
33
34
 
@@ -44,26 +45,11 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
44
45
  * to the coverage dir: `.ctl-<pid>`. */
45
46
  export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
46
47
 
47
- /** Where golden keeps the conversion tools (`c8` and its dependencies),
48
- * seen from the VM. `SPECTEST_COVERAGE_TOOLS_DIR` overrides it (tests).
49
- * The daemon bind-mounts it read-only into every covered container at
50
- * {@link NODE_COVERAGE_TOOLS_CONTAINER_DIR} when it exists. */
51
- export const NODE_COVERAGE_TOOLS_DIR = "/opt/spectest/coverage-tools";
52
-
53
- /** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
54
- export const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
55
-
56
- /** The `c8` entry point, relative to the tools dir. */
57
- export const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
58
-
59
- /** The hidden subdirectory of the coverage dir where `node()` parks the
60
- * V8 dumps it converts at one capture. Hidden, so the harness never
61
- * ships them: the lcov is the report. Emptied at every capture — the
62
- * lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
63
- export const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
64
-
65
- /** The lcov `node()` writes: the dumps of this capture, merged. */
66
- export const NODE_COVERAGE_LCOV = "node.lcov";
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";
67
53
 
68
54
  /** What `configure` learns about the service it rewrites. */
69
55
  export interface CoverageConfigureInfo {
@@ -104,6 +90,9 @@ export interface CoverageCaptureContext {
104
90
  exec(command: string): Promise<{ stdout: string; stderr: string }>;
105
91
  /** Write one report file (name relative to the directory). */
106
92
  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>;
107
96
  /** Aborts when the per-service capture budget runs out. */
108
97
  signal: AbortSignal;
109
98
  }
@@ -296,23 +285,26 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
296
285
  * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
297
286
  * `tsx`). Nothing for the app to write.
298
287
  *
299
- * At capture the dumps are **converted to lcov inside the container**
300
- * with `c8` (`c8 report`), and `node.lcov` is the one report that
301
- * ships. It holds **what ran since the previous capture**: V8 resets its
302
- * counters at every `takeCoverage()`, so a live server's dump after a
303
- * test is that test's own execution — the boot dump (everything loaded,
304
- * which at file level is everything "ran") lands in the bring-up
305
- * capture and nowhere else. A run's total is the union over its cases
306
- * on the server; a test's set is the test's own. Merging every dump on
307
- * the branch instead was tried first and would have made a long-lived
308
- * server's per-test set the boot set, every time. The conversion runs
309
- * in the container, not in the harness, because `v8-to-istanbul` needs
310
- * the script text (for byte offsets → lines) and the source maps
311
- * (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
312
- * and only the container has them. `c8` comes from golden
313
- * ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
314
- * documents ship instead, compacted ({@link compactV8Document}), as they
315
- * did before SDK 0.60.
288
+ * What ships is the V8 documents themselves, compacted
289
+ * ({@link compactV8Document}): the app's own scripts that ran, and each
290
+ * script's source map once per branch. A map comes from Node's own
291
+ * `source-map-cache` when Node found one through the script's
292
+ * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
293
+ * without `--enable-source-maps`), else from `<script>.map` next to the
294
+ * script, read out of the container once
295
+ * ({@link resolveSourceMapFromContainer}). 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.
316
308
  */
317
309
  export function node(): CoverageAdapter {
318
310
  return {
@@ -337,114 +329,29 @@ export function node(): CoverageAdapter {
337
329
  // rather than failed: an empty report is a report. A server whose
338
330
  // hook never loaded (NODE_OPTIONS not reaching it) then shows as
339
331
  // empty reports after bring-up, which the boot log warns about.
340
- await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
341
- if (await nodeCoverageToolsAvailable()) {
342
- await convertV8ReportsToLcov(ctx);
343
- return;
344
- }
345
- await compactV8Reports(ctx.reportDir);
346
- if (!(await hasV8Reports(ctx.reportDir))) {
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
+ const t0 = performance.now();
337
+ await removeShippedV8Reports(ctx.reportDir);
338
+ const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
339
+ const t1 = performance.now();
340
+ const stats = await prepareV8Reports(ctx.reportDir, (url) => resolveSourceMapFromContainer(ctx, url));
341
+ if (stats.dumps === 0) {
347
342
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
343
+ SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
348
344
  }
345
+ const t2 = performance.now();
346
+ console.log(
347
+ `[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
348
+ `${stats.dumps} dump(s), ${stats.scripts} script(s), ${stats.bytesIn} → ${stats.bytesOut} bytes compacted in ${Math.round(t2 - t1)} ms; ` +
349
+ `${stats.mapsFromContainer} map(s) read from the container`,
350
+ );
349
351
  },
350
352
  };
351
353
  }
352
354
 
353
- /** The VM-side tools dir, after the test override. */
354
- export function nodeCoverageToolsDir(): string {
355
- return process.env.SPECTEST_COVERAGE_TOOLS_DIR || NODE_COVERAGE_TOOLS_DIR;
356
- }
357
-
358
- /** True when golden (or the override) ships `c8`. The daemon mounts the
359
- * directory into covered containers on the same check, so what the VM
360
- * has is what the container sees. */
361
- export async function nodeCoverageToolsAvailable(): Promise<boolean> {
362
- const fs = await import("node:fs/promises");
363
- const path = await import("node:path");
364
- try {
365
- await fs.access(path.join(nodeCoverageToolsDir(), NODE_COVERAGE_C8_BIN));
366
- return true;
367
- } catch {
368
- return false;
369
- }
370
- }
371
-
372
- /** The command `convertV8ReportsToLcov` runs in the container. From `/`,
373
- * so lcov paths come out relative to the root (made absolute after). */
374
- export function nodeCoverageConvertCommand(): string {
375
- const c8 = `${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN}`;
376
- const dumps = `${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR}`;
377
- const out = `${COVERAGE_CONTAINER_DIR}/.c8`;
378
- // `env -u`: the converter is a node process in a container whose env
379
- // carries NODE_V8_COVERAGE and the `--require` hook, so without this it
380
- // would load the hook and write a dump of itself at exit — a raw V8
381
- // document at the root of the dir, shipped as a report (seen on the
382
- // first run of hello-node under 0.60).
383
- return `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${c8} report --temp-directory ${dumps} --reporter=lcovonly --reports-dir ${out}`;
384
- }
385
-
386
- /**
387
- * Convert the V8 dumps written since the previous capture to one lcov,
388
- * `node.lcov`. New dumps at the root of the dir are compacted to the
389
- * app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
390
- * reads the original sources from there when they are not on disk, the
391
- * usual shape of a multi-stage image) and moved into `.v8/`, which is
392
- * emptied first; `c8 report` merges that directory. So the lcov is this
393
- * capture's delta: what the live processes ran since their last take,
394
- * plus every process that exited since. No new dump ⇒ `TN:` alone,
395
- * nothing ran. The `SF:` paths come out relative to `/` and are made
396
- * absolute, so they read as container paths like every other tool's.
397
- */
398
- export async function convertV8ReportsToLcov(ctx: CoverageCaptureContext): Promise<void> {
399
- const fs = await import("node:fs/promises");
400
- const path = await import("node:path");
401
- const dumpsDir = path.join(ctx.reportDir, NODE_COVERAGE_DUMPS_SUBDIR);
402
- await fs.rm(dumpsDir, { recursive: true, force: true });
403
- await fs.mkdir(dumpsDir, { recursive: true });
404
- await fs.chmod(dumpsDir, 0o755);
405
- let dumps = 0;
406
- for (const name of await fs.readdir(ctx.reportDir)) {
407
- if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
408
- const file = path.join(ctx.reportDir, name);
409
- if (name === NODE_COVERAGE_EMPTY_REPORT) {
410
- await fs.unlink(file).catch(() => {}); // a pre-0.60 placeholder
411
- continue;
412
- }
413
- let doc: Record<string, unknown>;
414
- try {
415
- doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
416
- } catch {
417
- continue; // a dump mid-write; the next capture takes it
418
- }
419
- if (!Array.isArray(doc.result)) continue;
420
- const target = path.join(dumpsDir, name);
421
- await fs.writeFile(`${target}.tmp`, JSON.stringify(compactV8Document(doc, { keepSourcesContent: true })));
422
- await fs.chmod(`${target}.tmp`, 0o644);
423
- await fs.rename(`${target}.tmp`, target);
424
- await fs.unlink(file);
425
- dumps++;
426
- }
427
- if (dumps === 0) {
428
- await ctx.writeReport(NODE_COVERAGE_LCOV, "TN:\n");
429
- return;
430
- }
431
- await ctx.exec(nodeCoverageConvertCommand());
432
- let lcov: string;
433
- try {
434
- lcov = await fs.readFile(path.join(ctx.reportDir, ".c8", "lcov.info"), "utf8");
435
- } catch (err) {
436
- throw new Error(`c8 wrote no lcov.info: ${err instanceof Error ? err.message : String(err)}`);
437
- }
438
- await ctx.writeReport(NODE_COVERAGE_LCOV, absoluteLcovPaths(lcov));
439
- }
440
-
441
- /** `SF:` records relative to `/` (what `c8` run from `/` writes) made
442
- * absolute. An lcov with nothing in it becomes the empty report. */
443
- export function absoluteLcovPaths(lcov: string): string {
444
- if (lcov.trim().length === 0) return "TN:\n";
445
- return lcov.replace(/^SF:(?!\/)/gm, "SF:/");
446
- }
447
-
448
355
  /**
449
356
  * Ask every node process that holds a hook socket in `dir` for a dump.
450
357
  * A socket nobody answers (its process died without unlinking — SIGKILL,
@@ -495,23 +402,40 @@ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Pr
495
402
  return live;
496
403
  }
497
404
 
498
- /** True when `dir` holds at least one `coverage-*.json` other than the
499
- * empty placeholder. */
500
- async function hasV8Reports(dir: string): Promise<boolean> {
405
+ /** Report files shipped at an earlier capture on this branch, by host
406
+ * path. Module memory: forks with the environment, so a forked child
407
+ * removes its ancestors' reports at its first capture and ships only
408
+ * its own. */
409
+ const SHIPPED_V8_REPORTS = new Set<string>();
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>();
421
+
422
+ /** Forget the branch memory (tests). */
423
+ export function resetNodeCoverageMemory(): void {
424
+ SHIPPED_V8_REPORTS.clear();
425
+ SHIPPED_SOURCE_MAPS.clear();
426
+ NO_SOURCE_MAP.clear();
427
+ }
428
+
429
+ /** Remove from `dir` the reports an earlier capture shipped. */
430
+ export async function removeShippedV8Reports(dir: string): Promise<void> {
501
431
  const fs = await import("node:fs/promises");
502
- try {
503
- return (await fs.readdir(dir)).some(
504
- (n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT,
505
- );
506
- } catch {
507
- return false;
432
+ for (const file of [...SHIPPED_V8_REPORTS]) {
433
+ if (!file.startsWith(dir + "/")) continue;
434
+ await fs.unlink(file).catch(() => {});
435
+ SHIPPED_V8_REPORTS.delete(file);
508
436
  }
509
437
  }
510
438
 
511
- /** V8 files already compacted, by path. Module memory: forks with the
512
- * environment, so a child never re-parses its ancestors' dumps. */
513
- const COMPACTED_V8_REPORTS = new Set<string>();
514
-
515
439
  /** A script is the app's own when it is a file outside node_modules —
516
440
  * and not our hook, which every dump would otherwise carry. */
517
441
  export function isAppScriptUrl(url: string): boolean {
@@ -525,25 +449,49 @@ export function isAppScriptUrl(url: string): boolean {
525
449
  );
526
450
  }
527
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
+
528
472
  /**
529
- * Compact one V8 coverage document to the app's own scripts. What Node
530
- * writes is everything the process loaded: `node:` internals, every
473
+ * Compact one V8 coverage document to what the derivation needs. What
474
+ * Node writes is everything the process loaded: `node:` internals, every
531
475
  * `node_modules` file, and — under `--enable-source-maps` — a
532
476
  * `source-map-cache` with each file's full map **and its sources**,
533
477
  * repeated in every dump. Measured on a real project: a 12–20 MiB dump
534
478
  * per capture, of which the app's own coverage was under 0.5 MiB, and a
535
479
  * suite that hit the 64 MiB cap on its third test. Kept: `file://`
536
- * scripts outside `node_modules`, the map entries of exactly those
537
- * scripts, minus `sourcesContent` (the sources are the repo) unless
538
- * `keepSourcesContent` — the lcov conversion reads original sources from
539
- * there. Still a V8 document — nothing is converted here.
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.
540
488
  */
541
489
  export function compactV8Document(
542
490
  doc: Record<string, unknown>,
543
- opts: { keepSourcesContent?: boolean } = {},
491
+ shipped?: Map<string, string>,
544
492
  ): Record<string, unknown> {
545
- const result = Array.isArray(doc.result) ? (doc.result as { url?: unknown }[]) : [];
546
- const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
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));
547
495
  const out: Record<string, unknown> = { ...doc, result: kept };
548
496
  const cache = doc["source-map-cache"];
549
497
  if (cache && typeof cache === "object") {
@@ -552,11 +500,17 @@ export function compactV8Document(
552
500
  for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
553
501
  if (!urls.has(url) || !entry || typeof entry !== "object") continue;
554
502
  const e = { ...(entry as Record<string, unknown>) };
555
- if (!opts.keepSourcesContent && e.data && typeof e.data === "object") {
503
+ if (e.data && typeof e.data === "object") {
556
504
  const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
557
505
  e.data = data;
558
506
  }
559
- slim[url] = e;
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
+ }
560
514
  }
561
515
  if (Object.keys(slim).length > 0) out["source-map-cache"] = slim;
562
516
  else delete out["source-map-cache"];
@@ -564,32 +518,221 @@ export function compactV8Document(
564
518
  return out;
565
519
  }
566
520
 
567
- /** Compact every not-yet-compacted V8 document in `dir`, in place. */
568
- export async function compactV8Reports(dir: string): Promise<void> {
521
+ /** What {@link prepareV8Reports} did, for the capture's log line. */
522
+ export interface PrepareV8Stats {
523
+ dumps: number;
524
+ scripts: number;
525
+ bytesIn: number;
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[];
538
+ }
539
+
540
+ /** Finds the source map of a script the dump carries no map for.
541
+ * `null` when there is none. */
542
+ export type SourceMapResolver = (scriptUrl: string) => Promise<SourceMapEntry | null>;
543
+
544
+ /** The length of every line of `text`, the way Node computes it for
545
+ * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
546
+ * last line included. Lengths in UTF-16 units, which is what V8's byte
547
+ * offsets count in. */
548
+ export function lineLengthsOf(text: string): number[] {
549
+ const out: number[] = [];
550
+ let len = 0;
551
+ for (let i = 0; i < text.length; i++, len++) {
552
+ const c = text.charCodeAt(i);
553
+ if (c === 0x0a || c === 0x2028 || c === 0x2029) {
554
+ out.push(len);
555
+ len = -1;
556
+ }
557
+ }
558
+ out.push(len);
559
+ return out;
560
+ }
561
+
562
+ /** The last `sourceMappingURL` comment of a script, or `null`. */
563
+ export function sourceMappingUrlOf(script: string): string | null {
564
+ const re = /\/\/[#@]\s*sourceMappingURL=(\S+)/g;
565
+ let last: string | null = null;
566
+ let m: RegExpExecArray | null;
567
+ while ((m = re.exec(script)) !== null) last = m[1]!;
568
+ return last;
569
+ }
570
+
571
+ /**
572
+ * The source map of `scriptUrl`, read out of the container: the script
573
+ * itself (for its `sourceMappingURL` and its line lengths), then the map
574
+ * — inline as a `data:` URL, a file the comment names, or, with no
575
+ * comment, `<script>.map` next to it. `null` when the script is not a
576
+ * `file://` URL, cannot be read, or the map is not there or not JSON; a
577
+ * map's `sourcesContent` is dropped.
578
+ * One read per script per branch: the caller remembers the outcome.
579
+ */
580
+ export async function resolveSourceMapFromContainer(
581
+ ctx: CoverageCaptureContext,
582
+ scriptUrl: string,
583
+ ): Promise<SourceMapEntry | null> {
584
+ if (!ctx.readContainerFile || !scriptUrl.startsWith("file://")) return null;
585
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
586
+ const path = await import("node:path");
587
+ let scriptPath: string;
588
+ try {
589
+ scriptPath = fileURLToPath(scriptUrl);
590
+ } catch {
591
+ return null;
592
+ }
593
+ const script = await ctx.readContainerFile(scriptPath);
594
+ if (!script) return null;
595
+ const text = script.toString("utf8");
596
+ // No comment: the map next to the script, by convention (`x.js.map`).
597
+ // That is the shape to build a short-lived bundled process in: Node
598
+ // caches a map it finds through a comment into every dump it writes
599
+ // under NODE_V8_COVERAGE, flag or no flag, and a bundled CLI's map is
600
+ // tens of MB per exit; a map it does not find costs nothing there and
601
+ // is read here once per branch.
602
+ const ref = sourceMappingUrlOf(text) ?? `${path.basename(scriptPath)}.map`;
603
+ let mapText: string;
604
+ let mapUrl: string | null;
605
+ if (ref.startsWith("data:")) {
606
+ const comma = ref.indexOf(",");
607
+ if (comma < 0) return null;
608
+ const head = ref.slice(5, comma);
609
+ const body = ref.slice(comma + 1);
610
+ try {
611
+ mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
612
+ } catch {
613
+ return null;
614
+ }
615
+ mapUrl = null;
616
+ } else {
617
+ let mapPath: string;
618
+ if (ref.startsWith("file://")) {
619
+ try {
620
+ mapPath = fileURLToPath(ref);
621
+ } catch {
622
+ return null;
623
+ }
624
+ } else if (/^[a-z]+:/i.test(ref)) {
625
+ return null; // an http(s) map is not in the container
626
+ } else {
627
+ mapPath = path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
628
+ }
629
+ const map = await ctx.readContainerFile(mapPath);
630
+ if (!map) return null;
631
+ mapText = map.toString("utf8");
632
+ mapUrl = pathToFileURL(mapPath).href;
633
+ }
634
+ let data: Record<string, unknown>;
635
+ try {
636
+ data = JSON.parse(mapText) as Record<string, unknown>;
637
+ } catch {
638
+ return null;
639
+ }
640
+ if (!data || typeof data !== "object" || !("mappings" in data)) return null;
641
+ const { sourcesContent: _dropped, ...slim } = data;
642
+ return { url: mapUrl, data: slim, lineLengths: lineLengthsOf(text) };
643
+ }
644
+
645
+ /** The content hash of a map entry, over its JSON with the spectest
646
+ * keys removed. */
647
+ function sourceMapEntryHash(entry: Record<string, unknown>): string {
648
+ const e = { ...entry };
649
+ delete e[SOURCE_MAP_HASH_KEY];
650
+ delete e[SOURCE_MAP_REF_KEY];
651
+ return "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
652
+ }
653
+
654
+ /**
655
+ * Give every kept script of a compacted document a `source-map-cache`
656
+ * entry it lacks: a stub when the map shipped earlier on this branch,
657
+ * else the map `resolve` finds (shipped whole, with its hash, and
658
+ * remembered), else nothing — and that outcome is remembered too, so a
659
+ * script with no map is looked up once per branch. Returns how many maps
660
+ * `resolve` supplied.
661
+ */
662
+ export async function attachSourceMaps(
663
+ doc: Record<string, unknown>,
664
+ resolve: SourceMapResolver,
665
+ shipped: Map<string, string> = SHIPPED_SOURCE_MAPS,
666
+ missing: Set<string> = NO_SOURCE_MAP,
667
+ ): Promise<number> {
668
+ const result = Array.isArray(doc.result) ? (doc.result as V8Script[]) : [];
669
+ const cache = ((doc["source-map-cache"] as Record<string, unknown> | undefined) ?? {}) as Record<string, unknown>;
670
+ let found = 0;
671
+ for (const s of result) {
672
+ const url = s.url;
673
+ if (typeof url !== "string" || url in cache) continue;
674
+ const prior = shipped.get(url);
675
+ if (prior) {
676
+ cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
677
+ continue;
678
+ }
679
+ if (missing.has(url)) continue;
680
+ const entry = await resolve(url);
681
+ if (!entry) {
682
+ missing.add(url);
683
+ continue;
684
+ }
685
+ const hash = sourceMapEntryHash(entry as unknown as Record<string, unknown>);
686
+ cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
687
+ shipped.set(url, hash);
688
+ found++;
689
+ }
690
+ if (Object.keys(cache).length > 0) doc["source-map-cache"] = cache;
691
+ return found;
692
+ }
693
+
694
+ /**
695
+ * Compact, in place, every `coverage-*.json` in `dir` that no earlier
696
+ * capture shipped, attach the maps the dump lacks through `resolve`
697
+ * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
698
+ * yet valid JSON) is left for the next capture.
699
+ */
700
+ export async function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats> {
569
701
  const fs = await import("node:fs/promises");
570
702
  const path = await import("node:path");
703
+ const stats: PrepareV8Stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
571
704
  let names: string[];
572
705
  try {
573
706
  names = await fs.readdir(dir);
574
707
  } catch {
575
- return;
708
+ return stats;
576
709
  }
577
- for (const name of names) {
578
- if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
710
+ for (const name of names.sort()) {
711
+ if (!name.startsWith("coverage-") || !name.endsWith(".json") || name === NODE_COVERAGE_EMPTY_REPORT) continue;
579
712
  const file = path.join(dir, name);
580
- if (COMPACTED_V8_REPORTS.has(file)) continue;
713
+ if (SHIPPED_V8_REPORTS.has(file)) continue;
714
+ let text: string;
581
715
  let doc: Record<string, unknown>;
582
716
  try {
583
- doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
717
+ text = await fs.readFile(file, "utf8");
718
+ doc = JSON.parse(text) as Record<string, unknown>;
584
719
  } catch {
585
720
  continue; // a dump mid-write, or not ours; the read path judges it
586
721
  }
587
722
  if (!Array.isArray(doc.result)) continue;
723
+ const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
724
+ if (resolve) stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
725
+ const out = JSON.stringify(compact);
588
726
  const tmp = path.join(dir, `.${name}.compact`);
589
- await fs.writeFile(tmp, JSON.stringify(compactV8Document(doc)));
727
+ await fs.writeFile(tmp, out);
590
728
  await fs.rename(tmp, file);
591
- COMPACTED_V8_REPORTS.add(file);
729
+ SHIPPED_V8_REPORTS.add(file);
730
+ stats.dumps++;
731
+ stats.scripts += (doc.result as unknown[]).length;
732
+ stats.bytesIn += text.length;
733
+ stats.bytesOut += out.length;
592
734
  }
735
+ return stats;
593
736
  }
594
737
 
595
738
  // ── browser ───────────────────────────────────────────────────────