@specific.dev/spectest 0.81.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.
@@ -145,7 +145,7 @@ export declare const NODE_COVERAGE_HOOK: string;
145
145
  * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
146
146
  * without `--enable-source-maps`), else from `<script>.map` next to the
147
147
  * script, read out of the container once
148
- * ({@link resolveSourceMapFromContainer}). The second way is the one to
148
+ * ({@link resolveSourceMapsFromContainer}). The second way is the one to
149
149
  * build a short-lived bundled process in: a map Node finds is serialized
150
150
  * whole into every exit dump — 12 MB per invocation for a bundled CLI,
151
151
  * inside the test's own time — and parsed again by the harness; a map
@@ -224,33 +224,49 @@ export interface SourceMapEntry {
224
224
  data: Record<string, unknown>;
225
225
  lineLengths: number[];
226
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>;
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;
230
240
  /** The length of every line of `text`, the way Node computes it for
231
241
  * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
232
242
  * last line included. Lengths in UTF-16 units, which is what V8's byte
233
243
  * offsets count in. */
234
244
  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
245
  /**
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.
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.
245
261
  */
246
- export declare function resolveSourceMapFromContainer(ctx: CoverageCaptureContext, scriptUrl: string): Promise<SourceMapEntry | null>;
262
+ export declare function resolveSourceMapsFromContainer(ctx: CoverageCaptureContext, scriptUrls: string[]): Promise<Map<string, SourceMapEntry>>;
247
263
  /**
248
264
  * Give every kept script of a compacted document a `source-map-cache`
249
265
  * 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.
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.
254
270
  */
255
271
  export declare function attachSourceMaps(doc: Record<string, unknown>, resolve: SourceMapResolver, shipped?: Map<string, string>, missing?: Set<string>): Promise<number>;
256
272
  /**
package/dist/coverage.js CHANGED
@@ -189,7 +189,7 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
189
189
  * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
190
190
  * without `--enable-source-maps`), else from `<script>.map` next to the
191
191
  * script, read out of the container once
192
- * ({@link resolveSourceMapFromContainer}). The second way is the one to
192
+ * ({@link resolveSourceMapsFromContainer}). The second way is the one to
193
193
  * build a short-lived bundled process in: a map Node finds is serialized
194
194
  * whole into every exit dump — 12 MB per invocation for a bundled CLI,
195
195
  * inside the test's own time — and parsed again by the harness; a map
@@ -234,7 +234,7 @@ export function node() {
234
234
  await removeShippedV8Reports(ctx.reportDir);
235
235
  const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
236
236
  const t1 = performance.now();
237
- const stats = await prepareV8Reports(ctx.reportDir, (url) => resolveSourceMapFromContainer(ctx, url));
237
+ const stats = await prepareV8Reports(ctx.reportDir, (urls) => resolveSourceMapsFromContainer(ctx, urls));
238
238
  if (stats.dumps === 0) {
239
239
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
240
240
  SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
@@ -397,6 +397,30 @@ export function compactV8Document(doc, shipped) {
397
397
  }
398
398
  return out;
399
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
+ }
400
424
  /** The length of every line of `text`, the way Node computes it for
401
425
  * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
402
426
  * last line included. Lengths in UTF-16 units, which is what V8's byte
@@ -414,96 +438,119 @@ export function lineLengthsOf(text) {
414
438
  out.push(len);
415
439
  return out;
416
440
  }
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
441
  /**
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.
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.
434
457
  */
435
- export async function resolveSourceMapFromContainer(ctx, scriptUrl) {
436
- if (!ctx.readContainerFile || !scriptUrl.startsWith("file://"))
437
- return null;
458
+ export async function resolveSourceMapsFromContainer(ctx, scriptUrls) {
459
+ const out = new Map();
460
+ if (!ctx.readContainerFile)
461
+ return out;
438
462
  const { fileURLToPath, pathToFileURL } = await import("node:url");
439
463
  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);
464
+ const byPath = new Map();
465
+ for (const url of scriptUrls) {
466
+ if (!url.startsWith("file://"))
467
+ continue;
466
468
  try {
467
- mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
469
+ byPath.set(fileURLToPath(url), url);
468
470
  }
469
471
  catch {
470
- return null;
472
+ // not a path
471
473
  }
472
- mapUrl = null;
473
474
  }
474
- else {
475
- let mapPath;
476
- if (ref.startsWith("file://")) {
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);
477
501
  try {
478
- mapPath = fileURLToPath(ref);
502
+ mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
479
503
  }
480
504
  catch {
481
- return null;
505
+ continue;
482
506
  }
483
- }
484
- else if (/^[a-z]+:/i.test(ref)) {
485
- return null; // an http(s) map is not in the container
507
+ mapUrl = null;
486
508
  }
487
509
  else {
488
- mapPath = path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
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;
489
537
  }
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;
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")) });
502
552
  }
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) };
553
+ return out;
507
554
  }
508
555
  /** The content hash of a map entry, over its JSON with the spectest
509
556
  * keys removed. */
