@specific.dev/spectest 0.80.0 → 0.81.1

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.
@@ -53,6 +53,9 @@ export interface CoverageCaptureContext {
53
53
  }>;
54
54
  /** Write one report file (name relative to the directory). */
55
55
  writeReport(name: string, content: string): Promise<void>;
56
+ /** Read one file out of the container (`docker cp`). `null` when it
57
+ * does not exist. Absent on a context that has no container. */
58
+ readContainerFile?(containerPath: string): Promise<Buffer | null>;
56
59
  /** Aborts when the per-service capture budget runs out. */
57
60
  signal: AbortSignal;
58
61
  }
@@ -137,7 +140,16 @@ export declare const NODE_COVERAGE_HOOK: string;
137
140
  *
138
141
  * What ships is the V8 documents themselves, compacted
139
142
  * ({@link compactV8Document}): the app's own scripts that ran, and each
140
- * script's source map once per branch. Nothing is converted in the VM.
143
+ * script's source map once per branch. A map comes from Node's own
144
+ * `source-map-cache` when Node found one through the script's
145
+ * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
146
+ * without `--enable-source-maps`), else from `<script>.map` next to the
147
+ * script, read out of the container once
148
+ * ({@link resolveSourceMapsFromContainer}). The second way is the one to
149
+ * build a short-lived bundled process in: a map Node finds is serialized
150
+ * whole into every exit dump — 12 MB per invocation for a bundled CLI,
151
+ * inside the test's own time — and parsed again by the harness; a map
152
+ * with no comment costs nothing there. Nothing is converted in the VM.
141
153
  * A dump is a **delta** by construction: V8 resets its counters at every
142
154
  * `takeCoverage()`, so a live server's dump after a test is that test's
143
155
  * own execution, and the boot dump (everything loaded) lands in the
@@ -201,13 +213,69 @@ export interface PrepareV8Stats {
201
213
  scripts: number;
202
214
  bytesIn: number;
203
215
  bytesOut: number;
216
+ /** Source maps read out of the container this capture. */
217
+ mapsFromContainer: number;
204
218
  }
219
+ /** One `source-map-cache` entry in the shape Node writes: the map (its
220
+ * `sourcesContent` dropped), the generated script's line lengths, and
221
+ * the map's own URL. */
222
+ export interface SourceMapEntry {
223
+ url: string | null;
224
+ data: Record<string, unknown>;
225
+ lineLengths: number[];
226
+ }
227
+ /** Finds the source maps of the scripts a dump carries no map for, in
228
+ * one go. A script absent from the result has none. */
229
+ export type SourceMapResolver = (scriptUrls: string[]) => Promise<Map<string, SourceMapEntry>>;
230
+ /**
231
+ * The probe {@link resolveSourceMapsFromContainer} runs in the container:
232
+ * one `sh` loop over every script path, printing `<path>\t<ref>` — the
233
+ * last `sourceMappingURL` value in the file, else `<path>.map` when that
234
+ * file exists, else `-`; `!` for a script that is not there. One exec per
235
+ * capture per service, whatever the number of scripts: a Next.js server
236
+ * loads hundreds of chunk scripts with no map, and a `docker cp` per
237
+ * script (the first cut) took a capture past its budget on a busy host.
238
+ */
239
+ export declare function sourceMapProbeCommand(scriptPaths: string[]): string;
240
+ /** The length of every line of `text`, the way Node computes it for
241
+ * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
242
+ * last line included. Lengths in UTF-16 units, which is what V8's byte
243
+ * offsets count in. */
244
+ export declare function lineLengthsOf(text: string): number[];
245
+ /**
246
+ * The source maps of `scriptUrls`, read out of the container. One probe
247
+ * exec ({@link sourceMapProbeCommand}) finds, per script, the map a
248
+ * `sourceMappingURL` comment names or `<script>.map` next to it; only a
249
+ * script that has one is then read (`docker cp`, the script for its line
250
+ * lengths and the map file, or the inline `data:` map decoded). A map's
251
+ * `sourcesContent` is dropped. Scripts that are not `file://` URLs, have
252
+ * no map, or whose map is not there or not JSON are absent from the
253
+ * result. The caller remembers every outcome, so this runs once per
254
+ * script per branch.
255
+ *
256
+ * With no comment the map next to the script is the shape to build a
257
+ * short-lived bundled process in: Node caches a map it finds through a
258
+ * comment into every dump it writes under NODE_V8_COVERAGE, flag or no
259
+ * flag, and a bundled CLI's map is tens of MB per exit; a map it does not
260
+ * find costs nothing there.
261
+ */
262
+ export declare function resolveSourceMapsFromContainer(ctx: CoverageCaptureContext, scriptUrls: string[]): Promise<Map<string, SourceMapEntry>>;
263
+ /**
264
+ * Give every kept script of a compacted document a `source-map-cache`
265
+ * entry it lacks: a stub when the map shipped earlier on this branch,
266
+ * else the map `resolve` finds (asked once for all such scripts; shipped
267
+ * whole, with its hash, and remembered), else nothing — and that outcome
268
+ * is remembered too, so a script with no map is looked up once per
269
+ * branch. Returns how many maps `resolve` supplied.
270
+ */
271
+ export declare function attachSourceMaps(doc: Record<string, unknown>, resolve: SourceMapResolver, shipped?: Map<string, string>, missing?: Set<string>): Promise<number>;
205
272
  /**
206
273
  * Compact, in place, every `coverage-*.json` in `dir` that no earlier
207
- * capture shipped, and mark it shipped. A dump mid-write (not yet valid
208
- * JSON) is left for the next capture.
274
+ * capture shipped, attach the maps the dump lacks through `resolve`
275
+ * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
276
+ * yet valid JSON) is left for the next capture.
209
277
  */
