@specific.dev/spectest 0.79.1 → 0.80.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 +59 -69
- package/dist/coverage.js +110 -171
- package/dist/daemon.js +28 -200
- package/dist/harness/fetch.d.ts +5 -0
- package/dist/harness/fetch.js +165 -0
- package/dist/harness/instrumentation-scope.d.ts +18 -0
- package/dist/harness/instrumentation-scope.js +32 -0
- package/dist/harness/raw-fetch.js +5 -4
- package/dist/index.d.ts +5 -0
- package/dist/recorder.js +2 -1
- package/package.json +1 -1
- package/src/coverage.test.ts +69 -134
- package/src/coverage.ts +135 -177
- package/src/daemon.ts +33 -223
- package/src/harness/fetch.test.ts +253 -0
- package/src/harness/fetch.ts +174 -0
- package/src/harness/instrumentation-scope.ts +46 -0
- package/src/harness/raw-fetch.ts +5 -4
- package/src/index.ts +5 -0
- package/src/recorder.ts +2 -1
package/src/coverage.ts
CHANGED
|
@@ -14,11 +14,10 @@
|
|
|
14
14
|
//
|
|
15
15
|
// After every adapter ran, the harness reads the directory the same way
|
|
16
16
|
// it does for `coverage: true`: at least one well-formed report (lcov or
|
|
17
|
-
// V8 JSON), shipped verbatim.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// only the container has.
|
|
17
|
+
// V8 JSON), shipped verbatim. Nothing is converted in the VM: `node()`
|
|
18
|
+
// compacts its V8 dumps (the app's own scripts, each source map once per
|
|
19
|
+
// branch) and ships them as they are. The control plane derives what it
|
|
20
|
+
// needs from the stored bytes, off the test run.
|
|
22
21
|
//
|
|
23
22
|
// Imported from `@specific.dev/spectest/coverage`. The three shipped
|
|
24
23
|
// adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
|
|
@@ -28,6 +27,8 @@
|
|
|
28
27
|
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
29
28
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
30
29
|
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
|
|
31
32
|
import type { ServiceConfig } from "./index.js";
|
|
32
33
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
33
34
|
|
|
@@ -44,26 +45,11 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
|
44
45
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
45
46
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
export const
|
|
52
|
-
|
|
53
|
-
/** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
|
|
54
|
-
export const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
|
|
55
|
-
|
|
56
|
-
/** The `c8` entry point, relative to the tools dir. */
|
|
57
|
-
export const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
|
|
58
|
-
|
|
59
|
-
/** The hidden subdirectory of the coverage dir where `node()` parks the
|
|
60
|
-
* V8 dumps it converts at one capture. Hidden, so the harness never
|
|
61
|
-
* ships them: the lcov is the report. Emptied at every capture — the
|
|
62
|
-
* lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
|
|
63
|
-
export const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
|
|
64
|
-
|
|
65
|
-
/** The lcov `node()` writes: the dumps of this capture, merged. */
|
|
66
|
-
export const NODE_COVERAGE_LCOV = "node.lcov";
|
|
48
|
+
/** Key of the content hash (`sha256:<hex>`) a shipped `source-map-cache`
|
|
49
|
+
* entry carries, and key of the stub that stands in for an entry that
|
|
50
|
+
* shipped earlier on the same branch. See {@link compactV8Document}. */
|
|
51
|
+
export const SOURCE_MAP_HASH_KEY = "spectestHash";
|
|
52
|
+
export const SOURCE_MAP_REF_KEY = "spectestRef";
|
|
67
53
|
|
|
68
54
|
/** What `configure` learns about the service it rewrites. */
|
|
69
55
|
export interface CoverageConfigureInfo {
|
|
@@ -296,23 +282,17 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
|
|
|
296
282
|
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
297
283
|
* `tsx`). Nothing for the app to write.
|
|
298
284
|
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* capture and nowhere else.
|
|
306
|
-
*
|
|
307
|
-
* the
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* the script text (for byte offsets → lines) and the source maps
|
|
311
|
-
* (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
|
|
312
|
-
* and only the container has them. `c8` comes from golden
|
|
313
|
-
* ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
|
|
314
|
-
* documents ship instead, compacted ({@link compactV8Document}), as they
|
|
315
|
-
* did before SDK 0.60.
|
|
285
|
+
* What ships is the V8 documents themselves, compacted
|
|
286
|
+
* ({@link compactV8Document}): the app's own scripts that ran, and each
|
|
287
|
+
* script's source map once per branch. Nothing is converted in the VM.
|
|
288
|
+
* A dump is a **delta** by construction: V8 resets its counters at every
|
|
289
|
+
* `takeCoverage()`, so a live server's dump after a test is that test's
|
|
290
|
+
* own execution, and the boot dump (everything loaded) lands in the
|
|
291
|
+
* bring-up capture and nowhere else. The control plane derives lines
|
|
292
|
+
* from the stored documents, off the test run. Converting to lcov in
|
|
293
|
+
* the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
|
|
294
|
+
* service on a real project, inside the test run; a real project turned
|
|
295
|
+
* coverage off because of it, and the conversion went.
|
|
316
296
|
*/
|
|
317
297
|
export function node(): CoverageAdapter {
|
|
318
298
|
return {
|
|
@@ -337,114 +317,28 @@ export function node(): CoverageAdapter {
|
|
|
337
317
|
// rather than failed: an empty report is a report. A server whose
|
|
338
318
|
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
339
319
|
// empty reports after bring-up, which the boot log warns about.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
await
|
|
346
|
-
|
|
320
|
+
//
|
|
321
|
+
// Each capture ships only its own dumps: what an earlier capture on
|
|
322
|
+
// this branch shipped is removed first (module memory, forks with
|
|
323
|
+
// the environment).
|
|
324
|
+
const t0 = performance.now();
|
|
325
|
+
await removeShippedV8Reports(ctx.reportDir);
|
|
326
|
+
const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
327
|
+
const t1 = performance.now();
|
|
328
|
+
const stats = await prepareV8Reports(ctx.reportDir);
|
|
329
|
+
if (stats.dumps === 0) {
|
|
347
330
|
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
331
|
+
SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
|
|
348
332
|
}
|
|
333
|
+
const t2 = performance.now();
|
|
334
|
+
console.log(
|
|
335
|
+
`[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`,
|
|
337
|
+
);
|
|
349
338
|
},
|
|
350
339
|
};
|
|
351
340
|
}
|
|
352
341
|
|
|
353
|
-
/** The VM-side tools dir, after the test override. */
|
|
354
|
-
export function nodeCoverageToolsDir(): string {
|
|
355
|
-
return process.env.SPECTEST_COVERAGE_TOOLS_DIR || NODE_COVERAGE_TOOLS_DIR;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** True when golden (or the override) ships `c8`. The daemon mounts the
|
|
359
|
-
* directory into covered containers on the same check, so what the VM
|
|
360
|
-
* has is what the container sees. */
|
|
361
|
-
export async function nodeCoverageToolsAvailable(): Promise<boolean> {
|
|
362
|
-
const fs = await import("node:fs/promises");
|
|
363
|
-
const path = await import("node:path");
|
|
364
|
-
try {
|
|
365
|
-
await fs.access(path.join(nodeCoverageToolsDir(), NODE_COVERAGE_C8_BIN));
|
|
366
|
-
return true;
|
|
367
|
-
} catch {
|
|
368
|
-
return false;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** The command `convertV8ReportsToLcov` runs in the container. From `/`,
|
|
373
|
-
* so lcov paths come out relative to the root (made absolute after). */
|
|
374
|
-
export function nodeCoverageConvertCommand(): string {
|
|
375
|
-
const c8 = `${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN}`;
|
|
376
|
-
const dumps = `${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR}`;
|
|
377
|
-
const out = `${COVERAGE_CONTAINER_DIR}/.c8`;
|
|
378
|
-
// `env -u`: the converter is a node process in a container whose env
|
|
379
|
-
// carries NODE_V8_COVERAGE and the `--require` hook, so without this it
|
|
380
|
-
// would load the hook and write a dump of itself at exit — a raw V8
|
|
381
|
-
// document at the root of the dir, shipped as a report (seen on the
|
|
382
|
-
// first run of hello-node under 0.60).
|
|
383
|
-
return `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${c8} report --temp-directory ${dumps} --reporter=lcovonly --reports-dir ${out}`;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Convert the V8 dumps written since the previous capture to one lcov,
|
|
388
|
-
* `node.lcov`. New dumps at the root of the dir are compacted to the
|
|
389
|
-
* app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
|
|
390
|
-
* reads the original sources from there when they are not on disk, the
|
|
391
|
-
* usual shape of a multi-stage image) and moved into `.v8/`, which is
|
|
392
|
-
* emptied first; `c8 report` merges that directory. So the lcov is this
|
|
393
|
-
* capture's delta: what the live processes ran since their last take,
|
|
394
|
-
* plus every process that exited since. No new dump ⇒ `TN:` alone,
|
|
395
|
-
* nothing ran. The `SF:` paths come out relative to `/` and are made
|
|
396
|
-
* absolute, so they read as container paths like every other tool's.
|
|
397
|
-
*/
|
|
398
|
-
export async function convertV8ReportsToLcov(ctx: CoverageCaptureContext): Promise<void> {
|
|
399
|
-
const fs = await import("node:fs/promises");
|
|
400
|
-
const path = await import("node:path");
|
|
401
|
-
const dumpsDir = path.join(ctx.reportDir, NODE_COVERAGE_DUMPS_SUBDIR);
|
|
402
|
-
await fs.rm(dumpsDir, { recursive: true, force: true });
|
|
403
|
-
await fs.mkdir(dumpsDir, { recursive: true });
|
|
404
|
-
await fs.chmod(dumpsDir, 0o755);
|
|
405
|
-
let dumps = 0;
|
|
406
|
-
for (const name of await fs.readdir(ctx.reportDir)) {
|
|
407
|
-
if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
|
|
408
|
-
const file = path.join(ctx.reportDir, name);
|
|
409
|
-
if (name === NODE_COVERAGE_EMPTY_REPORT) {
|
|
410
|
-
await fs.unlink(file).catch(() => {}); // a pre-0.60 placeholder
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
let doc: Record<string, unknown>;
|
|
414
|
-
try {
|
|
415
|
-
doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
|
|
416
|
-
} catch {
|
|
417
|
-
continue; // a dump mid-write; the next capture takes it
|
|
418
|
-
}
|
|
419
|
-
if (!Array.isArray(doc.result)) continue;
|
|
420
|
-
const target = path.join(dumpsDir, name);
|
|
421
|
-
await fs.writeFile(`${target}.tmp`, JSON.stringify(compactV8Document(doc, { keepSourcesContent: true })));
|
|
422
|
-
await fs.chmod(`${target}.tmp`, 0o644);
|
|
423
|
-
await fs.rename(`${target}.tmp`, target);
|
|
424
|
-
await fs.unlink(file);
|
|
425
|
-
dumps++;
|
|
426
|
-
}
|
|
427
|
-
if (dumps === 0) {
|
|
428
|
-
await ctx.writeReport(NODE_COVERAGE_LCOV, "TN:\n");
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
await ctx.exec(nodeCoverageConvertCommand());
|
|
432
|
-
let lcov: string;
|
|
433
|
-
try {
|
|
434
|
-
lcov = await fs.readFile(path.join(ctx.reportDir, ".c8", "lcov.info"), "utf8");
|
|
435
|
-
} catch (err) {
|
|
436
|
-
throw new Error(`c8 wrote no lcov.info: ${err instanceof Error ? err.message : String(err)}`);
|
|
437
|
-
}
|
|
438
|
-
await ctx.writeReport(NODE_COVERAGE_LCOV, absoluteLcovPaths(lcov));
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** `SF:` records relative to `/` (what `c8` run from `/` writes) made
|
|
442
|
-
* absolute. An lcov with nothing in it becomes the empty report. */
|
|
443
|
-
export function absoluteLcovPaths(lcov: string): string {
|
|
444
|
-
if (lcov.trim().length === 0) return "TN:\n";
|
|
445
|
-
return lcov.replace(/^SF:(?!\/)/gm, "SF:/");
|
|
446
|
-
}
|
|
447
|
-
|
|
448
342
|
/**
|
|
449
343
|
* Ask every node process that holds a hook socket in `dir` for a dump.
|
|
450
344
|
* A socket nobody answers (its process died without unlinking — SIGKILL,
|
|
@@ -495,23 +389,34 @@ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Pr
|
|
|
495
389
|
return live;
|
|
496
390
|
}
|
|
497
391
|
|
|
498
|
-
/**
|
|
499
|
-
*
|
|
500
|
-
|
|
392
|
+
/** Report files shipped at an earlier capture on this branch, by host
|
|
393
|
+
* path. Module memory: forks with the environment, so a forked child
|
|
394
|
+
* removes its ancestors' reports at its first capture and ships only
|
|
395
|
+
* its own. */
|
|
396
|
+
const SHIPPED_V8_REPORTS = new Set<string>();
|
|
397
|
+
|
|
398
|
+
/** Source-map entries shipped earlier on this branch: script URL → hash
|
|
399
|
+
* of the entry. Module memory, forks with the environment. A later dump
|
|
400
|
+
* of the same script carries a stub that refers to the hash instead of
|
|
401
|
+
* the map again. */
|
|
402
|
+
const SHIPPED_SOURCE_MAPS = new Map<string, string>();
|
|
403
|
+
|
|
404
|
+
/** Forget the branch memory (tests). */
|
|
405
|
+
export function resetNodeCoverageMemory(): void {
|
|
406
|
+
SHIPPED_V8_REPORTS.clear();
|
|
407
|
+
SHIPPED_SOURCE_MAPS.clear();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Remove from `dir` the reports an earlier capture shipped. */
|
|
411
|
+
export async function removeShippedV8Reports(dir: string): Promise<void> {
|
|
501
412
|
const fs = await import("node:fs/promises");
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
);
|
|
506
|
-
} catch {
|
|
507
|
-
return false;
|
|
413
|
+
for (const file of [...SHIPPED_V8_REPORTS]) {
|
|
414
|
+
if (!file.startsWith(dir + "/")) continue;
|
|
415
|
+
await fs.unlink(file).catch(() => {});
|
|
416
|
+
SHIPPED_V8_REPORTS.delete(file);
|
|
508
417
|
}
|
|
509
418
|
}
|
|
510
419
|
|
|
511
|
-
/** V8 files already compacted, by path. Module memory: forks with the
|
|
512
|
-
* environment, so a child never re-parses its ancestors' dumps. */
|
|
513
|
-
const COMPACTED_V8_REPORTS = new Set<string>();
|
|
514
|
-
|
|
515
420
|
/** A script is the app's own when it is a file outside node_modules —
|
|
516
421
|
* and not our hook, which every dump would otherwise carry. */
|
|
517
422
|
export function isAppScriptUrl(url: string): boolean {
|
|
@@ -525,25 +430,49 @@ export function isAppScriptUrl(url: string): boolean {
|
|
|
525
430
|
);
|
|
526
431
|
}
|
|
527
432
|
|
|
433
|
+
interface V8Range {
|
|
434
|
+
count?: unknown;
|
|
435
|
+
}
|
|
436
|
+
interface V8Function {
|
|
437
|
+
ranges?: V8Range[];
|
|
438
|
+
}
|
|
439
|
+
interface V8Script {
|
|
440
|
+
url?: unknown;
|
|
441
|
+
functions?: V8Function[];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** True when any range of any function of the script has a count above
|
|
445
|
+
* zero: something in it ran since the previous take. A script whose
|
|
446
|
+
* every count is zero is one V8 still lists after a reset; it says
|
|
447
|
+
* nothing and is dropped. */
|
|
448
|
+
export function scriptExecuted(script: V8Script): boolean {
|
|
449
|
+
const fns = Array.isArray(script.functions) ? script.functions : [];
|
|
450
|
+
return fns.some((f) => Array.isArray(f.ranges) && f.ranges.some((r) => typeof r.count === "number" && r.count > 0));
|
|
451
|
+
}
|
|
452
|
+
|
|
528
453
|
/**
|
|
529
|
-
* Compact one V8 coverage document to the
|
|
530
|
-
* writes is everything the process loaded: `node:` internals, every
|
|
454
|
+
* Compact one V8 coverage document to what the derivation needs. What
|
|
455
|
+
* Node writes is everything the process loaded: `node:` internals, every
|
|
531
456
|
* `node_modules` file, and — under `--enable-source-maps` — a
|
|
532
457
|
* `source-map-cache` with each file's full map **and its sources**,
|
|
533
458
|
* repeated in every dump. Measured on a real project: a 12–20 MiB dump
|
|
534
459
|
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
535
460
|
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
536
|
-
* scripts outside `node_modules
|
|
537
|
-
* scripts, minus `sourcesContent` (the sources
|
|
538
|
-
*
|
|
539
|
-
*
|
|
461
|
+
* scripts outside `node_modules` in which something ran, and the map
|
|
462
|
+
* entries of exactly those scripts, minus `sourcesContent` (the sources
|
|
463
|
+
* are the repo). Each kept map entry carries its content hash under
|
|
464
|
+
* {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
|
|
465
|
+
* URL — the map went out with an earlier dump on this branch — a stub
|
|
466
|
+
* `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
|
|
467
|
+
* the map in an earlier capture of the branch. `shipped` is updated in
|
|
468
|
+
* place. Still a V8 document — nothing is converted here.
|
|
540
469
|
*/
|
|
541
470
|
export function compactV8Document(
|
|
542
471
|
doc: Record<string, unknown>,
|
|
543
|
-
|
|
472
|
+
shipped?: Map<string, string>,
|
|
544
473
|
): Record<string, unknown> {
|
|
545
|
-
const result = Array.isArray(doc.result) ? (doc.result as
|
|
546
|
-
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
|
|
474
|
+
const result = Array.isArray(doc.result) ? (doc.result as V8Script[]) : [];
|
|
475
|
+
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url) && scriptExecuted(s));
|
|
547
476
|
const out: Record<string, unknown> = { ...doc, result: kept };
|
|
548
477
|
const cache = doc["source-map-cache"];
|
|
549
478
|
if (cache && typeof cache === "object") {
|
|
@@ -552,11 +481,19 @@ export function compactV8Document(
|
|
|
552
481
|
for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
|
|
553
482
|
if (!urls.has(url) || !entry || typeof entry !== "object") continue;
|
|
554
483
|
const e = { ...(entry as Record<string, unknown>) };
|
|
555
|
-
if (
|
|
484
|
+
if (e.data && typeof e.data === "object") {
|
|
556
485
|
const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
|
|
557
486
|
e.data = data;
|
|
558
487
|
}
|
|
559
|
-
|
|
488
|
+
delete e[SOURCE_MAP_HASH_KEY];
|
|
489
|
+
delete e[SOURCE_MAP_REF_KEY];
|
|
490
|
+
const hash = "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
|
|
491
|
+
if (shipped?.get(url) === hash) {
|
|
492
|
+
slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
|
|
493
|
+
} else {
|
|
494
|
+
slim[url] = { ...e, [SOURCE_MAP_HASH_KEY]: hash };
|
|
495
|
+
shipped?.set(url, hash);
|
|
496
|
+
}
|
|
560
497
|
}
|
|
561
498
|
if (Object.keys(slim).length > 0) out["source-map-cache"] = slim;
|
|
562
499
|
else delete out["source-map-cache"];
|
|
@@ -564,32 +501,53 @@ export function compactV8Document(
|
|
|
564
501
|
return out;
|
|
565
502
|
}
|
|
566
503
|
|
|
567
|
-
/**
|
|
568
|
-
export
|
|
504
|
+
/** What {@link prepareV8Reports} did, for the capture's log line. */
|
|
505
|
+
export interface PrepareV8Stats {
|
|
506
|
+
dumps: number;
|
|
507
|
+
scripts: number;
|
|
508
|
+
bytesIn: number;
|
|
509
|
+
bytesOut: number;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Compact, in place, every `coverage-*.json` in `dir` that no earlier
|
|
514
|
+
* capture shipped, and mark it shipped. A dump mid-write (not yet valid
|
|
515
|
+
* JSON) is left for the next capture.
|
|
516
|
+
*/
|
|
517
|
+
export async function prepareV8Reports(dir: string): Promise<PrepareV8Stats> {
|
|
569
518
|
const fs = await import("node:fs/promises");
|
|
570
519
|
const path = await import("node:path");
|
|
520
|
+
const stats: PrepareV8Stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0 };
|
|
571
521
|
let names: string[];
|
|
572
522
|
try {
|
|
573
523
|
names = await fs.readdir(dir);
|
|
574
524
|
} catch {
|
|
575
|
-
return;
|
|
525
|
+
return stats;
|
|
576
526
|
}
|
|
577
|
-
for (const name of names) {
|
|
578
|
-
if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
|
|
527
|
+
for (const name of names.sort()) {
|
|
528
|
+
if (!name.startsWith("coverage-") || !name.endsWith(".json") || name === NODE_COVERAGE_EMPTY_REPORT) continue;
|
|
579
529
|
const file = path.join(dir, name);
|
|
580
|
-
if (
|
|
530
|
+
if (SHIPPED_V8_REPORTS.has(file)) continue;
|
|
531
|
+
let text: string;
|
|
581
532
|
let doc: Record<string, unknown>;
|
|
582
533
|
try {
|
|
583
|
-
|
|
534
|
+
text = await fs.readFile(file, "utf8");
|
|
535
|
+
doc = JSON.parse(text) as Record<string, unknown>;
|
|
584
536
|
} catch {
|
|
585
537
|
continue; // a dump mid-write, or not ours; the read path judges it
|
|
586
538
|
}
|
|
587
539
|
if (!Array.isArray(doc.result)) continue;
|
|
540
|
+
const out = JSON.stringify(compactV8Document(doc, SHIPPED_SOURCE_MAPS));
|
|
588
541
|
const tmp = path.join(dir, `.${name}.compact`);
|
|
589
|
-
await fs.writeFile(tmp,
|
|
542
|
+
await fs.writeFile(tmp, out);
|
|
590
543
|
await fs.rename(tmp, file);
|
|
591
|
-
|
|
544
|
+
SHIPPED_V8_REPORTS.add(file);
|
|
545
|
+
stats.dumps++;
|
|
546
|
+
stats.scripts += (doc.result as unknown[]).length;
|
|
547
|
+
stats.bytesIn += text.length;
|
|
548
|
+
stats.bytesOut += out.length;
|
|
592
549
|
}
|
|
550
|
+
return stats;
|
|
593
551
|
}
|
|
594
552
|
|
|
595
553
|
// ── browser ───────────────────────────────────────────────────────
|