@@ -516,15 +563,15 @@ function sourceMapEntryHash(entry) {
516
563
  /**
517
564
  * Give every kept script of a compacted document a `source-map-cache`
518
565
  * 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.
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.
523
570
  */
524
571
  export async function attachSourceMaps(doc, resolve, shipped = SHIPPED_SOURCE_MAPS, missing = NO_SOURCE_MAP) {
525
572
  const result = Array.isArray(doc.result) ? doc.result : [];
526
573
  const cache = (doc["source-map-cache"] ?? {});
527
- let found = 0;
574
+ const unknown = [];
528
575
  for (const s of result) {
529
576
  const url = s.url;
530
577
  if (typeof url !== "string" || url in cache)
@@ -534,17 +581,24 @@ export async function attachSourceMaps(doc, resolve, shipped = SHIPPED_SOURCE_MA
534
581
  cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
535
582
  continue;
536
583
  }
537
- if (missing.has(url))
538
- continue;
539
- const entry = await resolve(url);
540
- if (!entry) {
541
- missing.add(url);
584
+ if (missing.has(url) || unknown.includes(url))
542
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++;
543
601
  }
544
- const hash = sourceMapEntryHash(entry);
545
- cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
546
- shipped.set(url, hash);
547
- found++;
548
602
  }
549
603
  if (Object.keys(cache).length > 0)
550
604
  doc["source-map-cache"] = cache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.81.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",
@@ -14,8 +14,8 @@ import {
14
14
  appendEnvFlag,
15
15
  attachSourceMaps,
16
16
  lineLengthsOf,
17
- resolveSourceMapFromContainer,
18
- sourceMappingUrlOf,
17
+ resolveSourceMapsFromContainer,
18
+ sourceMapProbeCommand,
19
19
  browser,
20
20
  command,
21
21
  coverageReportsMode,
@@ -191,8 +191,17 @@ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCa
191
191
  service: "api",
192
192
  reportDir: dir,
193
193
  signal,
194
- async exec() {
195
- 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
+ });
196
205
  },
197
206
  async writeReport(name, content) {
198
207
  await fs.writeFile(path.join(dir, name), content);
@@ -209,10 +218,21 @@ describe("source maps by reference", () => {
209
218
  expect(lineLengthsOf("")).toEqual([0]);
210
219
  expect(lineLengthsOf("x\n")).toEqual([1, 0]);
211
220
  });
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();
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
+ );
216
236
  });
217
237
  test("resolves the map a comment names, drops sourcesContent, computes line lengths", async () => {
218
238
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
@@ -222,46 +242,46 @@ describe("source maps by reference", () => {
222
242
  path.join(dir, "maps", "cli.map"),
223
243
  JSON.stringify({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [], sourcesContent: ["x"] }),
224
244
  );
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]);
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]);
230
252
  });
231
- test("with no comment, <script>.map next to the script is the map", async () => {
253
+ test("with no comment, <script>.map next to the script is the map; inline data: maps decode; the rest is absent", async () => {
232
254
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
233
255
  await fs.writeFile(path.join(dir, "cli.js"), "x();\n");
234
256
  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
257
  const map = JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
242
258
  await fs.writeFile(
243
259
  path.join(dir, "inline.js"),
244
260
  `x();\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(map).toString("base64")}\n`,
245
261
  );
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
262
  await fs.writeFile(path.join(dir, "bare.js"), "x();\n");
250
- expect(await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "bare.js")}`)).toBeNull();
251
263
  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
264
  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();
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" });
256
276
  const noRead = { ...fakeCtx(dir), readContainerFile: undefined };
257
- expect(await resolveSourceMapFromContainer(noRead, `file://${path.join(dir, "inline.js")}`)).toBeNull();
277
+ expect((await resolveSourceMapsFromContainer(noRead, [u("cli.js")])).size).toBe(0);
258
278
  });
259
- test("attachSourceMaps: a map once, a stub after, a missing map looked up once", async () => {
260
- const calls: string[] = [];
279
+ test("attachSourceMaps: one batched lookup, a map once, a stub after, a missing map looked up once", async () => {
280
+ const calls: string[][] = [];
261
281
  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;
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]));
265
285
  };
266
286
  const shipped = new Map<string, string>();
267
287
  const missing = new Set<string>();
@@ -277,16 +297,16 @@ describe("source maps by reference", () => {
277
297
  expect(c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
278
298
  expect(c1["file:///app/a.js"].lineLengths).toEqual([4]);
279
299
  expect("file:///app/b.js" in c1).toBe(false);
280
- expect(calls).toEqual(["file:///app/a.js", "file:///app/b.js"]);
300
+ expect(calls).toEqual([["file:///app/a.js", "file:///app/b.js"]]);
281
301
  const second = dump() as Record<string, unknown>;
282
302
  expect(await attachSourceMaps(second, resolve, shipped, missing)).toBe(0);
283
303
  const c2 = second["source-map-cache"] as Record<string, Record<string, unknown>>;
284
304
  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
305
+ expect(calls.length).toBe(1); // b.js is not asked again
286
306
  // A dump that carries its own entry is left alone.
287
307
  const own = { ...dump(), "source-map-cache": { "file:///app/b.js": { data: { mappings: "" }, lineLengths: [1] } } } as Record<string, unknown>;
288
308
  expect(await attachSourceMaps(own, resolve, shipped, missing)).toBe(0);
289
- expect(calls.length).toBe(2);
309
+ expect(calls.length).toBe(1);
290
310
  });
291
311
  });
