@specific.dev/spectest 0.80.0 → 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.
@@ -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 resolveSourceMapFromContainer}). 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,53 @@ 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 map of a script the dump carries no map for.
228
+ * `null` when there is none. */
229
+ export type SourceMapResolver = (scriptUrl: string) => Promise<SourceMapEntry | null>;
230
+ /** The length of every line of `text`, the way Node computes it for
231
+ * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
232
+ * last line included. Lengths in UTF-16 units, which is what V8's byte
233
+ * offsets count in. */
234
+ export declare function lineLengthsOf(text: string): number[];
235
+ /** The last `sourceMappingURL` comment of a script, or `null`. */
236
+ export declare function sourceMappingUrlOf(script: string): string | null;
237
+ /**
238
+ * The source map of `scriptUrl`, read out of the container: the script
239
+ * itself (for its `sourceMappingURL` and its line lengths), then the map
240
+ * — inline as a `data:` URL, a file the comment names, or, with no
241
+ * comment, `<script>.map` next to it. `null` when the script is not a
242
+ * `file://` URL, cannot be read, or the map is not there or not JSON; a
243
+ * map's `sourcesContent` is dropped.
244
+ * One read per script per branch: the caller remembers the outcome.
245
+ */
246
+ export declare function resolveSourceMapFromContainer(ctx: CoverageCaptureContext, scriptUrl: string): Promise<SourceMapEntry | null>;
247
+ /**
248
+ * Give every kept script of a compacted document a `source-map-cache`
249
+ * entry it lacks: a stub when the map shipped earlier on this branch,
250
+ * else the map `resolve` finds (shipped whole, with its hash, and
251
+ * remembered), else nothing — and that outcome is remembered too, so a
252
+ * script with no map is looked up once per branch. Returns how many maps
253
+ * `resolve` supplied.
254
+ */
255
+ export declare function attachSourceMaps(doc: Record<string, unknown>, resolve: SourceMapResolver, shipped?: Map<string, string>, missing?: Set<string>): Promise<number>;
205
256
  /**
206
257
  * 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.
258
+ * capture shipped, attach the maps the dump lacks through `resolve`
259
+ * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
260
+ * yet valid JSON) is left for the next capture.
209
261
  */