210
- export declare function prepareV8Reports(dir: string): Promise<PrepareV8Stats>;
278
+ export declare function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats>;
211
279
  /**
212
280
  * Coverage for the frontend a service serves. The code runs in the guest
213
281
  * browser — spectest's own process — so no report can be written by the
package/dist/coverage.js CHANGED
@@ -184,7 +184,16 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
184
184
  *
185
185
  * What ships is the V8 documents themselves, compacted
186
186
  * ({@link compactV8Document}): the app's own scripts that ran, and each
187
- * script's source map once per branch. Nothing is converted in the VM.
187
+ * script's source map once per branch. A map comes from Node's own
188
+ * `source-map-cache` when Node found one through the script's
189
+ * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
190
+ * without `--enable-source-maps`), else from `<script>.map` next to the
191
+ * script, read out of the container once
192
+ * ({@link resolveSourceMapsFromContainer}). 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.
188
197
  * A dump is a **delta** by construction: V8 resets its counters at every
189
198
  * `takeCoverage()`, so a live server's dump after a test is that test's
190
199
  * own execution, and the boot dump (everything loaded) lands in the
@@ -225,14 +234,15 @@ export function node() {
225
234
  await removeShippedV8Reports(ctx.reportDir);
226
235
  const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
227
236
  const t1 = performance.now();
228
- const stats = await prepareV8Reports(ctx.reportDir);
237
+ const stats = await prepareV8Reports(ctx.reportDir, (urls) => resolveSourceMapsFromContainer(ctx, urls));
229
238
  if (stats.dumps === 0) {
230
239
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
231
240
  SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
232
241
  }
233
242
  const t2 = performance.now();
234
243
  console.log(`[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
235
- `${stats.dumps} dump(s), ${stats.scripts} script(s), ${stats.bytesIn} → ${stats.bytesOut} bytes compacted in ${Math.round(t2 - t1)} ms`);
244
+ `${stats.dumps} dump(s), ${stats.scripts} script(s), ${stats.bytesIn} → ${stats.bytesOut} bytes compacted in ${Math.round(t2 - t1)} ms; ` +
245
+ `${stats.mapsFromContainer} map(s) read from the container`);
236
246
  },
237
247
  };
238
248
  }
@@ -300,10 +310,15 @@ const SHIPPED_V8_REPORTS = new Set();
300
310
  * of the same script carries a stub that refers to the hash instead of
301
311
  * the map again. */
302
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();
303
317
  /** Forget the branch memory (tests). */
304
318
  export function resetNodeCoverageMemory() {
305
319
  SHIPPED_V8_REPORTS.clear();
306
320
  SHIPPED_SOURCE_MAPS.clear();
321
+ NO_SOURCE_MAP.clear();
307
322
  }
308
323
  /** Remove from `dir` the reports an earlier capture shipped. */
309
324
  export async function removeShippedV8Reports(dir) {
@@ -366,9 +381,7 @@ export function compactV8Document(doc, shipped) {
366
381
  const { sourcesContent: _dropped, ...data } = e.data;
367
382
  e.data = data;
368
383
  }
369
- delete e[SOURCE_MAP_HASH_KEY];
370
- delete e[SOURCE_MAP_REF_KEY];
371
- const hash = "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
384
+ const hash = sourceMapEntryHash(e);
372
385
  if (shipped?.get(url) === hash) {
373
386
  slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
374
387
  }
@@ -384,15 +397,223 @@ export function compactV8Document(doc, shipped) {
384
397
  }
385
398
  return out;
386
399
  }
