@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.
- package/dist/coverage.d.ts +72 -4
- package/dist/coverage.js +235 -11
- package/dist/daemon.js +19 -1
- package/package.json +1 -1
- package/src/coverage.test.ts +154 -2
- package/src/coverage.ts +247 -11
- package/src/daemon.ts +16 -1
package/dist/coverage.d.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
208
|
-
*
|
|
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.
|
|
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
|
-
|
|
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,
|
|
390
|
-
*
|
|
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
|
|
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
package/src/coverage.test.ts
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
515
|
-
*
|
|
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
|
|
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
|
|