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