@specific.dev/spectest 0.59.4 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +44 -0
- package/dist/harness/coverage.js +173 -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 +85 -0
- package/src/harness/coverage.ts +186 -0
package/src/harness/coverage.ts
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
|
|
|
20
31
|
import { promises as fs } from "node:fs";
|
|
@@ -211,3 +222,178 @@ export function coverageBundleRef(
|
|
|
211
222
|
})),
|
|
212
223
|
};
|
|
213
224
|
}
|
|
225
|
+
|
|
226
|
+
// ── delta ─────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/** How a report's counters behave across captures. */
|
|
229
|
+
export type CoverageReportsMode = "delta" | "cumulative";
|
|
230
|
+
|
|
231
|
+
/** One lcov record's execution counts: `DA:` by line, `FNDA:` by function
|
|
232
|
+
* name. Branch data is dropped: file and line are what the derivation
|
|
233
|
+
* reads. */
|
|
234
|
+
export interface LcovRecordCounts {
|
|
235
|
+
lines: Map<number, number>;
|
|
236
|
+
functions: Map<string, number>;
|
|
237
|
+
/** `FN:<line>,<name>` declarations, kept so the delta names them. */
|
|
238
|
+
functionLines: Map<string, number>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** An lcov document parsed to counts, by `SF:` path. Records for one
|
|
242
|
+
* path are merged (a tool may split a file across records). */
|
|
243
|
+
export function parseLcovCounts(text: string): Map<string, LcovRecordCounts> {
|
|
244
|
+
const out = new Map<string, LcovRecordCounts>();
|
|
245
|
+
let cur: LcovRecordCounts | null = null;
|
|
246
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
247
|
+
const line = raw.trim();
|
|
248
|
+
if (line.startsWith("SF:")) {
|
|
249
|
+
const file = line.slice(3).trim();
|
|
250
|
+
if (!file) {
|
|
251
|
+
cur = null;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
cur = out.get(file) ?? { lines: new Map(), functions: new Map(), functionLines: new Map() };
|
|
255
|
+
out.set(file, cur);
|
|
256
|
+
} else if (!cur) {
|
|
257
|
+
continue;
|
|
258
|
+
} else if (line.startsWith("DA:")) {
|
|
259
|
+
const [ln, hits] = line.slice(3).split(",");
|
|
260
|
+
const l = Number(ln), h = Number(hits);
|
|
261
|
+
if (Number.isFinite(l) && Number.isFinite(h)) cur.lines.set(l, (cur.lines.get(l) ?? 0) + h);
|
|
262
|
+
} else if (line.startsWith("FNDA:")) {
|
|
263
|
+
const i = line.indexOf(",", 5);
|
|
264
|
+
if (i < 0) continue;
|
|
265
|
+
const h = Number(line.slice(5, i)), name = line.slice(i + 1);
|
|
266
|
+
if (Number.isFinite(h)) cur.functions.set(name, (cur.functions.get(name) ?? 0) + h);
|
|
267
|
+
} else if (line.startsWith("FN:")) {
|
|
268
|
+
const i = line.indexOf(",", 3);
|
|
269
|
+
if (i < 0) continue;
|
|
270
|
+
const l = Number(line.slice(3, i)), name = line.slice(i + 1);
|
|
271
|
+
if (Number.isFinite(l)) cur.functionLines.set(name, l);
|
|
272
|
+
} else if (line === "end_of_record") {
|
|
273
|
+
cur = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* `now` minus `prev`, per line and per function. A file not in `prev`
|
|
281
|
+
* is new: everything in it counts. A **record in which any count went
|
|
282
|
+
* down** is a process that restarted (a restart resets every counter of
|
|
283
|
+
* that process at once): the whole record stands as-is, never
|
|
284
|
+
* subtracted — per line would report a line that ran exactly as often
|
|
285
|
+
* again as "not executed", and the restarted boot set (1 − 1) would
|
|
286
|
+
* vanish. Per record, not per report: a combined coverage.py file can
|
|
287
|
+
* hold several processes. A restart in which no counter dropped is not
|
|
288
|
+
* detectable from counts and is accepted as the residual (a service
|
|
289
|
+
* restarting mid-test is an anomaly already). Lines with a zero delta
|
|
290
|
+
* stay in the record: the server distinguishes "ran 0 times" from "not
|
|
291
|
+
* a counted line". Only records with at least one execution are kept —
|
|
292
|
+
* a file with nothing new in it did not run. `restarted` receives the
|
|
293
|
+
* paths of the records taken as-is.
|
|
294
|
+
*/
|
|
295
|
+
export function subtractLcovCounts(
|
|
296
|
+
now: Map<string, LcovRecordCounts>,
|
|
297
|
+
prev: Map<string, LcovRecordCounts>,
|
|
298
|
+
restarted?: string[],
|
|
299
|
+
): Map<string, LcovRecordCounts> {
|
|
300
|
+
const out = new Map<string, LcovRecordCounts>();
|
|
301
|
+
for (const [file, rec] of now) {
|
|
302
|
+
const before = prev.get(file);
|
|
303
|
+
const dropped =
|
|
304
|
+
!!before &&
|
|
305
|
+
([...rec.lines].some(([l, h]) => h < (before.lines.get(l) ?? 0)) ||
|
|
306
|
+
[...rec.functions].some(([n, h]) => h < (before.functions.get(n) ?? 0)));
|
|
307
|
+
if (dropped) restarted?.push(file);
|
|
308
|
+
const lines = new Map<number, number>();
|
|
309
|
+
const functions = new Map<string, number>();
|
|
310
|
+
let executed = false;
|
|
311
|
+
for (const [l, h] of rec.lines) {
|
|
312
|
+
const d = dropped ? h : h - (before?.lines.get(l) ?? 0);
|
|
313
|
+
lines.set(l, d);
|
|
314
|
+
if (d > 0) executed = true;
|
|
315
|
+
}
|
|
316
|
+
for (const [name, h] of rec.functions) {
|
|
317
|
+
const d = dropped ? h : h - (before?.functions.get(name) ?? 0);
|
|
318
|
+
functions.set(name, d);
|
|
319
|
+
if (d > 0) executed = true;
|
|
320
|
+
}
|
|
321
|
+
// A record with no counts at all carries presence as its only
|
|
322
|
+
// signal; it is new only when it was not there before.
|
|
323
|
+
if (rec.lines.size === 0 && rec.functions.size === 0 && !before) executed = true;
|
|
324
|
+
if (executed) out.set(file, { lines, functions, functionLines: rec.functionLines });
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Counts rendered back to lcov, records in path order. */
|
|
330
|
+
export function renderLcovCounts(counts: Map<string, LcovRecordCounts>): string {
|
|
331
|
+
const parts: string[] = ["TN:"];
|
|
332
|
+
for (const file of [...counts.keys()].sort()) {
|
|
333
|
+
const rec = counts.get(file)!;
|
|
334
|
+
parts.push(`SF:${file}`);
|
|
335
|
+
for (const [name, l] of [...rec.functionLines].sort((a, b) => a[1] - b[1])) parts.push(`FN:${l},${name}`);
|
|
336
|
+
let fnh = 0;
|
|
337
|
+
for (const [name, h] of rec.functions) {
|
|
338
|
+
parts.push(`FNDA:${h},${name}`);
|
|
339
|
+
if (h > 0) fnh++;
|
|
340
|
+
}
|
|
341
|
+
parts.push(`FNF:${rec.functions.size}`, `FNH:${fnh}`);
|
|
342
|
+
let lh = 0;
|
|
343
|
+
for (const [l, h] of [...rec.lines].sort((a, b) => a[0] - b[0])) {
|
|
344
|
+
parts.push(`DA:${l},${h}`);
|
|
345
|
+
if (h > 0) lh++;
|
|
346
|
+
}
|
|
347
|
+
parts.push(`LF:${rec.lines.size}`, `LH:${lh}`, "end_of_record");
|
|
348
|
+
}
|
|
349
|
+
return parts.join("\n") + "\n";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The previous capture's counts: service → report name → parsed lcov.
|
|
353
|
+
* Module memory, so it forks with the environment — a forked child's
|
|
354
|
+
* previous capture is its parent's last one. */
|
|
355
|
+
const PREVIOUS_CAPTURE = new Map<string, Map<string, Map<string, LcovRecordCounts>>>();
|
|
356
|
+
|
|
357
|
+
/** Forget every previous capture (tests). */
|
|
358
|
+
export function resetCoverageDeltaMemory(): void {
|
|
359
|
+
PREVIOUS_CAPTURE.clear();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Turn a capture's cumulative lcov reports into deltas against the
|
|
364
|
+
* previous capture of the same service and report name, and remember
|
|
365
|
+
* this capture as the new previous. Reports named in `deltaReports`
|
|
366
|
+
* (written by an adapter that declares `reports: "delta"`) and
|
|
367
|
+
* non-lcov reports (V8 JSON is a delta by construction) pass through
|
|
368
|
+
* untouched. With `mode === "delta"` every report passes through.
|
|
369
|
+
*/
|
|
370
|
+
export function applyCoverageDelta(
|
|
371
|
+
capture: ServiceCoverageCapture,
|
|
372
|
+
mode: CoverageReportsMode,
|
|
373
|
+
deltaReports: ReadonlySet<string>,
|
|
374
|
+
): ServiceCoverageCapture {
|
|
375
|
+
if (capture.error) return capture;
|
|
376
|
+
let prevByName = PREVIOUS_CAPTURE.get(capture.service);
|
|
377
|
+
if (!prevByName) {
|
|
378
|
+
prevByName = new Map();
|
|
379
|
+
PREVIOUS_CAPTURE.set(capture.service, prevByName);
|
|
380
|
+
}
|
|
381
|
+
const reports: CoverageReport[] = [];
|
|
382
|
+
for (const r of capture.reports) {
|
|
383
|
+
if (mode === "delta" || deltaReports.has(r.name) || !isLcovShaped(r.content)) {
|
|
384
|
+
reports.push(r);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const now = parseLcovCounts(r.content);
|
|
388
|
+
const prev = prevByName.get(r.name) ?? new Map<string, LcovRecordCounts>();
|
|
389
|
+
prevByName.set(r.name, now);
|
|
390
|
+
const restarted: string[] = [];
|
|
391
|
+
reports.push({ name: r.name, content: renderLcovCounts(subtractLcovCounts(now, prev, restarted)) });
|
|
392
|
+
if (restarted.length > 0) {
|
|
393
|
+
console.warn(
|
|
394
|
+
`[coverage] service "${capture.service}" report ${r.name}: counters dropped in ${restarted.length} file(s) (${restarted.slice(0, 3).join(", ")}) — a process restarted; those records count whole`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { ...capture, reports };
|
|
399
|
+
}
|