400
+ /** Shell-quote one argument for `sh -c`. */
401
+ function shQuote(s) {
402
+ return `'${s.replace(/'/g, `'\\''`)}'`;
403
+ }
404
+ /**
405
+ * The probe {@link resolveSourceMapsFromContainer} runs in the container:
406
+ * one `sh` loop over every script path, printing `<path>\t<ref>` — the
407
+ * last `sourceMappingURL` value in the file, else `<path>.map` when that
408
+ * file exists, else `-`; `!` for a script that is not there. One exec per
409
+ * capture per service, whatever the number of scripts: a Next.js server
410
+ * loads hundreds of chunk scripts with no map, and a `docker cp` per
411
+ * script (the first cut) took a capture past its budget on a busy host.
412
+ */
413
+ export function sourceMapProbeCommand(scriptPaths) {
414
+ const script = 'for f in "$@"; do ' +
415
+ 'if [ -f "$f" ]; then ' +
416
+ "ref=$(grep -o 'sourceMappingURL=[^[:space:]]*' -- \"$f\" 2>/dev/null | tail -n 1 | cut -c18-); " +
417
+ 'if [ -n "$ref" ]; then printf \'%s\\t%s\\n\' "$f" "$ref"; ' +
418
+ 'elif [ -f "$f.map" ]; then printf \'%s\\t%s.map\\n\' "$f" "$f"; ' +
419
+ 'else printf \'%s\\t-\\n\' "$f"; fi; ' +
420
+ 'else printf \'%s\\t!\\n\' "$f"; fi; ' +
421
+ "done";
422
+ return `sh -c ${shQuote(script)} sh ${scriptPaths.map(shQuote).join(" ")}`;
423
+ }
424
+ /** The length of every line of `text`, the way Node computes it for
425
+ * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
426
+ * last line included. Lengths in UTF-16 units, which is what V8's byte
427
+ * offsets count in. */
428
+ export function lineLengthsOf(text) {
429
+ const out = [];
430
+ let len = 0;
431
+ for (let i = 0; i < text.length; i++, len++) {
432
+ const c = text.charCodeAt(i);
433
+ if (c === 0x0a || c === 0x2028 || c === 0x2029) {
434
+ out.push(len);
435
+ len = -1;
436
+ }
437
+ }
438
+ out.push(len);
439
+ return out;
440
+ }
441
+ /**
442
+ * The source maps of `scriptUrls`, read out of the container. One probe
443
+ * exec ({@link sourceMapProbeCommand}) finds, per script, the map a
444
+ * `sourceMappingURL` comment names or `<script>.map` next to it; only a
445
+ * script that has one is then read (`docker cp`, the script for its line
446
+ * lengths and the map file, or the inline `data:` map decoded). A map's
447
+ * `sourcesContent` is dropped. Scripts that are not `file://` URLs, have
448
+ * no map, or whose map is not there or not JSON are absent from the
449
+ * result. The caller remembers every outcome, so this runs once per
450
+ * script per branch.
451
+ *
452
+ * With no comment the map next to the script is the shape to build a
453
+ * short-lived bundled process in: Node caches a map it finds through a
454
+ * comment into every dump it writes under NODE_V8_COVERAGE, flag or no
455
+ * flag, and a bundled CLI's map is tens of MB per exit; a map it does not
456
+ * find costs nothing there.
457
+ */
458
+ export async function resolveSourceMapsFromContainer(ctx, scriptUrls) {
459
+ const out = new Map();
460
+ if (!ctx.readContainerFile)
461
+ return out;
462
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
463
+ const path = await import("node:path");
464
+ const byPath = new Map();
465
+ for (const url of scriptUrls) {
466
+ if (!url.startsWith("file://"))
467
+ continue;
468
+ try {
469
+ byPath.set(fileURLToPath(url), url);
470
+ }
471
+ catch {
472
+ // not a path
473
+ }
474
+ }
475
+ if (byPath.size === 0)
476
+ return out;
477
+ let probe;
478
+ try {
479
+ probe = (await ctx.exec(sourceMapProbeCommand([...byPath.keys()]))).stdout;
480
+ }
481
+ catch {
482
+ return out; // no shell, no grep: no maps from this container
483
+ }
484
+ for (const line of probe.split("\n")) {
485
+ const tab = line.indexOf("\t");
486
+ if (tab < 0)
487
+ continue;
488
+ const scriptPath = line.slice(0, tab);
489
+ const ref = line.slice(tab + 1).trim();
490
+ const url = byPath.get(scriptPath);
491
+ if (!url || ref === "-" || ref === "!" || ref === "")
492
+ continue;
493
+ let mapText;
494
+ let mapUrl;
495
+ if (ref.startsWith("data:")) {
496
+ const comma = ref.indexOf(",");
497
+ if (comma < 0)
498
+ continue;
499
+ const head = ref.slice(5, comma);
500
+ const body = ref.slice(comma + 1);
501
+ try {
502
+ mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
503
+ }
504
+ catch {
505
+ continue;
506
+ }
507
+ mapUrl = null;
508
+ }
509
+ else {
510
+ let mapPath;
511
+ if (ref.startsWith("file://")) {
512
+ try {
513
+ mapPath = fileURLToPath(ref);
514
+ }
515
+ catch {
516
+ continue;
517
+ }
518
+ }
519
+ else if (/^[a-z]+:/i.test(ref)) {
520
+ continue; // an http(s) map is not in the container
521
+ }
522
+ else {
523
+ let rel = ref;
524
+ try {
525
+ rel = decodeURIComponent(ref);
526
+ }
527
+ catch {
528
+ // keep as written
529
+ }
530
+ mapPath = path.resolve(path.dirname(scriptPath), rel);
531
+ }
532
+ const map = await ctx.readContainerFile(mapPath);
533
+ if (!map)
534
+ continue;
535
+ mapText = map.toString("utf8");
536
+ mapUrl = pathToFileURL(mapPath).href;
537
+ }
538
+ let data;
539
+ try {
540
+ data = JSON.parse(mapText);
541
+ }
542
+ catch {
543
+ continue;
544
+ }
545
+ if (!data || typeof data !== "object" || !("mappings" in data))
546
+ continue;
547
+ const script = await ctx.readContainerFile(scriptPath);
548
+ if (!script)
549
+ continue;
550
+ const { sourcesContent: _dropped, ...slim } = data;
551
+ out.set(url, { url: mapUrl, data: slim, lineLengths: lineLengthsOf(script.toString("utf8")) });
552
+ }
553
+ return out;
554
+ }
555
+ /** The content hash of a map entry, over its JSON with the spectest
556
+ * keys removed. */
557
+ function sourceMapEntryHash(entry) {
558
+ const e = { ...entry };
559
+ delete e[SOURCE_MAP_HASH_KEY];
560
+ delete e[SOURCE_MAP_REF_KEY];
561
+ return "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
562
+ }
563
+ /**
564
+ * Give every kept script of a compacted document a `source-map-cache`
565
+ * entry it lacks: a stub when the map shipped earlier on this branch,
566
+ * else the map `resolve` finds (asked once for all such scripts; shipped
567
+ * whole, with its hash, and remembered), else nothing — and that outcome
568
+ * is remembered too, so a script with no map is looked up once per
569
+ * branch. Returns how many maps `resolve` supplied.
570
+ */
571
+ export async function attachSourceMaps(doc, resolve, shipped = SHIPPED_SOURCE_MAPS, missing = NO_SOURCE_MAP) {
572
+ const result = Array.isArray(doc.result) ? doc.result : [];
573
+ const cache = (doc["source-map-cache"] ?? {});
574
+ const unknown = [];
575
+ for (const s of result) {
576
+ const url = s.url;
577
+ if (typeof url !== "string" || url in cache)
578
+ continue;
579
+ const prior = shipped.get(url);
580
+ if (prior) {
581
+ cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
582
+ continue;
583
+ }
584
+ if (missing.has(url) || unknown.includes(url))
585
+ continue;
586
+ unknown.push(url);
587
+ }
588
+ let found = 0;
589
+ if (unknown.length > 0) {
590
+ const entries = await resolve(unknown);
591
+ for (const url of unknown) {
592
+ const entry = entries.get(url);
593
+ if (!entry) {
594
+ missing.add(url);
595
+ continue;
596
+ }
597
+ const hash = sourceMapEntryHash(entry);
598
+ cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
599
+ shipped.set(url, hash);
600
+ found++;
601
+ }
602
+ }
603
+ if (Object.keys(cache).length > 0)
604
+ doc["source-map-cache"] = cache;
605
+ return found;
606
+ }
387
607
  /**
388
608
  * Compact, in place, every `coverage-*.json` in `dir` that no earlier
389
- * capture shipped, and mark it shipped. A dump mid-write (not yet valid
390
- * JSON) is left for the next capture.
609
+ * capture shipped, attach the maps the dump lacks through `resolve`
610
+ * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
611
+ * yet valid JSON) is left for the next capture.
391
612
  */
392
- export async function prepareV8Reports(dir) {
613
+ export async function prepareV8Reports(dir, resolve) {
393
614
  const fs = await import("node:fs/promises");
394
615
  const path = await import("node:path");
395
- const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0 };
616
+ const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
396
617
  let names;
397
618
  try {
398
619
  names = await fs.readdir(dir);
@@ -417,7 +638,10 @@ export async function prepareV8Reports(dir) {
417
638
  }
418
639
  if (!Array.isArray(doc.result))
419
640
  continue;
420
- const out = JSON.stringify(compactV8Document(doc, SHIPPED_SOURCE_MAPS));
641
+ const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
642
+ if (resolve)
643
+ stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
644
+ const out = JSON.stringify(compact);
421
645
  const tmp = path.join(dir, `.${name}.compact`);
422
646
  await fs.writeFile(tmp, out);
423
647
  await fs.rename(tmp, file);
package/dist/daemon.js CHANGED
@@ -40,7 +40,7 @@ import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta
40
40
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
41
41
  import { pollUntilReady } from "./harness/ready-poll.js";
42
42
  import { runWrapperRules } from "./harness/wrapper-rules.js";
43
- import { cpus } from "node:os";
43
+ import { cpus, tmpdir } from "node:os";
44
44
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
45
45
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
46
46
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
@@ -3392,6 +3392,24 @@ function coverageCaptureContext(svc, signal, written) {
3392
3392
  await fs.rename(tmp, file);
3393
3393
  written.add(path.relative(reportDir, file));
3394
3394
  },
3395
+ async readContainerFile(containerPath) {
3396
+ // `docker cp` rather than `exec cat`: a source map can be tens of
3397
+ // MB, and the exec path buffers stdout as a string. The copy lands
3398
+ // in the VM's tmp, never in the coverage directory (it would ship).
3399
+ const tmp = path.join(tmpdir(), `spectest-cov-${svc.name}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
3400
+ try {
3401
+ const r = await docker(["cp", `${svc.name}:${containerPath}`, tmp], COVERAGE_CAPTURE_TIMEOUT_MS);
3402
+ if (r.code !== 0)
3403
+ return null;
3404
+ return await fs.readFile(tmp);
3405
+ }
3406
+ catch {
3407
+ return null;
3408
+ }
3409
+ finally {
3410
+ await fs.rm(tmp, { force: true, recursive: true }).catch(() => { });
3411
+ }
3412
+ },
3395
3413
  };
3396
3414
  }
3397
3415
  /** Run one service's adapters in order under one budget. Returns the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.80.0",
3
+ "version": "0.81.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -12,6 +12,10 @@ import {
12
12
  SOURCE_MAP_REF_KEY,
13
13
  applyCoverageAdapters,
14
14
  appendEnvFlag,
15
+ attachSourceMaps,
16
+ lineLengthsOf,
17
+ resolveSourceMapsFromContainer,
18
+ sourceMapProbeCommand,
15
19
  browser,
16
20
  command,
17
21
  coverageReportsMode,
@@ -187,15 +191,125 @@ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCa
187
191
  service: "api",
188
192
  reportDir: dir,
189
193
  signal,
190
- async exec() {
191
- throw new Error("no container");
194
+ // The probe runs through a real shell; the "container" paths are
195
+ // this directory's own.
196
+ async exec(command: string) {
197
+ return new Promise((resolve, reject) => {
198
+ const p = spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
199
+ let stdout = "";
200
+ let stderr = "";
201
+ p.stdout!.on("data", (b: Buffer) => (stdout += b));
202
+ p.stderr!.on("data", (b: Buffer) => (stderr += b));
203
+ p.on("exit", (code) => (code === 0 ? resolve({ stdout, stderr }) : reject(new Error(stderr || `exit ${code}`))));
204
+ });
192
205
  },
193
206
  async writeReport(name, content) {
194
207
  await fs.writeFile(path.join(dir, name), content);
195
208
  },
209
+ async readContainerFile(p) {
210
+ return fs.readFile(p).catch(() => null);
211
+ },
196
212
  };
197
213
  }
198
214
 
215
+ describe("source maps by reference", () => {
216
+ test("lineLengthsOf matches Node's shape: \\n splits, \\r stays, last line counted", () => {
217
+ expect(lineLengthsOf("ab\ncde\r\n\nf")).toEqual([2, 4, 0, 1]);
218
+ expect(lineLengthsOf("")).toEqual([0]);
219
+ expect(lineLengthsOf("x\n")).toEqual([1, 0]);
220
+ });
221
+ test("the probe: one exec, one line per script — the last comment's ref, the .map next to it, or none", async () => {
222
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covprobe-"));
223
+ await fs.writeFile(path.join(dir, "a.js"), "x\n//# sourceMappingURL=old.map\ny\n//# sourceMappingURL=maps/a.map\n");
224
+ await fs.writeFile(path.join(dir, "b.js"), "x();\n");
225
+ await fs.writeFile(path.join(dir, "b.js.map"), "{}");
226
+ await fs.writeFile(path.join(dir, "it's.js"), "x();\n");
227
+ const r = await fakeCtx(dir).exec(
228
+ sourceMapProbeCommand([path.join(dir, "a.js"), path.join(dir, "b.js"), path.join(dir, "it's.js"), path.join(dir, "gone.js")]),
229
+ );
230
+ expect(r.stdout).toBe(
231
+ `${path.join(dir, "a.js")}\tmaps/a.map\n` +
232
+ `${path.join(dir, "b.js")}\t${path.join(dir, "b.js.map")}\n` +
233
+ `${path.join(dir, "it's.js")}\t-\n` +
234
+ `${path.join(dir, "gone.js")}\t!\n`,
235
+ );
236
+ });
237
+ test("resolves the map a comment names, drops sourcesContent, computes line lengths", async () => {
238
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
239
+ await fs.mkdir(path.join(dir, "maps"));
240
+ await fs.writeFile(path.join(dir, "cli.js"), "const a = 1;\nconsole.log(a);\n//# sourceMappingURL=maps/cli.map\n");
241
+ await fs.writeFile(
242
+ path.join(dir, "maps", "cli.map"),
243
+ JSON.stringify({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [], sourcesContent: ["x"] }),
244
+ );
245
+ const url = `file://${path.join(dir, "cli.js")}`;
246
+ const out = await resolveSourceMapsFromContainer(fakeCtx(dir), [url, "node:fs"]);
247
+ expect([...out.keys()]).toEqual([url]);
248
+ const entry = out.get(url)!;
249
+ expect(entry.url).toBe(`file://${path.join(dir, "maps", "cli.map")}`);
250
+ expect(entry.data).toEqual({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [] });
251
+ expect(entry.lineLengths).toEqual([12, 15, 33, 0]);
252
+ });
253
+ test("with no comment, <script>.map next to the script is the map; inline data: maps decode; the rest is absent", async () => {
254
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
255
+ await fs.writeFile(path.join(dir, "cli.js"), "x();\n");
256
+ await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA" }));
257
+ const map = JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
258
+ await fs.writeFile(
259
+ path.join(dir, "inline.js"),
260
+ `x();\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(map).toString("base64")}\n`,
261
+ );
262
+ await fs.writeFile(path.join(dir, "bare.js"), "x();\n");
263
+ await fs.writeFile(path.join(dir, "gone.js"), "x();\n//# sourceMappingURL=gone.js.map\n");
264
+ await fs.writeFile(path.join(dir, "http.js"), "x();\n//# sourceMappingURL=https://cdn.test/x.map\n");
265
+ await fs.writeFile(path.join(dir, "notjson.js"), "x();\n");
266
+ await fs.writeFile(path.join(dir, "notjson.js.map"), "nope");
267
+ const u = (n: string) => `file://${path.join(dir, n)}`;
268
+ const out = await resolveSourceMapsFromContainer(fakeCtx(dir), [
269
+ u("cli.js"), u("inline.js"), u("bare.js"), u("gone.js"), u("http.js"), u("notjson.js"), u("missing.js"),
270
+ ]);
271
+ expect([...out.keys()].sort()).toEqual([u("cli.js"), u("inline.js")].sort());
272
+ expect(out.get(u("cli.js"))!.url).toBe(`file://${path.join(dir, "cli.js.map")}`);
273
+ expect(out.get(u("cli.js"))!.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA" });
274
+ expect(out.get(u("inline.js"))!.url).toBeNull();
275
+ expect(out.get(u("inline.js"))!.data).toEqual({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
276
+ const noRead = { ...fakeCtx(dir), readContainerFile: undefined };
277
+ expect((await resolveSourceMapsFromContainer(noRead, [u("cli.js")])).size).toBe(0);
278
+ });
279
+ test("attachSourceMaps: one batched lookup, a map once, a stub after, a missing map looked up once", async () => {
280
+ const calls: string[][] = [];
281
+ const entry = { url: null, data: { version: 3, sources: ["a.ts"], mappings: "AAAA" }, lineLengths: [4] };
282
+ const resolve = async (urls: string[]) => {
283
+ calls.push(urls);
284
+ return new Map(urls.filter((u) => u.endsWith("/a.js")).map((u) => [u, entry]));
285
+ };
286
+ const shipped = new Map<string, string>();
287
+ const missing = new Set<string>();
288
+ const dump = () => ({
289
+ result: [
290
+ { url: "file:///app/a.js", ...ran },
291
+ { url: "file:///app/b.js", ...ran },
292
+ ],
293
+ });
294
+ const first = dump() as Record<string, unknown>;
295
+ expect(await attachSourceMaps(first, resolve, shipped, missing)).toBe(1);
296
+ const c1 = first["source-map-cache"] as Record<string, Record<string, unknown>>;
297
+ expect(c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
298
+ expect(c1["file:///app/a.js"].lineLengths).toEqual([4]);
299
+ expect("file:///app/b.js" in c1).toBe(false);
300
+ expect(calls).toEqual([["file:///app/a.js", "file:///app/b.js"]]);
301
+ const second = dump() as Record<string, unknown>;
302
+ expect(await attachSourceMaps(second, resolve, shipped, missing)).toBe(0);
303
+ const c2 = second["source-map-cache"] as Record<string, Record<string, unknown>>;
304
+ expect(c2["file:///app/a.js"]).toEqual({ [SOURCE_MAP_REF_KEY]: c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY] });
305
+ expect(calls.length).toBe(1); // b.js is not asked again
306
+ // A dump that carries its own entry is left alone.
307
+ const own = { ...dump(), "source-map-cache": { "file:///app/b.js": { data: { mappings: "" }, lineLengths: [1] } } } as Record<string, unknown>;
308
+ expect(await attachSourceMaps(own, resolve, shipped, missing)).toBe(0);
309
+ expect(calls.length).toBe(1);
310
+ });
311
+ });
312
+
199
313
  describe("browserCoverage", () => {
200
314
  test("writes an empty lcov when nothing ran for the service", async () => {
201
315
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
@@ -391,6 +505,44 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
391
505
  }
392
506
  });
393
507
 
508
+ test("a CLI built with an external map and no comment gets its map from the file next to the script, once per branch", async () => {
509
+ // Node caches a map it finds through a sourceMappingURL comment into
510
+ // every dump under NODE_V8_COVERAGE, flag or no flag (checked: the
511
+ // entry then carries Node's resolved `sources` and `sourceRoot`).
512
+ // Without the comment the dump has no map and the adapter reads
513
+ // `cli.js.map` from the container.
514
+ resetNodeCoverageMemory();
515
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
516
+ const hook = path.join(dir, "coverage-hook.cjs");
517
+ await fs.writeFile(hook, NODE_COVERAGE_HOOK);
518
+ await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nused();\n");
519
+ await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA", sourcesContent: ["src"] }));
520
+ const url = `file://${path.join(dir, "cli.js")}`;
521
+ for (let i = 0; i < 2; i++) {
522
+ const cli = spawn("node", [path.join(dir, "cli.js")], {
523
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
524
+ stdio: "ignore",
525
+ });
526
+ expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
527
+ await node().capture!(fakeCtx(dir));
528
+ const [name] = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
529
+ const doc = JSON.parse(await fs.readFile(path.join(dir, name!), "utf8")) as {
530
+ result: { url: string }[];
531
+ "source-map-cache": Record<string, Record<string, unknown>>;
532
+ };
533
+ expect(doc.result.map((x) => x.url)).toEqual([url]);
534
+ const entry = doc["source-map-cache"][url]!;
535
+ if (i === 0) {
536
+ expect(entry[SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
537
+ expect(entry.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA" });
538
+ expect(entry.lineLengths).toEqual([28, 7, 0]);
539
+ } else {
540
+ expect(Object.keys(entry)).toEqual([SOURCE_MAP_REF_KEY]);
541
+ }
542
+ }
543
+ resetNodeCoverageMemory();
544
+ });
545
+
394
546
  test("no process and no dump: an empty report is written, once", async () => {
395
547
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
396
548
  await node().capture!(fakeCtx(dir));
package/src/coverage.ts CHANGED
@@ -90,6 +90,9 @@ export interface CoverageCaptureContext {
90
90
  exec(command: string): Promise<{ stdout: string; stderr: string }>;
91
91
  /** Write one report file (name relative to the directory). */
92
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>;
93
96
  /** Aborts when the per-service capture budget runs out. */
94
97
  signal: AbortSignal;
95
98
  }
@@ -284,7 +287,16 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
284
287
  *
285
288
  * What ships is the V8 documents themselves, compacted
286
289
  * ({@link compactV8Document}): the app's own scripts that ran, and each
287
- * script's source map once per branch. Nothing is converted in the VM.
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 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.
288
300
  * A dump is a **delta** by construction: V8 resets its counters at every
289
301
  * `takeCoverage()`, so a live server's dump after a test is that test's
290
302
  * own execution, and the boot dump (everything loaded) lands in the
@@ -325,7 +337,7 @@ export function node(): CoverageAdapter {
325
337
  await removeShippedV8Reports(ctx.reportDir);
326
338
  const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
327
339
  const t1 = performance.now();
328
- const stats = await prepareV8Reports(ctx.reportDir);
340
+ const stats = await prepareV8Reports(ctx.reportDir, (urls) => resolveSourceMapsFromContainer(ctx, urls));
329
341
  if (stats.dumps === 0) {
330
342
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
331
343
  SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
@@ -333,7 +345,8 @@ export function node(): CoverageAdapter {
333
345
  const t2 = performance.now();
334
346
  console.log(
335
347
  `[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
336
- `${stats.dumps} dump(s), ${stats.scripts} script(s), ${stats.bytesIn} → ${stats.bytesOut} bytes compacted in ${Math.round(t2 - t1)} 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`,
337
350
  );
338
351
  },
339
352
  };
@@ -401,10 +414,16 @@ const SHIPPED_V8_REPORTS = new Set<string>();
401
414
  * the map again. */
402
415
  const SHIPPED_SOURCE_MAPS = new Map<string, string>();
403
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
+
404
422
  /** Forget the branch memory (tests). */
405
423
  export function resetNodeCoverageMemory(): void {
406
424
  SHIPPED_V8_REPORTS.clear();
407
425
  SHIPPED_SOURCE_MAPS.clear();
426
+ NO_SOURCE_MAP.clear();
408
427
  }
409
428
 
410
429
  /** Remove from `dir` the reports an earlier capture shipped. */
@@ -485,9 +504,7 @@ export function compactV8Document(
485
504
  const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
486
505
  e.data = data;
487
506
  }
488
- delete e[SOURCE_MAP_HASH_KEY];
489
- delete e[SOURCE_MAP_REF_KEY];
490
- const hash = "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
507
+ const hash = sourceMapEntryHash(e);
491
508
  if (shipped?.get(url) === hash) {
492
509
  slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
493
510
  } else {
@@ -507,17 +524,234 @@ export interface PrepareV8Stats {
507
524
  scripts: number;
508
525
  bytesIn: number;
509
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 maps of the scripts a dump carries no map for, in
541
+ * one go. A script absent from the result has none. */
542
+ export type SourceMapResolver = (scriptUrls: string[]) => Promise<Map<string, SourceMapEntry>>;
543
+
544
+ /** Shell-quote one argument for `sh -c`. */
545
+ function shQuote(s: string): string {
546
+ return `'${s.replace(/'/g, `'\\''`)}'`;
547
+ }
548
+
549
+ /**
550
+ * The probe {@link resolveSourceMapsFromContainer} runs in the container:
551
+ * one `sh` loop over every script path, printing `<path>\t<ref>` — the
552
+ * last `sourceMappingURL` value in the file, else `<path>.map` when that
553
+ * file exists, else `-`; `!` for a script that is not there. One exec per
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.
557
+ */
558
+ export function sourceMapProbeCommand(scriptPaths: string[]): string {
559
+ const script =
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");
613
+ const path = await import("node:path");
614
+ const byPath = new Map<string, string>();
615
+ for (const url of scriptUrls) {
616
+ if (!url.startsWith("file://")) continue;
617
+ try {
618
+ byPath.set(fileURLToPath(url), url);
619
+ } catch {
620
+ // not a path
621
+ }
622
+ }
623
+ if (byPath.size === 0) return out;
624
+ let probe: string;
625
+ try {
626
+ probe = (await ctx.exec(sourceMapProbeCommand([...byPath.keys()]))).stdout;
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);
644
+ try {
645
+ mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
646
+ } catch {
647
+ continue;
648
+ }
649
+ mapUrl = null;
650
+ } else {
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++;
739
+ }
740
+ }
741
+ if (Object.keys(cache).length > 0) doc["source-map-cache"] = cache;
742
+ return found;
510
743
  }
511
744
 
512
745
  /**
513
746
  * Compact, in place, every `coverage-*.json` in `dir` that no earlier
514
- * capture shipped, and mark it shipped. A dump mid-write (not yet valid
515
- * JSON) is left for the next capture.
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.
516
750
  */
517
- export async function prepareV8Reports(dir: string): Promise<PrepareV8Stats> {
751
+ export async function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats> {
518
752
  const fs = await import("node:fs/promises");
519
753
  const path = await import("node:path");
520
- const stats: PrepareV8Stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0 };
754
+ const stats: PrepareV8Stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
521
755
  let names: string[];
522
756
  try {
523
757
  names = await fs.readdir(dir);
@@ -537,7 +771,9 @@ export async function prepareV8Reports(dir: string): Promise<PrepareV8Stats> {
537
771
  continue; // a dump mid-write, or not ours; the read path judges it
538
772
  }
539
773
  if (!Array.isArray(doc.result)) continue;
540
- const out = JSON.stringify(compactV8Document(doc, SHIPPED_SOURCE_MAPS));
774
+ const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
775
+ if (resolve) stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
776
+ const out = JSON.stringify(compact);
541
777
  const tmp = path.join(dir, `.${name}.compact`);
542
778
  await fs.writeFile(tmp, out);
543
779
  await fs.rename(tmp, file);
package/src/daemon.ts CHANGED
@@ -90,7 +90,7 @@ import {
90
90
  import { pollUntilReady } from "./harness/ready-poll.js";
91
91
  import { runWrapperRules } from "./harness/wrapper-rules.js";
92
92
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
93
- import { cpus } from "node:os";
93
+ import { cpus, tmpdir } from "node:os";
94
94
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
95
95
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
96
96
  import {
@@ -4052,6 +4052,21 @@ function coverageCaptureContext(
4052
4052
  await fs.rename(tmp, file);
4053
4053
  written.add(path.relative(reportDir, file));
4054
4054
  },
4055
+ async readContainerFile(containerPath: string) {
4056
+ // `docker cp` rather than `exec cat`: a source map can be tens of
4057
+ // MB, and the exec path buffers stdout as a string. The copy lands
4058
+ // in the VM's tmp, never in the coverage directory (it would ship).
4059
+ const tmp = path.join(tmpdir(), `spectest-cov-${svc.name}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
4060
+ try {
4061
+ const r = await docker(["cp", `${svc.name}:${containerPath}`, tmp], COVERAGE_CAPTURE_TIMEOUT_MS);
4062
+ if (r.code !== 0) return null;
4063
+ return await fs.readFile(tmp);
4064
+ } catch {
4065
+ return null;
4066
+ } finally {
4067
+ await fs.rm(tmp, { force: true, recursive: true }).catch(() => {});
4068
+ }
4069
+ },
4055
4070
  };
4056
4071
  }
4057
4072