@specific.dev/spectest 0.59.3 → 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 +44 -9
- package/dist/coverage.d.ts +97 -16
- package/dist/coverage.js +193 -26
- package/dist/daemon.js +39 -12
- package/dist/harness/browser-coverage.d.ts +8 -0
- package/dist/harness/browser-coverage.js +18 -1
- 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 +44 -10
- package/src/coverage.test.ts +183 -0
- package/src/coverage.ts +218 -31
- package/src/daemon.ts +49 -13
- package/src/harness/browser-coverage.test.ts +17 -0
- package/src/harness/browser-coverage.ts +19 -1
- package/src/harness/coverage.test.ts +72 -0
- package/src/harness/coverage.ts +167 -0
package/src/daemon.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
encodeCoverageBundle,
|
|
42
42
|
isEmptyReport,
|
|
43
43
|
readCoverageDir,
|
|
44
|
+
applyCoverageDelta,
|
|
44
45
|
type CoverageBundleRef,
|
|
45
46
|
type CoverageReport,
|
|
46
47
|
type ServiceCoverageCapture,
|
|
@@ -49,7 +50,11 @@ import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
|
49
50
|
import {
|
|
50
51
|
applyCoverageAdapters,
|
|
51
52
|
coverageAdapters,
|
|
53
|
+
coverageReportsMode,
|
|
52
54
|
validateCoverage,
|
|
55
|
+
nodeCoverageToolsAvailable,
|
|
56
|
+
nodeCoverageToolsDir,
|
|
57
|
+
NODE_COVERAGE_TOOLS_CONTAINER_DIR,
|
|
53
58
|
type CoverageAdapter,
|
|
54
59
|
type CoverageCaptureContext,
|
|
55
60
|
} from "./coverage.js";
|
|
@@ -790,7 +795,16 @@ async function ensureCoverage(svc: NamedService): Promise<string[]> {
|
|
|
790
795
|
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
791
796
|
await fs.mkdir(host, { recursive: true });
|
|
792
797
|
await fs.chmod(host, 0o777);
|
|
793
|
-
|
|
798
|
+
const flags = [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
|
|
799
|
+
// The conversion tools golden ships (`c8` for `coverage.node()`),
|
|
800
|
+
// read-only, on the same existence check the adapter makes at capture
|
|
801
|
+
// — so the VM and the container agree on whether they are there. A
|
|
802
|
+
// plain `--volume`, never a config `volumes` entry: an absolute-source
|
|
803
|
+
// volume dir is listed in the delta-restore manifest and wiped.
|
|
804
|
+
if (await nodeCoverageToolsAvailable()) {
|
|
805
|
+
flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
|
|
806
|
+
}
|
|
807
|
+
return flags;
|
|
794
808
|
}
|
|
795
809
|
|
|
796
810
|
/** A `user`/`group` pair resolved to the numeric ids the kernel wants.
|
|
@@ -3667,8 +3681,14 @@ function rebuildBrowserCoverageResolver(): void {
|
|
|
3667
3681
|
});
|
|
3668
3682
|
}
|
|
3669
3683
|
|
|
3670
|
-
/** The context one adapter's `capture` runs against.
|
|
3671
|
-
|
|
3684
|
+
/** The context one adapter's `capture` runs against. Every report it
|
|
3685
|
+
* writes through `writeReport` is added to `written`, so the delta step
|
|
3686
|
+
* knows which files came from which adapter. */
|
|
3687
|
+
function coverageCaptureContext(
|
|
3688
|
+
svc: NamedService,
|
|
3689
|
+
signal: AbortSignal,
|
|
3690
|
+
written: Set<string>,
|
|
3691
|
+
): CoverageCaptureContext {
|
|
3672
3692
|
const reportDir = coverageHostDir(WORKSPACE, svc.name);
|
|
3673
3693
|
return {
|
|
3674
3694
|
service: svc.name,
|
|
@@ -3691,20 +3711,27 @@ function coverageCaptureContext(svc: NamedService, signal: AbortSignal): Coverag
|
|
|
3691
3711
|
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
|
|
3692
3712
|
await fs.writeFile(tmp, content);
|
|
3693
3713
|
await fs.rename(tmp, file);
|
|
3714
|
+
written.add(path.relative(reportDir, file));
|
|
3694
3715
|
},
|
|
3695
3716
|
};
|
|
3696
3717
|
}
|
|
3697
3718
|
|
|
3698
3719
|
/** Run one service's adapters in order under one budget. Returns the
|
|
3699
|
-
* error to fail the capture with, naming the adapter
|
|
3700
|
-
|
|
3720
|
+
* error to fail the capture with, naming the adapter, and the reports
|
|
3721
|
+
* written by adapters that declare `reports: "delta"`. */
|
|
3722
|
+
async function runCoverageAdapters(
|
|
3723
|
+
svc: NamedService,
|
|
3724
|
+
): Promise<{ error?: string; deltaReports: Set<string> }> {
|
|
3725
|
+
const deltaReports = new Set<string>();
|
|
3701
3726
|
const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
|
|
3702
|
-
if (adapters.length === 0) return
|
|
3727
|
+
if (adapters.length === 0) return { deltaReports };
|
|
3703
3728
|
const ac = new AbortController();
|
|
3704
3729
|
const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3705
|
-
const
|
|
3730
|
+
const written = new Set<string>();
|
|
3731
|
+
const ctx = coverageCaptureContext(svc, ac.signal, written);
|
|
3706
3732
|
try {
|
|
3707
3733
|
for (const a of adapters) {
|
|
3734
|
+
written.clear();
|
|
3708
3735
|
try {
|
|
3709
3736
|
await Promise.race([
|
|
3710
3737
|
(a as Required<Pick<CoverageAdapter, "capture">>).capture(ctx),
|
|
@@ -3717,12 +3744,16 @@ async function runCoverageAdapters(svc: NamedService): Promise<string | undefine
|
|
|
3717
3744
|
}),
|
|
3718
3745
|
]);
|
|
3719
3746
|
} catch (err) {
|
|
3720
|
-
return
|
|
3721
|
-
|
|
3722
|
-
|
|
3747
|
+
return {
|
|
3748
|
+
error: `coverage adapter "${a.name}" on service "${svc.name}" failed: ${
|
|
3749
|
+
err instanceof Error ? err.message : String(err)
|
|
3750
|
+
}`,
|
|
3751
|
+
deltaReports,
|
|
3752
|
+
};
|
|
3723
3753
|
}
|
|
3754
|
+
if (a.reports === "delta") for (const n of written) deltaReports.add(n);
|
|
3724
3755
|
}
|
|
3725
|
-
return
|
|
3756
|
+
return { deltaReports };
|
|
3726
3757
|
} finally {
|
|
3727
3758
|
clearTimeout(timer);
|
|
3728
3759
|
}
|
|
@@ -3737,9 +3768,14 @@ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
|
|
|
3737
3768
|
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
3738
3769
|
return Promise.all(
|
|
3739
3770
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
3740
|
-
const error = await runCoverageAdapters(svc);
|
|
3771
|
+
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
3741
3772
|
if (error) return { service: svc.name, reports: [], error };
|
|
3742
|
-
|
|
3773
|
+
const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3774
|
+
// What ships is what ran during this capture, whatever the tool
|
|
3775
|
+
// counts: cumulative lcov is subtracted against the previous
|
|
3776
|
+
// capture (module memory, forks with the env); a delta adapter's
|
|
3777
|
+
// files and V8 JSON pass through.
|
|
3778
|
+
return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
|
|
3743
3779
|
}),
|
|
3744
3780
|
);
|
|
3745
3781
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
decodeSourceMap,
|
|
4
4
|
decodeVlq,
|
|
5
|
+
isDocumentScriptPath,
|
|
5
6
|
isForeignSource,
|
|
6
7
|
lcovDocument,
|
|
7
8
|
mapToOriginal,
|
|
@@ -92,3 +93,19 @@ describe("serviceForHost", () => {
|
|
|
92
93
|
expect(serviceForHost("example.com", services, table)).toBeUndefined();
|
|
93
94
|
});
|
|
94
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("turbopack + inline scripts", () => {
|
|
98
|
+
test("[project] root token is stripped, [turbopack] shims are foreign", () => {
|
|
99
|
+
expect(normalizeSourcePath("/[project]/dashboard/app/page.tsx")).toBe("dashboard/app/page.tsx");
|
|
100
|
+
expect(normalizeSourcePath("[project]/dashboard/app/page.tsx")).toBe("dashboard/app/page.tsx");
|
|
101
|
+
expect(isForeignSource("/[turbopack]/browser/runtime/base/build-base.ts")).toBe(true);
|
|
102
|
+
expect(isForeignSource("[turbopack]/shared/runtime-utils.ts")).toBe(true);
|
|
103
|
+
expect(isForeignSource("dashboard/app/page.tsx")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
test("an unmapped script at a page URL is a document script, a .js file is not", () => {
|
|
106
|
+
expect(isDocumentScriptPath("/")).toBe(true);
|
|
107
|
+
expect(isDocumentScriptPath("/projects/proj_1")).toBe(true);
|
|
108
|
+
expect(isDocumentScriptPath("/_next/static/chunks/main.js")).toBe(false);
|
|
109
|
+
expect(isDocumentScriptPath("/app.js")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -136,6 +136,10 @@ export function normalizeSourcePath(src: string, sourceRoot?: string): string {
|
|
|
136
136
|
if (m) s = m[1];
|
|
137
137
|
s = s.replace(/^\/@fs\//, "/");
|
|
138
138
|
s = s.replace(/\?.*$/, "");
|
|
139
|
+
// Turbopack (Next.js) roots every source at the `[project]` token —
|
|
140
|
+
// `/[project]/dashboard/app/page.tsx` — which is the workspace root,
|
|
141
|
+
// i.e. repo-relative once stripped.
|
|
142
|
+
s = s.replace(/^\/?\[project\]\//, "");
|
|
139
143
|
while (s.startsWith("./")) s = s.slice(2);
|
|
140
144
|
return s;
|
|
141
145
|
}
|
|
@@ -150,10 +154,24 @@ export function isForeignSource(path: string): boolean {
|
|
|
150
154
|
path.startsWith("(webpack)") ||
|
|
151
155
|
path.startsWith("\0") ||
|
|
152
156
|
path.startsWith("vite/") ||
|
|
153
|
-
path.startsWith("@vite/")
|
|
157
|
+
path.startsWith("@vite/") ||
|
|
158
|
+
path.startsWith("[turbopack]/") ||
|
|
159
|
+
path.startsWith("/[turbopack]/")
|
|
154
160
|
);
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A script with no source map is attributed to its served path — unless
|
|
165
|
+
* that path is a page, not a file: an inline `<script>` in an HTML
|
|
166
|
+
* document reports the document's URL (`/`, `/projects/123`), and no
|
|
167
|
+
* repository file can ever be named by it. Extension-less paths are
|
|
168
|
+
* treated as pages.
|
|
169
|
+
*/
|
|
170
|
+
export function isDocumentScriptPath(pathname: string): boolean {
|
|
171
|
+
const last = pathname.split("/").pop() ?? "";
|
|
172
|
+
return !last.includes(".");
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
/**
|
|
158
176
|
* Decode a source map document. Index maps (`sections`) are not
|
|
159
177
|
* supported and decode to `null`, as does anything malformed — the
|
|
@@ -131,3 +131,75 @@ test("bundle round-trips and the ref summarises it", () => {
|
|
|
131
131
|
test("coverageHostDir sits under the workspace's .spectest", () => {
|
|
132
132
|
expect(coverageHostDir("/workspace", "api")).toBe("/workspace/.spectest/coverage/api");
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
import {
|
|
136
|
+
applyCoverageDelta,
|
|
137
|
+
parseLcovCounts,
|
|
138
|
+
renderLcovCounts,
|
|
139
|
+
resetCoverageDeltaMemory,
|
|
140
|
+
subtractLcovCounts,
|
|
141
|
+
} from "./coverage.js";
|
|
142
|
+
|
|
143
|
+
describe("coverage delta", () => {
|
|
144
|
+
const boot = "TN:\nSF:/app/a.py\nFN:1,main\nFNDA:1,main\nDA:1,1\nDA:2,1\nDA:3,0\nend_of_record\nSF:/app/b.py\nDA:1,5\nend_of_record\n";
|
|
145
|
+
const later = "TN:\nSF:/app/a.py\nFN:1,main\nFNDA:1,main\nDA:1,1\nDA:2,1\nDA:3,4\nend_of_record\nSF:/app/b.py\nDA:1,5\nend_of_record\nSF:/app/c.py\nDA:1,2\nend_of_record\n";
|
|
146
|
+
|
|
147
|
+
test("parse merges records for one path and reads DA/FNDA/FN", () => {
|
|
148
|
+
const c = parseLcovCounts("SF:/x\nDA:1,1\nend_of_record\nSF:/x\nDA:1,2\nFN:3,f\nFNDA:4,f\nend_of_record\nSF:\nDA:9,9\n");
|
|
149
|
+
expect([...c.keys()]).toEqual(["/x"]);
|
|
150
|
+
expect(c.get("/x")!.lines.get(1)).toBe(3);
|
|
151
|
+
expect(c.get("/x")!.functions.get("f")).toBe(4);
|
|
152
|
+
expect(c.get("/x")!.functionLines.get("f")).toBe(3);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("subtract keeps only what ran since; a dropped counter is a restart; a new file counts whole", () => {
|
|
156
|
+
const d = subtractLcovCounts(parseLcovCounts(later), parseLcovCounts(boot));
|
|
157
|
+
expect([...d.keys()].sort()).toEqual(["/app/a.py", "/app/c.py"]); // b.py: nothing new
|
|
158
|
+
expect(d.get("/app/a.py")!.lines.get(3)).toBe(4);
|
|
159
|
+
expect(d.get("/app/a.py")!.lines.get(1)).toBe(0);
|
|
160
|
+
expect(d.get("/app/a.py")!.functions.get("main")).toBe(0);
|
|
161
|
+
expect(d.get("/app/c.py")!.lines.get(1)).toBe(2);
|
|
162
|
+
const restarted = subtractLcovCounts(parseLcovCounts("SF:/app/b.py\nDA:1,2\nend_of_record\n"), parseLcovCounts(boot));
|
|
163
|
+
expect(restarted.get("/app/b.py")!.lines.get(1)).toBe(2);
|
|
164
|
+
// A record with no counts at all: presence is its signal, new only once.
|
|
165
|
+
const bare = "SF:/app/bare.py\nend_of_record\n";
|
|
166
|
+
expect(subtractLcovCounts(parseLcovCounts(bare), new Map()).has("/app/bare.py")).toBe(true);
|
|
167
|
+
expect(subtractLcovCounts(parseLcovCounts(bare), parseLcovCounts(bare)).has("/app/bare.py")).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("render round-trips and totals the header counts", () => {
|
|
171
|
+
const text = renderLcovCounts(subtractLcovCounts(parseLcovCounts(later), parseLcovCounts(boot)));
|
|
172
|
+
expect(text).toBe(
|
|
173
|
+
"TN:\nSF:/app/a.py\nFN:1,main\nFNDA:0,main\nFNF:1\nFNH:0\nDA:1,0\nDA:2,0\nDA:3,4\nLF:3\nLH:1\nend_of_record\nSF:/app/c.py\nFNF:0\nFNH:0\nDA:1,2\nLF:1\nLH:1\nend_of_record\n",
|
|
174
|
+
);
|
|
175
|
+
expect(parseLcovCounts(text).get("/app/a.py")!.lines.get(3)).toBe(4);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("applyCoverageDelta: cumulative reports are subtracted per service and name; delta and V8 reports pass", () => {
|
|
179
|
+
resetCoverageDeltaMemory();
|
|
180
|
+
const first = applyCoverageDelta(
|
|
181
|
+
{ service: "api", reports: [{ name: "cov.lcov", content: boot }, { name: "node.lcov", content: later }, { name: "coverage-1.json", content: '{"result":[]}' }] },
|
|
182
|
+
"cumulative",
|
|
183
|
+
new Set(["node.lcov"]),
|
|
184
|
+
);
|
|
185
|
+
// First capture: no previous, the whole boot set.
|
|
186
|
+
expect(first.reports[0]!.content).toContain("SF:/app/a.py");
|
|
187
|
+
expect(first.reports[0]!.content).toContain("SF:/app/b.py");
|
|
188
|
+
expect(first.reports[1]!.content).toBe(later); // delta adapter's own file, untouched
|
|
189
|
+
expect(first.reports[2]!.content).toBe('{"result":[]}');
|
|
190
|
+
const second = applyCoverageDelta({ service: "api", reports: [{ name: "cov.lcov", content: later }] }, "cumulative", new Set());
|
|
191
|
+
expect(second.reports[0]!.content).not.toContain("SF:/app/b.py");
|
|
192
|
+
expect(second.reports[0]!.content).toContain("DA:3,4");
|
|
193
|
+
// Nothing new: an lcov with no records.
|
|
194
|
+
const third = applyCoverageDelta({ service: "api", reports: [{ name: "cov.lcov", content: later }] }, "cumulative", new Set());
|
|
195
|
+
expect(third.reports[0]!.content).toBe("TN:\n");
|
|
196
|
+
// Another service has its own memory; a delta-mode service is never touched.
|
|
197
|
+
const other = applyCoverageDelta({ service: "web", reports: [{ name: "cov.lcov", content: later }] }, "cumulative", new Set());
|
|
198
|
+
expect(other.reports[0]!.content).toContain("SF:/app/b.py");
|
|
199
|
+
const asIs = applyCoverageDelta({ service: "api", reports: [{ name: "cov.lcov", content: later }] }, "delta", new Set());
|
|
200
|
+
expect(asIs.reports[0]!.content).toBe(later);
|
|
201
|
+
// An errored capture passes through.
|
|
202
|
+
const err = applyCoverageDelta({ service: "api", reports: [], error: "x" }, "cumulative", new Set());
|
|
203
|
+
expect(err.error).toBe("x");
|
|
204
|
+
});
|
|
205
|
+
});
|
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,159 @@ 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 count that went
|
|
281
|
+
* *down* is a counter that restarted (the process was replaced): the
|
|
282
|
+
* new value stands on its own. A file not in `prev` is new: everything
|
|
283
|
+
* in it counts. Only records with at least one execution are kept — a
|
|
284
|
+
* file with nothing new in it did not run.
|
|
285
|
+
*/
|
|
286
|
+
export function subtractLcovCounts(
|
|
287
|
+
now: Map<string, LcovRecordCounts>,
|
|
288
|
+
prev: Map<string, LcovRecordCounts>,
|
|
289
|
+
): Map<string, LcovRecordCounts> {
|
|
290
|
+
const out = new Map<string, LcovRecordCounts>();
|
|
291
|
+
for (const [file, rec] of now) {
|
|
292
|
+
const before = prev.get(file);
|
|
293
|
+
const lines = new Map<number, number>();
|
|
294
|
+
const functions = new Map<string, number>();
|
|
295
|
+
let executed = false;
|
|
296
|
+
for (const [l, h] of rec.lines) {
|
|
297
|
+
const b = before?.lines.get(l) ?? 0;
|
|
298
|
+
const d = h < b ? h : h - b;
|
|
299
|
+
lines.set(l, d);
|
|
300
|
+
if (d > 0) executed = true;
|
|
301
|
+
}
|
|
302
|
+
for (const [name, h] of rec.functions) {
|
|
303
|
+
const b = before?.functions.get(name) ?? 0;
|
|
304
|
+
const d = h < b ? h : h - b;
|
|
305
|
+
functions.set(name, d);
|
|
306
|
+
if (d > 0) executed = true;
|
|
307
|
+
}
|
|
308
|
+
// A record with no counts at all carries presence as its only
|
|
309
|
+
// signal; it is new only when it was not there before.
|
|
310
|
+
if (rec.lines.size === 0 && rec.functions.size === 0 && !before) executed = true;
|
|
311
|
+
if (executed) out.set(file, { lines, functions, functionLines: rec.functionLines });
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Counts rendered back to lcov, records in path order. */
|
|
317
|
+
export function renderLcovCounts(counts: Map<string, LcovRecordCounts>): string {
|
|
318
|
+
const parts: string[] = ["TN:"];
|
|
319
|
+
for (const file of [...counts.keys()].sort()) {
|
|
320
|
+
const rec = counts.get(file)!;
|
|
321
|
+
parts.push(`SF:${file}`);
|
|
322
|
+
for (const [name, l] of [...rec.functionLines].sort((a, b) => a[1] - b[1])) parts.push(`FN:${l},${name}`);
|
|
323
|
+
let fnh = 0;
|
|
324
|
+
for (const [name, h] of rec.functions) {
|
|
325
|
+
parts.push(`FNDA:${h},${name}`);
|
|
326
|
+
if (h > 0) fnh++;
|
|
327
|
+
}
|
|
328
|
+
parts.push(`FNF:${rec.functions.size}`, `FNH:${fnh}`);
|
|
329
|
+
let lh = 0;
|
|
330
|
+
for (const [l, h] of [...rec.lines].sort((a, b) => a[0] - b[0])) {
|
|
331
|
+
parts.push(`DA:${l},${h}`);
|
|
332
|
+
if (h > 0) lh++;
|
|
333
|
+
}
|
|
334
|
+
parts.push(`LF:${rec.lines.size}`, `LH:${lh}`, "end_of_record");
|
|
335
|
+
}
|
|
336
|
+
return parts.join("\n") + "\n";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The previous capture's counts: service → report name → parsed lcov.
|
|
340
|
+
* Module memory, so it forks with the environment — a forked child's
|
|
341
|
+
* previous capture is its parent's last one. */
|
|
342
|
+
const PREVIOUS_CAPTURE = new Map<string, Map<string, Map<string, LcovRecordCounts>>>();
|
|
343
|
+
|
|
344
|
+
/** Forget every previous capture (tests). */
|
|
345
|
+
export function resetCoverageDeltaMemory(): void {
|
|
346
|
+
PREVIOUS_CAPTURE.clear();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Turn a capture's cumulative lcov reports into deltas against the
|
|
351
|
+
* previous capture of the same service and report name, and remember
|
|
352
|
+
* this capture as the new previous. Reports named in `deltaReports`
|
|
353
|
+
* (written by an adapter that declares `reports: "delta"`) and
|
|
354
|
+
* non-lcov reports (V8 JSON is a delta by construction) pass through
|
|
355
|
+
* untouched. With `mode === "delta"` every report passes through.
|
|
356
|
+
*/
|
|
357
|
+
export function applyCoverageDelta(
|
|
358
|
+
capture: ServiceCoverageCapture,
|
|
359
|
+
mode: CoverageReportsMode,
|
|
360
|
+
deltaReports: ReadonlySet<string>,
|
|
361
|
+
): ServiceCoverageCapture {
|
|
362
|
+
if (capture.error) return capture;
|
|
363
|
+
let prevByName = PREVIOUS_CAPTURE.get(capture.service);
|
|
364
|
+
if (!prevByName) {
|
|
365
|
+
prevByName = new Map();
|
|
366
|
+
PREVIOUS_CAPTURE.set(capture.service, prevByName);
|
|
367
|
+
}
|
|
368
|
+
const reports: CoverageReport[] = [];
|
|
369
|
+
for (const r of capture.reports) {
|
|
370
|
+
if (mode === "delta" || deltaReports.has(r.name) || !isLcovShaped(r.content)) {
|
|
371
|
+
reports.push(r);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const now = parseLcovCounts(r.content);
|
|
375
|
+
const prev = prevByName.get(r.name) ?? new Map<string, LcovRecordCounts>();
|
|
376
|
+
prevByName.set(r.name, now);
|
|
377
|
+
reports.push({ name: r.name, content: renderLcovCounts(subtractLcovCounts(now, prev)) });
|
|
378
|
+
}
|
|
379
|
+
return { ...capture, reports };
|
|
380
|
+
}
|