@specific.dev/spectest 0.59.4 → 0.60.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/browser-coverage.d.ts +12 -1
- package/dist/browser-coverage.js +41 -8
- package/dist/coverage.d.ts +93 -16
- package/dist/coverage.js +181 -25
- package/dist/daemon.js +39 -12
- package/dist/harness/coverage.d.ts +35 -0
- package/dist/harness/coverage.js +157 -0
- package/package.json +1 -1
- package/src/browser-coverage.ts +42 -10
- package/src/coverage.test.ts +154 -0
- package/src/coverage.ts +206 -30
- package/src/daemon.ts +49 -13
- package/src/harness/coverage.test.ts +72 -0
- package/src/harness/coverage.ts +167 -0
package/dist/daemon.js
CHANGED
|
@@ -22,9 +22,9 @@ import net from "node:net";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
24
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
25
|
-
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, } from "./harness/coverage.js";
|
|
25
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
|
|
26
26
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
27
|
-
import { applyCoverageAdapters, coverageAdapters, validateCoverage, } from "./coverage.js";
|
|
27
|
+
import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, nodeCoverageToolsAvailable, nodeCoverageToolsDir, NODE_COVERAGE_TOOLS_CONTAINER_DIR, } from "./coverage.js";
|
|
28
28
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
29
29
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
30
30
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
@@ -529,7 +529,16 @@ async function ensureCoverage(svc) {
|
|
|
529
529
|
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
530
530
|
await fs.mkdir(host, { recursive: true });
|
|
531
531
|
await fs.chmod(host, 0o777);
|
|
532
|
-
|
|
532
|
+
const flags = [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
|
|
533
|
+
// The conversion tools golden ships (`c8` for `coverage.node()`),
|
|
534
|
+
// read-only, on the same existence check the adapter makes at capture
|
|
535
|
+
// — so the VM and the container agree on whether they are there. A
|
|
536
|
+
// plain `--volume`, never a config `volumes` entry: an absolute-source
|
|
537
|
+
// volume dir is listed in the delta-restore manifest and wiped.
|
|
538
|
+
if (await nodeCoverageToolsAvailable()) {
|
|
539
|
+
flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
|
|
540
|
+
}
|
|
541
|
+
return flags;
|
|
533
542
|
}
|
|
534
543
|
/** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
|
|
535
544
|
* new content every rebuild, and a stale uid is silently wrong. */
|
|
@@ -3037,8 +3046,10 @@ function rebuildBrowserCoverageResolver() {
|
|
|
3037
3046
|
},
|
|
3038
3047
|
});
|
|
3039
3048
|
}
|
|
3040
|
-
/** The context one adapter's `capture` runs against.
|
|
3041
|
-
|
|
3049
|
+
/** The context one adapter's `capture` runs against. Every report it
|
|
3050
|
+
* writes through `writeReport` is added to `written`, so the delta step
|
|
3051
|
+
* knows which files came from which adapter. */
|
|
3052
|
+
function coverageCaptureContext(svc, signal, written) {
|
|
3042
3053
|
const reportDir = coverageHostDir(WORKSPACE, svc.name);
|
|
3043
3054
|
return {
|
|
3044
3055
|
service: svc.name,
|
|
@@ -3061,20 +3072,25 @@ function coverageCaptureContext(svc, signal) {
|
|
|
3061
3072
|
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
|
|
3062
3073
|
await fs.writeFile(tmp, content);
|
|
3063
3074
|
await fs.rename(tmp, file);
|
|
3075
|
+
written.add(path.relative(reportDir, file));
|
|
3064
3076
|
},
|
|
3065
3077
|
};
|
|
3066
3078
|
}
|
|
3067
3079
|
/** Run one service's adapters in order under one budget. Returns the
|
|
3068
|
-
* error to fail the capture with, naming the adapter
|
|
3080
|
+
* error to fail the capture with, naming the adapter, and the reports
|
|
3081
|
+
* written by adapters that declare `reports: "delta"`. */
|
|
3069
3082
|
async function runCoverageAdapters(svc) {
|
|
3083
|
+
const deltaReports = new Set();
|
|
3070
3084
|
const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
|
|
3071
3085
|
if (adapters.length === 0)
|
|
3072
|
-
return
|
|
3086
|
+
return { deltaReports };
|
|
3073
3087
|
const ac = new AbortController();
|
|
3074
3088
|
const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3075
|
-
const
|
|
3089
|
+
const written = new Set();
|
|
3090
|
+
const ctx = coverageCaptureContext(svc, ac.signal, written);
|
|
3076
3091
|
try {
|
|
3077
3092
|
for (const a of adapters) {
|
|
3093
|
+
written.clear();
|
|
3078
3094
|
try {
|
|
3079
3095
|
await Promise.race([
|
|
3080
3096
|
a.capture(ctx),
|
|
@@ -3084,10 +3100,16 @@ async function runCoverageAdapters(svc) {
|
|
|
3084
3100
|
]);
|
|
3085
3101
|
}
|
|
3086
3102
|
catch (err) {
|
|
3087
|
-
return
|
|
3103
|
+
return {
|
|
3104
|
+
error: `coverage adapter "${a.name}" on service "${svc.name}" failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3105
|
+
deltaReports,
|
|
3106
|
+
};
|
|
3088
3107
|
}
|
|
3108
|
+
if (a.reports === "delta")
|
|
3109
|
+
for (const n of written)
|
|
3110
|
+
deltaReports.add(n);
|
|
3089
3111
|
}
|
|
3090
|
-
return
|
|
3112
|
+
return { deltaReports };
|
|
3091
3113
|
}
|
|
3092
3114
|
finally {
|
|
3093
3115
|
clearTimeout(timer);
|
|
@@ -3104,10 +3126,15 @@ async function captureServiceCoverage() {
|
|
|
3104
3126
|
byName.set(name, s);
|
|
3105
3127
|
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
3106
3128
|
return Promise.all(services.map(async (svc) => {
|
|
3107
|
-
const error = await runCoverageAdapters(svc);
|
|
3129
|
+
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
3108
3130
|
if (error)
|
|
3109
3131
|
return { service: svc.name, reports: [], error };
|
|
3110
|
-
|
|
3132
|
+
const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3133
|
+
// What ships is what ran during this capture, whatever the tool
|
|
3134
|
+
// counts: cumulative lcov is subtracted against the previous
|
|
3135
|
+
// capture (module memory, forks with the env); a delta adapter's
|
|
3136
|
+
// files and V8 JSON pass through.
|
|
3137
|
+
return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
|
|
3111
3138
|
}));
|
|
3112
3139
|
}
|
|
3113
3140
|
/**
|
|
@@ -77,3 +77,38 @@ export declare function readCoverageDir(service: string, dir: string): Promise<S
|
|
|
77
77
|
export declare function encodeCoverageBundle(caseId: string, services: ServiceCoverageCapture[]): Buffer;
|
|
78
78
|
/** The ref for a bundle: its size plus a per-service summary. */
|
|
79
79
|
export declare function coverageBundleRef(gz: Buffer, services: ServiceCoverageCapture[]): CoverageBundleRef;
|
|
80
|
+
/** How a report's counters behave across captures. */
|
|
81
|
+
export type CoverageReportsMode = "delta" | "cumulative";
|
|
82
|
+
/** One lcov record's execution counts: `DA:` by line, `FNDA:` by function
|
|
83
|
+
* name. Branch data is dropped: file and line are what the derivation
|
|
84
|
+
* reads. */
|
|
85
|
+
export interface LcovRecordCounts {
|
|
86
|
+
lines: Map<number, number>;
|
|
87
|
+
functions: Map<string, number>;
|
|
88
|
+
/** `FN:<line>,<name>` declarations, kept so the delta names them. */
|
|
89
|
+
functionLines: Map<string, number>;
|
|
90
|
+
}
|
|
91
|
+
/** An lcov document parsed to counts, by `SF:` path. Records for one
|
|
92
|
+
* path are merged (a tool may split a file across records). */
|
|
93
|
+
export declare function parseLcovCounts(text: string): Map<string, LcovRecordCounts>;
|
|
94
|
+
/**
|
|
95
|
+
* `now` minus `prev`, per line and per function. A count that went
|
|
96
|
+
* *down* is a counter that restarted (the process was replaced): the
|
|
97
|
+
* new value stands on its own. A file not in `prev` is new: everything
|
|
98
|
+
* in it counts. Only records with at least one execution are kept — a
|
|
99
|
+
* file with nothing new in it did not run.
|
|
100
|
+
*/
|
|
101
|
+
export declare function subtractLcovCounts(now: Map<string, LcovRecordCounts>, prev: Map<string, LcovRecordCounts>): Map<string, LcovRecordCounts>;
|
|
102
|
+
/** Counts rendered back to lcov, records in path order. */
|
|
103
|
+
export declare function renderLcovCounts(counts: Map<string, LcovRecordCounts>): string;
|
|
104
|
+
/** Forget every previous capture (tests). */
|
|
105
|
+
export declare function resetCoverageDeltaMemory(): void;
|
|
106
|
+
/**
|
|
107
|
+
* Turn a capture's cumulative lcov reports into deltas against the
|
|
108
|
+
* previous capture of the same service and report name, and remember
|
|
109
|
+
* this capture as the new previous. Reports named in `deltaReports`
|
|
110
|
+
* (written by an adapter that declares `reports: "delta"`) and
|
|
111
|
+
* non-lcov reports (V8 JSON is a delta by construction) pass through
|
|
112
|
+
* untouched. With `mode === "delta"` every report passes through.
|
|
113
|
+
*/
|
|
114
|
+
export declare function applyCoverageDelta(capture: ServiceCoverageCapture, mode: CoverageReportsMode, deltaReports: ReadonlySet<string>): ServiceCoverageCapture;
|
package/dist/harness/coverage.js
CHANGED
|
@@ -15,6 +15,17 @@
|
|
|
15
15
|
// stored bytes, so a change in that derivation is a re-read of stored
|
|
16
16
|
// runs, never a hole in the history.
|
|
17
17
|
//
|
|
18
|
+
// The one transformation done here is the **delta**: a stored report
|
|
19
|
+
// describes what ran between the previous capture and this one, for
|
|
20
|
+
// every source alike. A tool that resets its own counters (V8) writes
|
|
21
|
+
// deltas already; a cumulative tool (coverage.py, Go, JaCoCo — most)
|
|
22
|
+
// gets subtracted against the previous capture, which lives in module
|
|
23
|
+
// memory and so forks with the environment: a forked child's "previous"
|
|
24
|
+
// is its parent's last capture, exactly the state it started from. Which
|
|
25
|
+
// of the two a file is must be declared (an adapter's `reports`, or the
|
|
26
|
+
// service's) — nothing in the bytes tells a delta that happens to equal
|
|
27
|
+
// the previous one from a cumulative report with no new execution.
|
|
28
|
+
//
|
|
18
29
|
// The container side (mount flag, `command` exec) is in `daemon.ts`.
|
|
19
30
|
import { promises as fs } from "node:fs";
|
|
20
31
|
import path from "node:path";
|
|
@@ -156,3 +167,149 @@ export function coverageBundleRef(gz, services) {
|
|
|
156
167
|
})),
|
|
157
168
|
};
|
|
158
169
|
}
|
|
170
|
+
/** An lcov document parsed to counts, by `SF:` path. Records for one
|
|
171
|
+
* path are merged (a tool may split a file across records). */
|
|
172
|
+
export function parseLcovCounts(text) {
|
|
173
|
+
const out = new Map();
|
|
174
|
+
let cur = null;
|
|
175
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
176
|
+
const line = raw.trim();
|
|
177
|
+
if (line.startsWith("SF:")) {
|
|
178
|
+
const file = line.slice(3).trim();
|
|
179
|
+
if (!file) {
|
|
180
|
+
cur = null;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
cur = out.get(file) ?? { lines: new Map(), functions: new Map(), functionLines: new Map() };
|
|
184
|
+
out.set(file, cur);
|
|
185
|
+
}
|
|
186
|
+
else if (!cur) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
else if (line.startsWith("DA:")) {
|
|
190
|
+
const [ln, hits] = line.slice(3).split(",");
|
|
191
|
+
const l = Number(ln), h = Number(hits);
|
|
192
|
+
if (Number.isFinite(l) && Number.isFinite(h))
|
|
193
|
+
cur.lines.set(l, (cur.lines.get(l) ?? 0) + h);
|
|
194
|
+
}
|
|
195
|
+
else if (line.startsWith("FNDA:")) {
|
|
196
|
+
const i = line.indexOf(",", 5);
|
|
197
|
+
if (i < 0)
|
|
198
|
+
continue;
|
|
199
|
+
const h = Number(line.slice(5, i)), name = line.slice(i + 1);
|
|
200
|
+
if (Number.isFinite(h))
|
|
201
|
+
cur.functions.set(name, (cur.functions.get(name) ?? 0) + h);
|
|
202
|
+
}
|
|
203
|
+
else if (line.startsWith("FN:")) {
|
|
204
|
+
const i = line.indexOf(",", 3);
|
|
205
|
+
if (i < 0)
|
|
206
|
+
continue;
|
|
207
|
+
const l = Number(line.slice(3, i)), name = line.slice(i + 1);
|
|
208
|
+
if (Number.isFinite(l))
|
|
209
|
+
cur.functionLines.set(name, l);
|
|
210
|
+
}
|
|
211
|
+
else if (line === "end_of_record") {
|
|
212
|
+
cur = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* `now` minus `prev`, per line and per function. A count that went
|
|
219
|
+
* *down* is a counter that restarted (the process was replaced): the
|
|
220
|
+
* new value stands on its own. A file not in `prev` is new: everything
|
|
221
|
+
* in it counts. Only records with at least one execution are kept — a
|
|
222
|
+
* file with nothing new in it did not run.
|
|
223
|
+
*/
|
|
224
|
+
export function subtractLcovCounts(now, prev) {
|
|
225
|
+
const out = new Map();
|
|
226
|
+
for (const [file, rec] of now) {
|
|
227
|
+
const before = prev.get(file);
|
|
228
|
+
const lines = new Map();
|
|
229
|
+
const functions = new Map();
|
|
230
|
+
let executed = false;
|
|
231
|
+
for (const [l, h] of rec.lines) {
|
|
232
|
+
const b = before?.lines.get(l) ?? 0;
|
|
233
|
+
const d = h < b ? h : h - b;
|
|
234
|
+
lines.set(l, d);
|
|
235
|
+
if (d > 0)
|
|
236
|
+
executed = true;
|
|
237
|
+
}
|
|
238
|
+
for (const [name, h] of rec.functions) {
|
|
239
|
+
const b = before?.functions.get(name) ?? 0;
|
|
240
|
+
const d = h < b ? h : h - b;
|
|
241
|
+
functions.set(name, d);
|
|
242
|
+
if (d > 0)
|
|
243
|
+
executed = true;
|
|
244
|
+
}
|
|
245
|
+
// A record with no counts at all carries presence as its only
|
|
246
|
+
// signal; it is new only when it was not there before.
|
|
247
|
+
if (rec.lines.size === 0 && rec.functions.size === 0 && !before)
|
|
248
|
+
executed = true;
|
|
249
|
+
if (executed)
|
|
250
|
+
out.set(file, { lines, functions, functionLines: rec.functionLines });
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
/** Counts rendered back to lcov, records in path order. */
|
|
255
|
+
export function renderLcovCounts(counts) {
|
|
256
|
+
const parts = ["TN:"];
|
|
257
|
+
for (const file of [...counts.keys()].sort()) {
|
|
258
|
+
const rec = counts.get(file);
|
|
259
|
+
parts.push(`SF:${file}`);
|
|
260
|
+
for (const [name, l] of [...rec.functionLines].sort((a, b) => a[1] - b[1]))
|
|
261
|
+
parts.push(`FN:${l},${name}`);
|
|
262
|
+
let fnh = 0;
|
|
263
|
+
for (const [name, h] of rec.functions) {
|
|
264
|
+
parts.push(`FNDA:${h},${name}`);
|
|
265
|
+
if (h > 0)
|
|
266
|
+
fnh++;
|
|
267
|
+
}
|
|
268
|
+
parts.push(`FNF:${rec.functions.size}`, `FNH:${fnh}`);
|
|
269
|
+
let lh = 0;
|
|
270
|
+
for (const [l, h] of [...rec.lines].sort((a, b) => a[0] - b[0])) {
|
|
271
|
+
parts.push(`DA:${l},${h}`);
|
|
272
|
+
if (h > 0)
|
|
273
|
+
lh++;
|
|
274
|
+
}
|
|
275
|
+
parts.push(`LF:${rec.lines.size}`, `LH:${lh}`, "end_of_record");
|
|
276
|
+
}
|
|
277
|
+
return parts.join("\n") + "\n";
|
|
278
|
+
}
|
|
279
|
+
/** The previous capture's counts: service → report name → parsed lcov.
|
|
280
|
+
* Module memory, so it forks with the environment — a forked child's
|
|
281
|
+
* previous capture is its parent's last one. */
|
|
282
|
+
const PREVIOUS_CAPTURE = new Map();
|
|
283
|
+
/** Forget every previous capture (tests). */
|
|
284
|
+
export function resetCoverageDeltaMemory() {
|
|
285
|
+
PREVIOUS_CAPTURE.clear();
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Turn a capture's cumulative lcov reports into deltas against the
|
|
289
|
+
* previous capture of the same service and report name, and remember
|
|
290
|
+
* this capture as the new previous. Reports named in `deltaReports`
|
|
291
|
+
* (written by an adapter that declares `reports: "delta"`) and
|
|
292
|
+
* non-lcov reports (V8 JSON is a delta by construction) pass through
|
|
293
|
+
* untouched. With `mode === "delta"` every report passes through.
|
|
294
|
+
*/
|
|
295
|
+
export function applyCoverageDelta(capture, mode, deltaReports) {
|
|
296
|
+
if (capture.error)
|
|
297
|
+
return capture;
|
|
298
|
+
let prevByName = PREVIOUS_CAPTURE.get(capture.service);
|
|
299
|
+
if (!prevByName) {
|
|
300
|
+
prevByName = new Map();
|
|
301
|
+
PREVIOUS_CAPTURE.set(capture.service, prevByName);
|
|
302
|
+
}
|
|
303
|
+
const reports = [];
|
|
304
|
+
for (const r of capture.reports) {
|
|
305
|
+
if (mode === "delta" || deltaReports.has(r.name) || !isLcovShaped(r.content)) {
|
|
306
|
+
reports.push(r);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const now = parseLcovCounts(r.content);
|
|
310
|
+
const prev = prevByName.get(r.name) ?? new Map();
|
|
311
|
+
prevByName.set(r.name, now);
|
|
312
|
+
reports.push({ name: r.name, content: renderLcovCounts(subtractLcovCounts(now, prev)) });
|
|
313
|
+
}
|
|
314
|
+
return { ...capture, reports };
|
|
315
|
+
}
|
package/package.json
CHANGED
package/src/browser-coverage.ts
CHANGED
|
@@ -68,6 +68,9 @@ interface Collector {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const COLLECTORS = new WeakMap<CDPSession, Collector>();
|
|
71
|
+
/** The collectors of every session attached so far, for the harvest at
|
|
72
|
+
* capture; a closed session's take fails and is dropped there. */
|
|
73
|
+
const LIVE_COLLECTORS = new Set<Collector>();
|
|
71
74
|
|
|
72
75
|
/** Decoded maps by URL (or content hash for inline `data:` maps). */
|
|
73
76
|
const SOURCE_MAPS = new Map<string, DecodedSourceMap | null>();
|
|
@@ -80,6 +83,7 @@ export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
|
80
83
|
if (!CONFIG || COLLECTORS.has(cdp)) return;
|
|
81
84
|
const collector: Collector = { cdp, scripts: new Map(), inFlight: null };
|
|
82
85
|
COLLECTORS.set(cdp, collector);
|
|
86
|
+
LIVE_COLLECTORS.add(collector);
|
|
83
87
|
cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
|
|
84
88
|
if (!ev.url) return;
|
|
85
89
|
collector.scripts.set(ev.scriptId, {
|
|
@@ -109,9 +113,13 @@ export async function harvestBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promise<void> {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
let result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
|
|
117
|
+
try {
|
|
118
|
+
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as { result: typeof result });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
LIVE_COLLECTORS.delete(c); // the session is gone
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
115
123
|
for (const script of result) {
|
|
116
124
|
const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
|
|
117
125
|
if (!info) continue;
|
|
@@ -142,13 +150,18 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promis
|
|
|
142
150
|
const files: FileCounts = info.map
|
|
143
151
|
? mapToOriginal(info.map, generated)
|
|
144
152
|
: new Map([[url.pathname, generated]]);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
foldBrowserCoverage(service, files);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Fold one script's per-file counts into a service's totals. */
|
|
158
|
+
export function foldBrowserCoverage(service: string, files: FileCounts): void {
|
|
159
|
+
let totals = TOTALS.get(service);
|
|
160
|
+
if (!totals) {
|
|
161
|
+
totals = new Map();
|
|
162
|
+
TOTALS.set(service, totals);
|
|
151
163
|
}
|
|
164
|
+
mergeFileCounts(totals, files);
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
/** Resolve, fetch (or inline-decode) and decode a script's source map. */
|
|
@@ -201,7 +214,26 @@ function parseDataUrl(ref: string): unknown {
|
|
|
201
214
|
}
|
|
202
215
|
}
|
|
203
216
|
|
|
204
|
-
/**
|
|
217
|
+
/** Harvest every live session, so a capture sees the counters of code
|
|
218
|
+
* that ran since the last browser op. */
|
|
219
|
+
export async function harvestAllBrowserCoverage(): Promise<void> {
|
|
220
|
+
await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One lcov document per service that has any browser coverage — and
|
|
224
|
+
* the totals are cleared, so the next capture holds only what runs
|
|
225
|
+
* from now on. Module memory forks with the environment, so a forked
|
|
226
|
+
* child starts from its parent's last capture, empty. */
|
|
227
|
+
export function takeBrowserCoverageReports(): Map<string, string> {
|
|
228
|
+
const out = new Map<string, string>();
|
|
229
|
+
for (const [service, files] of TOTALS) {
|
|
230
|
+
if (files.size > 0) out.set(service, lcovDocument(files));
|
|
231
|
+
}
|
|
232
|
+
TOTALS.clear();
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The current totals, without clearing them (tests). */
|
|
205
237
|
export function browserCoverageReports(): Map<string, string> {
|
|
206
238
|
const out = new Map<string, string>();
|
|
207
239
|
for (const [service, files] of TOTALS) {
|
package/src/coverage.test.ts
CHANGED
|
@@ -4,21 +4,32 @@ import { promises as fs } from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import {
|
|
7
|
+
NODE_COVERAGE_C8_BIN,
|
|
8
|
+
NODE_COVERAGE_DUMPS_SUBDIR,
|
|
7
9
|
NODE_COVERAGE_HOOK,
|
|
8
10
|
NODE_COVERAGE_EMPTY_REPORT,
|
|
11
|
+
NODE_COVERAGE_LCOV,
|
|
12
|
+
NODE_COVERAGE_TOOLS_CONTAINER_DIR,
|
|
9
13
|
NODE_COVERAGE_HOOK_PATH,
|
|
10
14
|
NODE_COVERAGE_SOCKET_PREFIX,
|
|
15
|
+
absoluteLcovPaths,
|
|
11
16
|
applyCoverageAdapters,
|
|
12
17
|
appendEnvFlag,
|
|
13
18
|
browser,
|
|
14
19
|
command,
|
|
20
|
+
coverageReportsMode,
|
|
15
21
|
compactV8Document,
|
|
16
22
|
compactV8Reports,
|
|
23
|
+
COVERAGE_CONTAINER_DIR,
|
|
17
24
|
node,
|
|
25
|
+
nodeCoverageConvertCommand,
|
|
26
|
+
nodeCoverageToolsAvailable,
|
|
27
|
+
nodeCoverageToolsDir,
|
|
18
28
|
validateCoverage,
|
|
19
29
|
type CoverageCaptureContext,
|
|
20
30
|
} from "./coverage.js";
|
|
21
31
|
import type { ServiceConfig } from "./index.js";
|
|
32
|
+
import { foldBrowserCoverage } from "./browser-coverage.js";
|
|
22
33
|
|
|
23
34
|
const base: ServiceConfig = {
|
|
24
35
|
image: { type: "registry", reference: "node:20" },
|
|
@@ -36,6 +47,23 @@ describe("validateCoverage", () => {
|
|
|
36
47
|
expect(() => validateCoverage("a", [node()])).toThrow(/invalid `coverage` value/);
|
|
37
48
|
expect(() => validateCoverage("a", { adapters: [{ name: "" }] })).toThrow(/invalid coverage adapter/);
|
|
38
49
|
expect(() => validateCoverage("a", { adapters: [{ name: "x", capture: 1 }] })).toThrow(/invalid coverage adapter/);
|
|
50
|
+
validateCoverage("a", { reports: "delta" });
|
|
51
|
+
validateCoverage("a", { reports: "cumulative", adapters: [] });
|
|
52
|
+
expect(() => validateCoverage("a", { reports: "sometimes" })).toThrow(/invalid `coverage` value/);
|
|
53
|
+
expect(() => validateCoverage("a", { adapters: [{ name: "x", reports: "no" }] })).toThrow(/invalid coverage adapter/);
|
|
54
|
+
expect(() => validateCoverage("a", { adapters: [], extra: 1 })).toThrow(/invalid `coverage` value/);
|
|
55
|
+
expect(() => validateCoverage("a", {})).toThrow(/invalid `coverage` value/);
|
|
56
|
+
});
|
|
57
|
+
test("reports mode: cumulative unless declared; the shipped adapters write deltas", () => {
|
|
58
|
+
expect(coverageReportsMode(undefined)).toBe("cumulative");
|
|
59
|
+
expect(coverageReportsMode(true)).toBe("cumulative");
|
|
60
|
+
expect(coverageReportsMode({ adapters: [] })).toBe("cumulative");
|
|
61
|
+
expect(coverageReportsMode({ reports: "delta" })).toBe("delta");
|
|
62
|
+
expect(node().reports).toBe("delta");
|
|
63
|
+
expect(browser().reports).toBe("delta");
|
|
64
|
+
expect(command("x").reports).toBeUndefined();
|
|
65
|
+
const out = applyCoverageAdapters("api", { ...base, coverage: { reports: "delta", adapters: [command("x")] } });
|
|
66
|
+
expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({ adapters: [{ adapter: "command" }], reports: "delta" });
|
|
39
67
|
});
|
|
40
68
|
});
|
|
41
69
|
|
|
@@ -91,6 +119,21 @@ describe("compactV8Document", () => {
|
|
|
91
119
|
expect(Object.keys(out["source-map-cache"])).toEqual(["file:///app/src/a.ts"]);
|
|
92
120
|
expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
|
|
93
121
|
});
|
|
122
|
+
test("keepSourcesContent keeps the sources for the in-container conversion", () => {
|
|
123
|
+
const out = compactV8Document(doc, { keepSourcesContent: true }) as typeof doc;
|
|
124
|
+
expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({
|
|
125
|
+
mappings: "AAAA",
|
|
126
|
+
sources: ["a.ts"],
|
|
127
|
+
sourcesContent: ["x"],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
test("absoluteLcovPaths makes c8's root-relative SF records container paths", () => {
|
|
131
|
+
expect(absoluteLcovPaths("TN:\nSF:app/src/a.ts\nDA:1,1\nend_of_record\nSF:/abs.js\nend_of_record\n")).toBe(
|
|
132
|
+
"TN:\nSF:/app/src/a.ts\nDA:1,1\nend_of_record\nSF:/abs.js\nend_of_record\n",
|
|
133
|
+
);
|
|
134
|
+
expect(absoluteLcovPaths("")).toBe("TN:\n");
|
|
135
|
+
expect(absoluteLcovPaths(" \n")).toBe("TN:\n");
|
|
136
|
+
});
|
|
94
137
|
test("a dump with no app script keeps an empty result and no cache", () => {
|
|
95
138
|
const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
|
|
96
139
|
expect(out.result).toEqual([]);
|
|
@@ -132,6 +175,21 @@ describe("browserCoverage", () => {
|
|
|
132
175
|
await browser().capture!(fakeCtx(dir));
|
|
133
176
|
expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
|
|
134
177
|
});
|
|
178
|
+
test("capture takes the totals and clears them: the next capture is a delta", async () => {
|
|
179
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
180
|
+
foldBrowserCoverage("api", new Map([["src/a.ts", new Map([[1, 2]])]]));
|
|
181
|
+
await browser().capture!(fakeCtx(dir));
|
|
182
|
+
const first = await fs.readFile(path.join(dir, "browser.lcov"), "utf8");
|
|
183
|
+
expect(first).toContain("SF:src/a.ts");
|
|
184
|
+
expect(first).toContain("DA:1,2");
|
|
185
|
+
await browser().capture!(fakeCtx(dir));
|
|
186
|
+
expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
|
|
187
|
+
foldBrowserCoverage("api", new Map([["src/b.ts", new Map([[3, 1]])]]));
|
|
188
|
+
await browser().capture!(fakeCtx(dir));
|
|
189
|
+
const third = await fs.readFile(path.join(dir, "browser.lcov"), "utf8");
|
|
190
|
+
expect(third).toContain("SF:src/b.ts");
|
|
191
|
+
expect(third).not.toContain("SF:src/a.ts");
|
|
192
|
+
});
|
|
135
193
|
});
|
|
136
194
|
|
|
137
195
|
// The node hook against a real `node` (the box has one; skipped where it
|
|
@@ -309,3 +367,99 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
309
367
|
expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
310
368
|
});
|
|
311
369
|
});
|
|
370
|
+
|
|
371
|
+
// The conversion to lcov against a real `node` and a real `c8` tree.
|
|
372
|
+
// Golden ships the tree at /opt/spectest/coverage-tools; on a dev box
|
|
373
|
+
// point SPECTEST_C8_TEST_DIR at one (`bun install --linker=hoisted` of
|
|
374
|
+
// `c8` in a scratch dir). Skipped where either is missing. The exec here
|
|
375
|
+
// is the container's `sh -c` with the container paths rewritten to this
|
|
376
|
+
// directory: the command is exactly what a container runs.
|
|
377
|
+
const c8TestDir = process.env.SPECTEST_C8_TEST_DIR || "/opt/spectest/coverage-tools";
|
|
378
|
+
const hasC8 = await fs
|
|
379
|
+
.access(path.join(c8TestDir, NODE_COVERAGE_C8_BIN))
|
|
380
|
+
.then(() => true)
|
|
381
|
+
.catch(() => false);
|
|
382
|
+
|
|
383
|
+
describe.if(hasNode && hasC8)("nodeCoverage → lcov (real node + c8)", () => {
|
|
384
|
+
function c8Ctx(dir: string): CoverageCaptureContext {
|
|
385
|
+
return {
|
|
386
|
+
...fakeCtx(dir),
|
|
387
|
+
async exec(command: string) {
|
|
388
|
+
const local = command
|
|
389
|
+
.replaceAll(NODE_COVERAGE_TOOLS_CONTAINER_DIR, c8TestDir)
|
|
390
|
+
.replaceAll(COVERAGE_CONTAINER_DIR, dir);
|
|
391
|
+
return new Promise((resolve, reject) => {
|
|
392
|
+
const p = spawn("sh", ["-c", local], { stdio: ["ignore", "pipe", "pipe"] });
|
|
393
|
+
let out = "";
|
|
394
|
+
p.stdout!.on("data", (b: Buffer) => (out += b));
|
|
395
|
+
p.stderr!.on("data", (b: Buffer) => (out += b));
|
|
396
|
+
p.on("exit", (code) => (code === 0 ? resolve({ stdout: out, stderr: "" }) : reject(new Error(out))));
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
async function withTools<T>(f: () => Promise<T>): Promise<T> {
|
|
402
|
+
const prior = process.env.SPECTEST_COVERAGE_TOOLS_DIR;
|
|
403
|
+
process.env.SPECTEST_COVERAGE_TOOLS_DIR = c8TestDir;
|
|
404
|
+
try {
|
|
405
|
+
return await f();
|
|
406
|
+
} finally {
|
|
407
|
+
if (prior === undefined) delete process.env.SPECTEST_COVERAGE_TOOLS_DIR;
|
|
408
|
+
else process.env.SPECTEST_COVERAGE_TOOLS_DIR = prior;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
test("the tools dir is found through the override, and the command is the container's", async () => {
|
|
413
|
+
await withTools(async () => {
|
|
414
|
+
expect(nodeCoverageToolsDir()).toBe(c8TestDir);
|
|
415
|
+
expect(await nodeCoverageToolsAvailable()).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
expect(nodeCoverageConvertCommand()).toBe(
|
|
418
|
+
`cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN} report --temp-directory ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR} --reporter=lcovonly --reports-dir ${COVERAGE_CONTAINER_DIR}/.c8`,
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("dumps are converted to node.lcov, the delta of this capture; the raw dumps never ship", async () => {
|
|
423
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covc8-"));
|
|
424
|
+
const hook = path.join(dir, "coverage-hook.cjs");
|
|
425
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
426
|
+
await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
|
|
427
|
+
await withTools(async () => {
|
|
428
|
+
// Nothing ran yet: an empty lcov, no exec.
|
|
429
|
+
await node().capture!(c8Ctx(dir));
|
|
430
|
+
expect(await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8")).toBe("TN:\n");
|
|
431
|
+
const cli = spawn("node", [path.join(dir, "cli.js")], {
|
|
432
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
433
|
+
stdio: "ignore",
|
|
434
|
+
});
|
|
435
|
+
expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
|
|
436
|
+
await node().capture!(c8Ctx(dir));
|
|
437
|
+
const lcov = await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8");
|
|
438
|
+
expect(lcov).toContain(`SF:${path.join(dir, "cli.js")}\n`);
|
|
439
|
+
expect(lcov).toMatch(/FNDA:1,used/);
|
|
440
|
+
expect(lcov).toMatch(/FNDA:0,unused/);
|
|
441
|
+
// The hook itself is not the app's code.
|
|
442
|
+
expect(lcov).not.toContain("coverage-hook.cjs");
|
|
443
|
+
// Raw dumps moved under the hidden .v8/ dir: the visible reports
|
|
444
|
+
// are the lcov alone.
|
|
445
|
+
const visible = (await fs.readdir(dir)).filter((n) => !n.startsWith(".") && n.startsWith("coverage-") && n.endsWith(".json"));
|
|
446
|
+
expect(visible).toEqual([]);
|
|
447
|
+
const dumps = (await fs.readdir(path.join(dir, NODE_COVERAGE_DUMPS_SUBDIR))).filter((n) => n.endsWith(".json"));
|
|
448
|
+
expect(dumps.length).toBe(1);
|
|
449
|
+
// Nothing new since: the next capture is an empty report, not the
|
|
450
|
+
// previous one again — the lcov is a delta.
|
|
451
|
+
await node().capture!(c8Ctx(dir));
|
|
452
|
+
expect(await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8")).toBe("TN:\n");
|
|
453
|
+
// A new run is a new delta on its own: counts of 1, not 2.
|
|
454
|
+
const again = spawn("node", [path.join(dir, "cli.js")], {
|
|
455
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
456
|
+
stdio: "ignore",
|
|
457
|
+
});
|
|
458
|
+
await new Promise((r) => again.on("exit", r));
|
|
459
|
+
await node().capture!(c8Ctx(dir));
|
|
460
|
+
const lcov2 = await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8");
|
|
461
|
+
expect(lcov2).toMatch(/FNDA:1,used/);
|
|
462
|
+
expect((await fs.readdir(path.join(dir, NODE_COVERAGE_DUMPS_SUBDIR))).filter((n) => n.endsWith(".json")).length).toBe(1);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|