292
312
 
package/src/coverage.ts CHANGED
@@ -292,7 +292,7 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
292
292
  * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
293
293
  * without `--enable-source-maps`), else from `<script>.map` next to the
294
294
  * script, read out of the container once
295
- * ({@link resolveSourceMapFromContainer}). The second way is the one to
295
+ * ({@link resolveSourceMapsFromContainer}). The second way is the one to
296
296
  * build a short-lived bundled process in: a map Node finds is serialized
297
297
  * whole into every exit dump — 12 MB per invocation for a bundled CLI,
298
298
  * inside the test's own time — and parsed again by the harness; a map
@@ -337,7 +337,7 @@ export function node(): CoverageAdapter {
337
337
  await removeShippedV8Reports(ctx.reportDir);
338
338
  const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
339
339
  const t1 = performance.now();
340
- const stats = await prepareV8Reports(ctx.reportDir, (url) => resolveSourceMapFromContainer(ctx, url));
340
+ const stats = await prepareV8Reports(ctx.reportDir, (urls) => resolveSourceMapsFromContainer(ctx, urls));
341
341
  if (stats.dumps === 0) {
342
342
  await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
343
343
  SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
@@ -537,9 +537,36 @@ export interface SourceMapEntry {
537
537
  lineLengths: number[];
538
538
  }
539
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>;
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
+ }
543
570
 
544
571
  /** The length of every line of `text`, the way Node computes it for
545
572
  * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
@@ -559,87 +586,104 @@ export function lineLengthsOf(text: string): number[] {
559
586
  return out;
560
587
  }
561
588
 
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
589
  /**
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.
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.
579
605
  */
580
- export async function resolveSourceMapFromContainer(
606
+ export async function resolveSourceMapsFromContainer(
581
607
  ctx: CoverageCaptureContext,
582
- scriptUrl: string,
583
- ): Promise<SourceMapEntry | null> {
584
- if (!ctx.readContainerFile || !scriptUrl.startsWith("file://")) return null;
608
+ scriptUrls: string[],
609
+ ): Promise<Map<string, SourceMapEntry>> {
610
+ const out = new Map<string, SourceMapEntry>();
611
+ if (!ctx.readContainerFile) return out;
585
612
  const { fileURLToPath, pathToFileURL } = await import("node:url");
586
613
  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);
614
+ const byPath = new Map<string, string>();
615
+ for (const url of scriptUrls) {
616
+ if (!url.startsWith("file://")) continue;
610
617
  try {
611
- mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
618
+ byPath.set(fileURLToPath(url), url);
612
619
  } catch {
613
- return null;
620
+ // not a path
614
621
  }
615
- mapUrl = null;
616
- } else {
617
- let mapPath: string;
618
- if (ref.startsWith("file://")) {
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);
619
644
  try {
620
- mapPath = fileURLToPath(ref);
645
+ mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
621
646
  } catch {
622
- return null;
647
+ continue;
623
648
  }
624
- } else if (/^[a-z]+:/i.test(ref)) {
625
- return null; // an http(s) map is not in the container
649
+ mapUrl = null;
626
650
  } else {
627
- mapPath = path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
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;
628
673
  }
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;
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")) });
639
685
  }
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) };
686
+ return out;
643
687
  }
644
688
 
645
689
  /** The content hash of a map entry, over its JSON with the spectest
@@ -654,10 +698,10 @@ function sourceMapEntryHash(entry: Record<string, unknown>): string {
654
698
  /**
655
699
  * Give every kept script of a compacted document a `source-map-cache`
656
700
  * 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.
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.
661
705
  */
662
706
  export async function attachSourceMaps(
663
707
  doc: Record<string, unknown>,
@@ -667,7 +711,7 @@ export async function attachSourceMaps(
667
711
  ): Promise<number> {
668
712
  const result = Array.isArray(doc.result) ? (doc.result as V8Script[]) : [];
669
713
  const cache = ((doc["source-map-cache"] as Record<string, unknown> | undefined) ?? {}) as Record<string, unknown>;
670
- let found = 0;
714
+ const unknown: string[] = [];
671
715
  for (const s of result) {
672
716
  const url = s.url;
673
717
  if (typeof url !== "string" || url in cache) continue;
@@ -676,16 +720,23 @@ export async function attachSourceMaps(
676
720
  cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
677
721
  continue;
678
722
  }
679
- if (missing.has(url)) continue;
680
- const entry = await resolve(url);
681
- if (!entry) {
682
- missing.add(url);
683
- continue;
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++;
684
739
  }
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
740
  }
690
741
  if (Object.keys(cache).length > 0) doc["source-map-cache"] = cache;
691
742
  return found;