210
- export declare function prepareV8Reports(dir: string): Promise<PrepareV8Stats>;
262
+ export declare function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats>;
211
263
  /**
212
264
  * Coverage for the frontend a service serves. The code runs in the guest
213
265
  * 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 resolveSourceMapFromContainer}). The second way is the one to
193
+ * build a short-lived bundled process in: a map Node finds is serialized
194
+ * whole into every exit dump — 12 MB per invocation for a bundled CLI,
195
+ * inside the test's own time — and parsed again by the harness; a map
196
+ * with no comment costs nothing there. Nothing is converted in the VM.
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, (url) => resolveSourceMapFromContainer(ctx, url));
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,169 @@ export function compactV8Document(doc, shipped) {
384
397
  }
385
398
  return out;
386
399
  }
400
+ /** The length of every line of `text`, the way Node computes it for
401
+ * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
402
+ * last line included. Lengths in UTF-16 units, which is what V8's byte
403
+ * offsets count in. */
404
+ export function lineLengthsOf(text) {
405
+ const out = [];
406
+ let len = 0;
407
+ for (let i = 0; i < text.length; i++, len++) {
408
+ const c = text.charCodeAt(i);
409
+ if (c === 0x0a || c === 0x2028 || c === 0x2029) {
410
+ out.push(len);
411
+ len = -1;
412
+ }
413
+ }
414
+ out.push(len);
415
+ return out;
416
+ }
417
+ /** The last `sourceMappingURL` comment of a script, or `null`. */
418
+ export function sourceMappingUrlOf(script) {
419
+ const re = /\/\/[#@]\s*sourceMappingURL=(\S+)/g;
420
+ let last = null;
421
+ let m;
422
+ while ((m = re.exec(script)) !== null)
423
+ last = m[1];
424
+ return last;
425
+ }
426
+ /**
427
+ * The source map of `scriptUrl`, read out of the container: the script
428
+ * itself (for its `sourceMappingURL` and its line lengths), then the map
429
+ * — inline as a `data:` URL, a file the comment names, or, with no
430
+ * comment, `<script>.map` next to it. `null` when the script is not a
431
+ * `file://` URL, cannot be read, or the map is not there or not JSON; a
432
+ * map's `sourcesContent` is dropped.
433
+ * One read per script per branch: the caller remembers the outcome.
434
+ */
435
+ export async function resolveSourceMapFromContainer(ctx, scriptUrl) {
436
+ if (!ctx.readContainerFile || !scriptUrl.startsWith("file://"))
437
+ return null;
438
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
439
+ const path = await import("node:path");
440
+ let scriptPath;
441
+ try {
442
+ scriptPath = fileURLToPath(scriptUrl);
443
+ }
444
+ catch {
445
+ return null;
446
+ }
447
+ const script = await ctx.readContainerFile(scriptPath);
448
+ if (!script)
449
+ return null;
450
+ const text = script.toString("utf8");
451
+ // No comment: the map next to the script, by convention (`x.js.map`).
452
+ // That is the shape to build a short-lived bundled process in: Node
453
+ // caches a map it finds through a comment into every dump it writes
454
+ // under NODE_V8_COVERAGE, flag or no flag, and a bundled CLI's map is
455
+ // tens of MB per exit; a map it does not find costs nothing there and
456
+ // is read here once per branch.
457
+ const ref = sourceMappingUrlOf(text) ?? `${path.basename(scriptPath)}.map`;
458
+ let mapText;
459
+ let mapUrl;
460
+ if (ref.startsWith("data:")) {
461
+ const comma = ref.indexOf(",");
462
+ if (comma < 0)
463
+ return null;
464
+ const head = ref.slice(5, comma);
465
+ const body = ref.slice(comma + 1);
466
+ try {
467
+ mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
468
+ }
469
+ catch {
470
+ return null;
471
+ }
472
+ mapUrl = null;
473
+ }
474
+ else {
475
+ let mapPath;
476
+ if (ref.startsWith("file://")) {
477
+ try {
478
+ mapPath = fileURLToPath(ref);
479
+ }
480
+ catch {
481
+ return null;
482
+ }
483
+ }
484
+ else if (/^[a-z]+:/i.test(ref)) {
485
+ return null; // an http(s) map is not in the container
486
+ }
487
+ else {
488
+ mapPath = path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
489
+ }
490
+ const map = await ctx.readContainerFile(mapPath);
491
+ if (!map)
492
+ return null;
493
+ mapText = map.toString("utf8");
494
+ mapUrl = pathToFileURL(mapPath).href;
495
+ }
496
+ let data;
497
+ try {
498
+ data = JSON.parse(mapText);
499
+ }
500
+ catch {
501
+ return null;
502
+ }
503
+ if (!data || typeof data !== "object" || !("mappings" in data))
504
+ return null;
505
+ const { sourcesContent: _dropped, ...slim } = data;
506
+ return { url: mapUrl, data: slim, lineLengths: lineLengthsOf(text) };
507
+ }
508
+ /** The content hash of a map entry, over its JSON with the spectest
509
+ * keys removed. */
510
+ function sourceMapEntryHash(entry) {
511
+ const e = { ...entry };
512
+ delete e[SOURCE_MAP_HASH_KEY];
513
+ delete e[SOURCE_MAP_REF_KEY];
514
+ return "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
515
+ }
516
+ /**
517
+ * Give every kept script of a compacted document a `source-map-cache`
518
+ * entry it lacks: a stub when the map shipped earlier on this branch,
519
+ * else the map `resolve` finds (shipped whole, with its hash, and
520
+ * remembered), else nothing — and that outcome is remembered too, so a
521
+ * script with no map is looked up once per branch. Returns how many maps
522
+ * `resolve` supplied.
523
+ */
524
+ export async function attachSourceMaps(doc, resolve, shipped = SHIPPED_SOURCE_MAPS, missing = NO_SOURCE_MAP) {
525
+ const result = Array.isArray(doc.result) ? doc.result : [];
526
+ const cache = (doc["source-map-cache"] ?? {});
527
+ let found = 0;
528
+ for (const s of result) {
529
+ const url = s.url;
530
+ if (typeof url !== "string" || url in cache)
531
+ continue;
532
+ const prior = shipped.get(url);
533
+ if (prior) {
534
+ cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
535
+ continue;
536
+ }
537
+ if (missing.has(url))
538
+ continue;
539
+ const entry = await resolve(url);
540
+ if (!entry) {
541
+ missing.add(url);
542
+ continue;
543
+ }
544
+ const hash = sourceMapEntryHash(entry);
545
+ cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
546
+ shipped.set(url, hash);
547
+ found++;
548
+ }
549
+ if (Object.keys(cache).length > 0)
550
+ doc["source-map-cache"] = cache;
551
+ return found;
552
+ }
387
553
  /**
388
554
  * 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.
555
+ * capture shipped, attach the maps the dump lacks through `resolve`
556
+ * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
557
+ * yet valid JSON) is left for the next capture.
391
558
  */
392
- export async function prepareV8Reports(dir) {
559
+ export async function prepareV8Reports(dir, resolve) {
393
560
  const fs = await import("node:fs/promises");
394
561
  const path = await import("node:path");
395
- const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0 };
562
+ const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
396
563
  let names;
397
564
  try {
398
565
  names = await fs.readdir(dir);
@@ -417,7 +584,10 @@ export async function prepareV8Reports(dir) {
417
584
  }
418
585
  if (!Array.isArray(doc.result))
419
586
  continue;
420
- const out = JSON.stringify(compactV8Document(doc, SHIPPED_SOURCE_MAPS));
587
+ const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
588
+ if (resolve)
589
+ stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
590
+ const out = JSON.stringify(compact);
421
591
  const tmp = path.join(dir, `.${name}.compact`);
422
592
  await fs.writeFile(tmp, out);
423
593
  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.0",
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
+ resolveSourceMapFromContainer,
18
+ sourceMappingUrlOf,
15
19
  browser,
16
20
  command,
17
21
  coverageReportsMode,
@@ -193,9 +197,99 @@ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCa
193
197
  async writeReport(name, content) {
194
198
  await fs.writeFile(path.join(dir, name), content);
195
199
  },
200
+ async readContainerFile(p) {
201
+ return fs.readFile(p).catch(() => null);
202
+ },
196
203
  };
197
204
  }
198
205
 
206
+ describe("source maps by reference", () => {
207
+ test("lineLengthsOf matches Node's shape: \\n splits, \\r stays, last line counted", () => {
208
+ expect(lineLengthsOf("ab\ncde\r\n\nf")).toEqual([2, 4, 0, 1]);
209
+ expect(lineLengthsOf("")).toEqual([0]);
210
+ expect(lineLengthsOf("x\n")).toEqual([1, 0]);
211
+ });
212
+ test("sourceMappingUrlOf takes the last comment", () => {
213
+ expect(sourceMappingUrlOf("a\n//# sourceMappingURL=a.js.map\nb\n//# sourceMappingURL=b.js.map\n")).toBe("b.js.map");
214
+ expect(sourceMappingUrlOf("//@ sourceMappingURL=old.map")).toBe("old.map");
215
+ expect(sourceMappingUrlOf("no map here")).toBeNull();
216
+ });
217
+ test("resolves the map a comment names, drops sourcesContent, computes line lengths", async () => {
218
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
219
+ await fs.mkdir(path.join(dir, "maps"));
220
+ await fs.writeFile(path.join(dir, "cli.js"), "const a = 1;\nconsole.log(a);\n//# sourceMappingURL=maps/cli.map\n");
221
+ await fs.writeFile(
222
+ path.join(dir, "maps", "cli.map"),
223
+ JSON.stringify({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [], sourcesContent: ["x"] }),
224
+ );
225
+ const entry = await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "cli.js")}`);
226
+ expect(entry).not.toBeNull();
227
+ expect(entry!.url).toBe(`file://${path.join(dir, "maps", "cli.map")}`);
228
+ expect(entry!.data).toEqual({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [] });
229
+ expect(entry!.lineLengths).toEqual([12, 15, 33, 0]);
230
+ });
231
+ test("with no comment, <script>.map next to the script is the map", async () => {
232
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
233
+ await fs.writeFile(path.join(dir, "cli.js"), "x();\n");
234
+ await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA" }));
235
+ const entry = await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "cli.js")}`);
236
+ expect(entry!.url).toBe(`file://${path.join(dir, "cli.js.map")}`);
237
+ expect(entry!.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA" });
238
+ });
239
+ test("resolves an inline data: URL map; no map, no file, or a foreign URL is null", async () => {
240
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
241
+ const map = JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
242
+ await fs.writeFile(
243
+ path.join(dir, "inline.js"),
244
+ `x();\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(map).toString("base64")}\n`,
245
+ );
246
+ const inline = await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "inline.js")}`);
247
+ expect(inline!.url).toBeNull();
248
+ expect(inline!.data).toEqual({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
249
+ await fs.writeFile(path.join(dir, "bare.js"), "x();\n");
250
+ expect(await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "bare.js")}`)).toBeNull();
251
+ await fs.writeFile(path.join(dir, "gone.js"), "x();\n//# sourceMappingURL=gone.js.map\n");
252
+ expect(await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "gone.js")}`)).toBeNull();
253
+ await fs.writeFile(path.join(dir, "http.js"), "x();\n//# sourceMappingURL=https://cdn.test/x.map\n");
254
+ expect(await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "http.js")}`)).toBeNull();
255
+ expect(await resolveSourceMapFromContainer(fakeCtx(dir), "node:fs")).toBeNull();
256
+ const noRead = { ...fakeCtx(dir), readContainerFile: undefined };
257
+ expect(await resolveSourceMapFromContainer(noRead, `file://${path.join(dir, "inline.js")}`)).toBeNull();
258
+ });
259
+ test("attachSourceMaps: a map once, a stub after, a missing map looked up once", async () => {
260
+ const calls: string[] = [];
261
+ const entry = { url: null, data: { version: 3, sources: ["a.ts"], mappings: "AAAA" }, lineLengths: [4] };
262
+ const resolve = async (url: string) => {
263
+ calls.push(url);
264
+ return url.endsWith("/a.js") ? entry : null;
265
+ };
266
+ const shipped = new Map<string, string>();
267
+ const missing = new Set<string>();
268
+ const dump = () => ({
269
+ result: [
270
+ { url: "file:///app/a.js", ...ran },
271
+ { url: "file:///app/b.js", ...ran },
272
+ ],
273
+ });
274
+ const first = dump() as Record<string, unknown>;
275
+ expect(await attachSourceMaps(first, resolve, shipped, missing)).toBe(1);
276
+ const c1 = first["source-map-cache"] as Record<string, Record<string, unknown>>;
277
+ expect(c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
278
+ expect(c1["file:///app/a.js"].lineLengths).toEqual([4]);
279
+ expect("file:///app/b.js" in c1).toBe(false);
280
+ expect(calls).toEqual(["file:///app/a.js", "file:///app/b.js"]);
281
+ const second = dump() as Record<string, unknown>;
282
+ expect(await attachSourceMaps(second, resolve, shipped, missing)).toBe(0);
283
+ const c2 = second["source-map-cache"] as Record<string, Record<string, unknown>>;
284
+ expect(c2["file:///app/a.js"]).toEqual({ [SOURCE_MAP_REF_KEY]: c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY] });
285
+ expect(calls.length).toBe(2); // b.js is not asked again
286
+ // A dump that carries its own entry is left alone.
287
+ const own = { ...dump(), "source-map-cache": { "file:///app/b.js": { data: { mappings: "" }, lineLengths: [1] } } } as Record<string, unknown>;
288
+ expect(await attachSourceMaps(own, resolve, shipped, missing)).toBe(0);
289
+ expect(calls.length).toBe(2);
290
+ });
291
+ });
292
+
199
293
  describe("browserCoverage", () => {
200
294
  test("writes an empty lcov when nothing ran for the service", async () => {
201
295
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
@@ -391,6 +485,44 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
391
485
  }
392
486
  });
393
487
 
488
+ 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 () => {
489
+ // Node caches a map it finds through a sourceMappingURL comment into
490
+ // every dump under NODE_V8_COVERAGE, flag or no flag (checked: the
491
+ // entry then carries Node's resolved `sources` and `sourceRoot`).
492
+ // Without the comment the dump has no map and the adapter reads
493
+ // `cli.js.map` from the container.
494
+ resetNodeCoverageMemory();
495
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
496
+ const hook = path.join(dir, "coverage-hook.cjs");
497
+ await fs.writeFile(hook, NODE_COVERAGE_HOOK);
498
+ await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nused();\n");
499
+ await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA", sourcesContent: ["src"] }));
500
+ const url = `file://${path.join(dir, "cli.js")}`;
501
+ for (let i = 0; i < 2; i++) {
502
+ const cli = spawn("node", [path.join(dir, "cli.js")], {
503
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
504
+ stdio: "ignore",
505
+ });
506
+ expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
507
+ await node().capture!(fakeCtx(dir));
508
+ const [name] = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
509
+ const doc = JSON.parse(await fs.readFile(path.join(dir, name!), "utf8")) as {
510
+ result: { url: string }[];
511
+ "source-map-cache": Record<string, Record<string, unknown>>;
512
+ };
513
+ expect(doc.result.map((x) => x.url)).toEqual([url]);
514
+ const entry = doc["source-map-cache"][url]!;
515
+ if (i === 0) {
516
+ expect(entry[SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
517
+ expect(entry.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA" });
518
+ expect(entry.lineLengths).toEqual([28, 7, 0]);
519
+ } else {
520
+ expect(Object.keys(entry)).toEqual([SOURCE_MAP_REF_KEY]);
521
+ }
522
+ }
523
+ resetNodeCoverageMemory();
524
+ });
525
+
394
526
  test("no process and no dump: an empty report is written, once", async () => {
395
527
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
396
528
  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 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.
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, (url) => resolveSourceMapFromContainer(ctx, url));
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,183 @@ 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 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;
510
692
  }
511
693
 
512
694
  /**
513
695
  * 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.
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.
516
699
  */
517
- export async function prepareV8Reports(dir: string): Promise<PrepareV8Stats> {
700
+ export async function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats> {
518
701
  const fs = await import("node:fs/promises");
519
702
  const path = await import("node:path");
520
- const stats: PrepareV8Stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0 };
703
+ const stats: PrepareV8Stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
521
704
  let names: string[];
522
705
  try {
523
706
  names = await fs.readdir(dir);
@@ -537,7 +720,9 @@ export async function prepareV8Reports(dir: string): Promise<PrepareV8Stats> {
537
720
  continue; // a dump mid-write, or not ours; the read path judges it
538
721
  }
539
722
  if (!Array.isArray(doc.result)) continue;
540
- const out = JSON.stringify(compactV8Document(doc, SHIPPED_SOURCE_MAPS));
723
+ const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
724
+ if (resolve) stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
725
+ const out = JSON.stringify(compact);
541
726
  const tmp = path.join(dir, `.${name}.compact`);
542
727
  await fs.writeFile(tmp, out);
543
728
  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