@specific.dev/spectest 0.57.0 → 0.59.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 +20 -0
- package/dist/browser-coverage.js +185 -0
- package/dist/browser.js +36 -0
- package/dist/coverage.d.ts +124 -0
- package/dist/coverage.js +236 -0
- package/dist/daemon.js +138 -24
- package/dist/harness/browser-coverage.d.ts +95 -0
- package/dist/harness/browser-coverage.js +257 -0
- package/dist/harness/coverage.d.ts +23 -4
- package/dist/harness/coverage.js +50 -14
- package/dist/index.d.ts +18 -25
- package/dist/index.js +6 -16
- package/package.json +6 -1
- package/src/browser-coverage.ts +209 -0
- package/src/browser.ts +36 -0
- package/src/coverage.test.ts +192 -0
- package/src/coverage.ts +327 -0
- package/src/daemon.ts +152 -26
- package/src/harness/browser-coverage.test.ts +94 -0
- package/src/harness/browser-coverage.ts +310 -0
- package/src/harness/coverage.test.ts +46 -1
- package/src/harness/coverage.ts +53 -13
- package/src/index.ts +36 -38
package/src/daemon.ts
CHANGED
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
dnsName as makeDnsDecl,
|
|
33
33
|
isWildcard,
|
|
34
34
|
proxy as makeProxyDecl,
|
|
35
|
-
validateCoverage,
|
|
36
35
|
} from "./index.js";
|
|
37
36
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
38
37
|
import {
|
|
@@ -40,10 +39,21 @@ import {
|
|
|
40
39
|
coverageBundleRef,
|
|
41
40
|
coverageHostDir,
|
|
42
41
|
encodeCoverageBundle,
|
|
42
|
+
isEmptyReport,
|
|
43
43
|
readCoverageDir,
|
|
44
44
|
type CoverageBundleRef,
|
|
45
|
+
type CoverageReport,
|
|
45
46
|
type ServiceCoverageCapture,
|
|
46
47
|
} from "./harness/coverage.js";
|
|
48
|
+
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
49
|
+
import {
|
|
50
|
+
applyCoverageAdapters,
|
|
51
|
+
coverageAdapters,
|
|
52
|
+
validateCoverage,
|
|
53
|
+
type CoverageAdapter,
|
|
54
|
+
type CoverageCaptureContext,
|
|
55
|
+
} from "./coverage.js";
|
|
56
|
+
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
47
57
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
48
58
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
49
59
|
// Pure ingress hostname matching, ported out of this file (see
|
|
@@ -1802,6 +1812,11 @@ function buildIngress(project: Project): void {
|
|
|
1802
1812
|
// generic decl set the daemon executes. The special-casing lives in the
|
|
1803
1813
|
// SDK's lowerIngress, not here.
|
|
1804
1814
|
LOWERED = lowerIngress(project);
|
|
1815
|
+
BROWSER_COVERAGE_SERVICES.clear();
|
|
1816
|
+
void runCoverageLoadHooks(namedServices(project.environment)).catch((err) => {
|
|
1817
|
+
// eslint-disable-next-line no-console
|
|
1818
|
+
console.warn("[coverage] load hook failed:", err);
|
|
1819
|
+
});
|
|
1805
1820
|
if (!project.fakes) return;
|
|
1806
1821
|
for (const [name, def] of Object.entries(project.fakes)) {
|
|
1807
1822
|
FAKES.set(name, {
|
|
@@ -2785,7 +2800,9 @@ const RUNTIME_SERVICES = new Map<string, NamedService>();
|
|
|
2785
2800
|
// the orchestration helpers want a NamedService, which is the same shape.
|
|
2786
2801
|
function specToNamedService(spec: RuntimeServiceSpec): NamedService {
|
|
2787
2802
|
const { name, ...rest } = spec;
|
|
2788
|
-
|
|
2803
|
+
// A runtime spec skipped `defineEnvironment`, so its coverage adapters
|
|
2804
|
+
// get their config-time pass here.
|
|
2805
|
+
return { name, ...applyCoverageAdapters(name, rest as ServiceConfig) };
|
|
2789
2806
|
}
|
|
2790
2807
|
|
|
2791
2808
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
@@ -2799,6 +2816,7 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
|
|
|
2799
2816
|
const t0 = Date.now();
|
|
2800
2817
|
const resv = reserveEvent();
|
|
2801
2818
|
const svc = specToNamedService(spec);
|
|
2819
|
+
await runCoverageLoadHooks([svc]);
|
|
2802
2820
|
const aliases = (spec.hostnames ?? []).map((h) => h.toLowerCase());
|
|
2803
2821
|
const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
|
|
2804
2822
|
try {
|
|
@@ -3589,17 +3607,126 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
|
|
|
3589
3607
|
}
|
|
3590
3608
|
|
|
3591
3609
|
// ────────────────────────────────────────────────────────────────────────
|
|
3592
|
-
// Coverage capture (opt-in per service; see harness/coverage.ts)
|
|
3610
|
+
// Coverage capture (opt-in per service; see coverage.ts + harness/coverage.ts)
|
|
3593
3611
|
// ────────────────────────────────────────────────────────────────────────
|
|
3594
3612
|
//
|
|
3595
|
-
// Read after bring-up and after every test, on every case.
|
|
3596
|
-
//
|
|
3597
|
-
//
|
|
3598
|
-
//
|
|
3599
|
-
//
|
|
3600
|
-
//
|
|
3613
|
+
// Read after bring-up and after every test, on every case. A service's
|
|
3614
|
+
// adapters run first (each makes its report appear in the directory —
|
|
3615
|
+
// `nodeCoverage` asks the hook for a dump, `browserCoverage` writes the
|
|
3616
|
+
// harness's totals, `commandCoverage` runs the command), then the
|
|
3617
|
+
// directory is read the same way for every service, `coverage: true`
|
|
3618
|
+
// included. The reports live on the rootfs, so what a child fork reads
|
|
3619
|
+
// already includes everything its ancestors ran — no markers, no deltas
|
|
3620
|
+
// (unlike the service logs above). The reports ship verbatim; the server
|
|
3621
|
+
// derives what it needs from the stored bytes.
|
|
3622
|
+
|
|
3623
|
+
/** Budget for one service's adapters at one capture, all of them. */
|
|
3624
|
+
const COVERAGE_CAPTURE_TIMEOUT_MS = 60_000;
|
|
3625
|
+
|
|
3626
|
+
/** Services whose adapters asked for browser scripts at /load
|
|
3627
|
+
* (`CoverageLoadContext.collectBrowserScripts`). Cleared on every /load;
|
|
3628
|
+
* a runtime service's adapters add to it when it starts. */
|
|
3629
|
+
const BROWSER_COVERAGE_SERVICES = new Set<string>();
|
|
3630
|
+
|
|
3631
|
+
/** Run every adapter's `load` hook for `services`, then rebuild the
|
|
3632
|
+
* browser-coverage attribution from whoever asked. The attribution is a
|
|
3633
|
+
* host → service resolver over the lowered ingress, restricted to those
|
|
3634
|
+
* services, so a script from an uncovered service is ignored; null when
|
|
3635
|
+
* nobody asked, which costs nothing. */
|
|
3636
|
+
async function runCoverageLoadHooks(services: NamedService[]): Promise<void> {
|
|
3637
|
+
for (const svc of services) {
|
|
3638
|
+
for (const a of coverageAdapters(svc.coverage)) {
|
|
3639
|
+
if (!a.load) continue;
|
|
3640
|
+
await a.load({
|
|
3641
|
+
service: svc.name,
|
|
3642
|
+
collectBrowserScripts: () => {
|
|
3643
|
+
BROWSER_COVERAGE_SERVICES.add(svc.name);
|
|
3644
|
+
},
|
|
3645
|
+
});
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
rebuildBrowserCoverageResolver();
|
|
3649
|
+
}
|
|
3601
3650
|
|
|
3602
|
-
|
|
3651
|
+
function rebuildBrowserCoverageResolver(): void {
|
|
3652
|
+
if (BROWSER_COVERAGE_SERVICES.size === 0 || !loaded) {
|
|
3653
|
+
configureBrowserCoverage(null);
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3656
|
+
const all = [...namedServices(loaded.project.environment).map((s) => s.name), ...RUNTIME_SERVICES.keys()];
|
|
3657
|
+
const table = {
|
|
3658
|
+
proxies: LOWERED.proxies,
|
|
3659
|
+
aliasesByService: LOWERED.aliasesByService,
|
|
3660
|
+
wildcards: LOWERED.wildcards,
|
|
3661
|
+
};
|
|
3662
|
+
configureBrowserCoverage({
|
|
3663
|
+
serviceForHost(host: string): string | undefined {
|
|
3664
|
+
const svc = hostToService(host, all, table);
|
|
3665
|
+
return svc && BROWSER_COVERAGE_SERVICES.has(svc) ? svc : undefined;
|
|
3666
|
+
},
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
/** The context one adapter's `capture` runs against. */
|
|
3671
|
+
function coverageCaptureContext(svc: NamedService, signal: AbortSignal): CoverageCaptureContext {
|
|
3672
|
+
const reportDir = coverageHostDir(WORKSPACE, svc.name);
|
|
3673
|
+
return {
|
|
3674
|
+
service: svc.name,
|
|
3675
|
+
reportDir,
|
|
3676
|
+
signal,
|
|
3677
|
+
async exec(command: string) {
|
|
3678
|
+
const r = await docker(["exec", svc.name, "sh", "-c", command], COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3679
|
+
if (r.code !== 0) {
|
|
3680
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3681
|
+
throw new Error(`${JSON.stringify(command)} exited ${r.code}${tail ? `\n${tail}` : ""}`);
|
|
3682
|
+
}
|
|
3683
|
+
return { stdout: r.stdout, stderr: r.stderr };
|
|
3684
|
+
},
|
|
3685
|
+
async writeReport(name: string, content: string) {
|
|
3686
|
+
const file = path.join(reportDir, name);
|
|
3687
|
+
if (path.relative(reportDir, file).startsWith("..")) {
|
|
3688
|
+
throw new Error(`writeReport: ${JSON.stringify(name)} leaves the coverage directory`);
|
|
3689
|
+
}
|
|
3690
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
3691
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
|
|
3692
|
+
await fs.writeFile(tmp, content);
|
|
3693
|
+
await fs.rename(tmp, file);
|
|
3694
|
+
},
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
/** Run one service's adapters in order under one budget. Returns the
|
|
3699
|
+
* error to fail the capture with, naming the adapter. */
|
|
3700
|
+
async function runCoverageAdapters(svc: NamedService): Promise<string | undefined> {
|
|
3701
|
+
const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
|
|
3702
|
+
if (adapters.length === 0) return undefined;
|
|
3703
|
+
const ac = new AbortController();
|
|
3704
|
+
const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3705
|
+
const ctx = coverageCaptureContext(svc, ac.signal);
|
|
3706
|
+
try {
|
|
3707
|
+
for (const a of adapters) {
|
|
3708
|
+
try {
|
|
3709
|
+
await Promise.race([
|
|
3710
|
+
(a as Required<Pick<CoverageAdapter, "capture">>).capture(ctx),
|
|
3711
|
+
new Promise<never>((_, reject) => {
|
|
3712
|
+
ac.signal.addEventListener(
|
|
3713
|
+
"abort",
|
|
3714
|
+
() => reject(new Error(`no report within ${COVERAGE_CAPTURE_TIMEOUT_MS / 1000}s`)),
|
|
3715
|
+
{ once: true },
|
|
3716
|
+
);
|
|
3717
|
+
}),
|
|
3718
|
+
]);
|
|
3719
|
+
} catch (err) {
|
|
3720
|
+
return `coverage adapter "${a.name}" on service "${svc.name}" failed: ${
|
|
3721
|
+
err instanceof Error ? err.message : String(err)
|
|
3722
|
+
}`;
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
return undefined;
|
|
3726
|
+
} finally {
|
|
3727
|
+
clearTimeout(timer);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3603
3730
|
|
|
3604
3731
|
async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
|
|
3605
3732
|
const l = loaded;
|
|
@@ -3610,21 +3737,8 @@ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
|
|
|
3610
3737
|
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
3611
3738
|
return Promise.all(
|
|
3612
3739
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
3613
|
-
const
|
|
3614
|
-
if (
|
|
3615
|
-
const r = await docker(
|
|
3616
|
-
["exec", svc.name, "sh", "-c", cov.command],
|
|
3617
|
-
COVERAGE_COMMAND_TIMEOUT_MS,
|
|
3618
|
-
);
|
|
3619
|
-
if (r.code !== 0) {
|
|
3620
|
-
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3621
|
-
return {
|
|
3622
|
-
service: svc.name,
|
|
3623
|
-
reports: [],
|
|
3624
|
-
error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
|
|
3625
|
-
};
|
|
3626
|
-
}
|
|
3627
|
-
}
|
|
3740
|
+
const error = await runCoverageAdapters(svc);
|
|
3741
|
+
if (error) return { service: svc.name, reports: [], error };
|
|
3628
3742
|
return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3629
3743
|
}),
|
|
3630
3744
|
);
|
|
@@ -6078,7 +6192,19 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6078
6192
|
// the control plane logs it.
|
|
6079
6193
|
let coverage: CoverageBundleRef | undefined;
|
|
6080
6194
|
try {
|
|
6081
|
-
|
|
6195
|
+
const captures = await captureServiceCoverage();
|
|
6196
|
+
for (const c of captures) {
|
|
6197
|
+
// After bring-up the code that started the service has run, so a
|
|
6198
|
+
// report set that names no file at all is a misconfigured tool —
|
|
6199
|
+
// visible in the boot log, never a failure (there is no case).
|
|
6200
|
+
if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
|
|
6201
|
+
// eslint-disable-next-line no-console
|
|
6202
|
+
console.warn(
|
|
6203
|
+
`[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`,
|
|
6204
|
+
);
|
|
6205
|
+
}
|
|
6206
|
+
}
|
|
6207
|
+
coverage = parkCoverageBundle(SETUP_CASE_ID, captures);
|
|
6082
6208
|
} catch (err) {
|
|
6083
6209
|
// eslint-disable-next-line no-console
|
|
6084
6210
|
console.warn("[coverage] baseline capture failed:", err);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeSourceMap,
|
|
4
|
+
decodeVlq,
|
|
5
|
+
isForeignSource,
|
|
6
|
+
lcovDocument,
|
|
7
|
+
mapToOriginal,
|
|
8
|
+
mergeFileCounts,
|
|
9
|
+
normalizeSourcePath,
|
|
10
|
+
rangesToLineCounts,
|
|
11
|
+
serviceForHost,
|
|
12
|
+
} from "./browser-coverage";
|
|
13
|
+
|
|
14
|
+
describe("rangesToLineCounts", () => {
|
|
15
|
+
test("outer range sets lines, narrower ranges override, blanks dropped", () => {
|
|
16
|
+
const src = "function f() {\n if (x) {\n a();\n }\n\n b();\n}\n";
|
|
17
|
+
const fnRange = { startOffset: 0, endOffset: src.length, count: 3 };
|
|
18
|
+
const ifBlock = { startOffset: src.indexOf("{\n a"), endOffset: src.indexOf("}\n\n") + 1, count: 0 };
|
|
19
|
+
const counts = rangesToLineCounts(src, [{ ranges: [fnRange, ifBlock] }]);
|
|
20
|
+
expect(counts.get(1)).toBe(3);
|
|
21
|
+
expect(counts.get(3)).toBe(0);
|
|
22
|
+
expect(counts.get(6)).toBe(3);
|
|
23
|
+
expect(counts.has(5)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("source maps", () => {
|
|
28
|
+
test("decodeVlq", () => {
|
|
29
|
+
expect(decodeVlq("AAAA")).toEqual([0, 0, 0, 0]);
|
|
30
|
+
expect(decodeVlq("IACC")).toEqual([4, 0, 1, 1]);
|
|
31
|
+
expect(decodeVlq("D")).toEqual([-1]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("decode + map generated lines onto original files", () => {
|
|
35
|
+
// Two generated lines: line 1 maps to src/a.ts:1 and node_modules/x.js:1,
|
|
36
|
+
// line 2 maps to src/b.ts:5 (columns irrelevant for line attribution).
|
|
37
|
+
const map = decodeSourceMap({
|
|
38
|
+
version: 3,
|
|
39
|
+
sources: ["webpack:///./src/a.ts", "webpack:///./node_modules/x.js", "./src/b.ts"],
|
|
40
|
+
mappings: "AAAA,ICAA;ACIA",
|
|
41
|
+
});
|
|
42
|
+
expect(map).not.toBeNull();
|
|
43
|
+
expect(map!.sources).toEqual(["src/a.ts", "node_modules/x.js", "src/b.ts"]);
|
|
44
|
+
const files = mapToOriginal(map!, new Map([[1, 2], [2, 0]]));
|
|
45
|
+
expect([...files.keys()]).toEqual(["src/a.ts", "src/b.ts"]);
|
|
46
|
+
expect(files.get("src/a.ts")!.get(1)).toBe(2);
|
|
47
|
+
expect(files.get("src/b.ts")!.get(5)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("index maps and garbage decode to null", () => {
|
|
51
|
+
expect(decodeSourceMap({ version: 3, sections: [] })).toBeNull();
|
|
52
|
+
expect(decodeSourceMap({ version: 3, sources: ["a"], mappings: "!!" })).toBeNull();
|
|
53
|
+
expect(decodeSourceMap("nope")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("normalizeSourcePath strips schemes, roots and queries", () => {
|
|
57
|
+
expect(normalizeSourcePath("webpack:///./src/a.ts")).toBe("src/a.ts");
|
|
58
|
+
expect(normalizeSourcePath("http://app.test/src/a.ts?t=1")).toBe("/src/a.ts");
|
|
59
|
+
expect(normalizeSourcePath("/@fs/repo/src/a.ts")).toBe("/repo/src/a.ts");
|
|
60
|
+
expect(normalizeSourcePath("a.ts", "/app/src")).toBe("/app/src/a.ts");
|
|
61
|
+
expect(normalizeSourcePath("file:///app/src/a.ts")).toBe("/app/src/a.ts");
|
|
62
|
+
expect(isForeignSource("webpack/bootstrap")).toBe(true);
|
|
63
|
+
expect(isForeignSource("src/a.ts")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("lcovDocument is sorted, one record per file, merge sums", () => {
|
|
68
|
+
const files = new Map([["b.js", new Map([[2, 1]])], ["a.js", new Map([[1, 0], [3, 2]])]]);
|
|
69
|
+
mergeFileCounts(files, new Map([["a.js", new Map([[3, 1]])]]));
|
|
70
|
+
expect(lcovDocument(files)).toBe(
|
|
71
|
+
"TN:\nSF:a.js\nDA:1,0\nDA:3,3\nLF:2\nLH:1\nend_of_record\nSF:b.js\nDA:2,1\nLF:1\nLH:1\nend_of_record\n",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("serviceForHost", () => {
|
|
76
|
+
const table = {
|
|
77
|
+
proxies: [{ hostname: "app.test", service: "web" }],
|
|
78
|
+
aliasesByService: { api: ["api.todos.local"] },
|
|
79
|
+
wildcards: [
|
|
80
|
+
{ pattern: "*.apps.test", target: { service: "k8s" } },
|
|
81
|
+
{ pattern: "*.fake.test", target: { ingress: true } },
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
const services = ["web", "api", "k8s"];
|
|
85
|
+
test("service key, .internal, alias, proxy, wildcard", () => {
|
|
86
|
+
expect(serviceForHost("web", services, table)).toBe("web");
|
|
87
|
+
expect(serviceForHost("WEB.internal", services, table)).toBe("web");
|
|
88
|
+
expect(serviceForHost("api.todos.local", services, table)).toBe("api");
|
|
89
|
+
expect(serviceForHost("app.test", services, table)).toBe("web");
|
|
90
|
+
expect(serviceForHost("a.b.apps.test", services, table)).toBe("k8s");
|
|
91
|
+
expect(serviceForHost("x.fake.test", services, table)).toBeUndefined();
|
|
92
|
+
expect(serviceForHost("example.com", services, table)).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Browser coverage — the pure part.
|
|
2
|
+
//
|
|
3
|
+
// A frontend runs in the guest Chromium, which is spectest's own process,
|
|
4
|
+
// so the user cannot write a coverage report for it the way a service
|
|
5
|
+
// process can. A service declares `coverage: { browser: true }` and
|
|
6
|
+
// spectest collects it: V8 precise coverage over CDP for every script the
|
|
7
|
+
// browser loads from that service, mapped back to original sources
|
|
8
|
+
// through the served source maps, and written as one lcov report per
|
|
9
|
+
// service into the same `case-coverage` bundle the container path uses.
|
|
10
|
+
//
|
|
11
|
+
// This module has no CDP in it: source-map decoding, V8 range → line
|
|
12
|
+
// counts, host → service attribution, lcov output. The collector that
|
|
13
|
+
// talks to the page is `../browser-coverage.ts`.
|
|
14
|
+
|
|
15
|
+
/** Per-line hit counts, 1-based line numbers. */
|
|
16
|
+
export type LineCounts = Map<number, number>;
|
|
17
|
+
|
|
18
|
+
/** Per-file line counts. Keys are source paths as the source map (or the
|
|
19
|
+
* script URL, when there is no map) reports them. */
|
|
20
|
+
export type FileCounts = Map<string, LineCounts>;
|
|
21
|
+
|
|
22
|
+
// ── V8 precise coverage → generated line counts ─────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Byte offset at which each line starts. */
|
|
25
|
+
export function lineStartsOf(source: string): number[] {
|
|
26
|
+
const starts = [0];
|
|
27
|
+
for (let i = 0; i < source.length; i++) {
|
|
28
|
+
if (source.charCodeAt(i) === 10) starts.push(i + 1);
|
|
29
|
+
}
|
|
30
|
+
return starts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 1-based line holding `offset`. */
|
|
34
|
+
export function lineOf(starts: number[], offset: number): number {
|
|
35
|
+
let lo = 0;
|
|
36
|
+
let hi = starts.length - 1;
|
|
37
|
+
while (lo < hi) {
|
|
38
|
+
const mid = (lo + hi + 1) >> 1;
|
|
39
|
+
if (starts[mid] <= offset) lo = mid;
|
|
40
|
+
else hi = mid - 1;
|
|
41
|
+
}
|
|
42
|
+
return lo + 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One V8 `FunctionCoverage` as `Profiler.takePreciseCoverage` reports it. */
|
|
46
|
+
export interface V8FunctionCoverage {
|
|
47
|
+
functionName?: string;
|
|
48
|
+
ranges: { startOffset: number; endOffset: number; count: number }[];
|
|
49
|
+
isBlockCoverage?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fold a script's block ranges onto its lines. Ranges come outer-first
|
|
54
|
+
* and a narrower range overrides the lines it spans, so a line inside an
|
|
55
|
+
* unexecuted branch ends at 0 even though its enclosing function ran.
|
|
56
|
+
* Blank lines are dropped. Approximate — a line holding two blocks gets
|
|
57
|
+
* the narrower one's count — which is exact at the file level, the only
|
|
58
|
+
* level anything reads today.
|
|
59
|
+
*/
|
|
60
|
+
export function rangesToLineCounts(
|
|
61
|
+
source: string,
|
|
62
|
+
functions: readonly V8FunctionCoverage[],
|
|
63
|
+
): LineCounts {
|
|
64
|
+
const starts = lineStartsOf(source);
|
|
65
|
+
const counts: LineCounts = new Map();
|
|
66
|
+
for (const fn of functions) {
|
|
67
|
+
for (const r of fn.ranges) {
|
|
68
|
+
const a = lineOf(starts, r.startOffset);
|
|
69
|
+
const b = lineOf(starts, Math.max(r.startOffset, r.endOffset - 1));
|
|
70
|
+
for (let l = a; l <= b; l++) counts.set(l, r.count);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const l of [...counts.keys()]) {
|
|
74
|
+
const text = source.slice(starts[l - 1], starts[l] ?? source.length);
|
|
75
|
+
if (text.trim().length === 0) counts.delete(l);
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Source maps ─────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** One mapping segment: generated column → original (source, line). */
|
|
83
|
+
export interface Segment {
|
|
84
|
+
col: number;
|
|
85
|
+
src: number;
|
|
86
|
+
line: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DecodedSourceMap {
|
|
90
|
+
/** Normalised source paths, indexed by segment `src`. */
|
|
91
|
+
sources: string[];
|
|
92
|
+
/** Segments per generated line (0-based), sorted by column. */
|
|
93
|
+
lines: Segment[][];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
97
|
+
const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
|
|
98
|
+
|
|
99
|
+
/** Decode one VLQ-encoded segment into its fields. */
|
|
100
|
+
export function decodeVlq(segment: string): number[] {
|
|
101
|
+
const out: number[] = [];
|
|
102
|
+
let value = 0;
|
|
103
|
+
let shift = 0;
|
|
104
|
+
for (const ch of segment) {
|
|
105
|
+
const digit = B64_INDEX.get(ch);
|
|
106
|
+
if (digit === undefined) throw new Error(`bad VLQ char ${JSON.stringify(ch)}`);
|
|
107
|
+
value += (digit & 31) << shift;
|
|
108
|
+
if (digit & 32) {
|
|
109
|
+
shift += 5;
|
|
110
|
+
} else {
|
|
111
|
+
const negative = value & 1;
|
|
112
|
+
value >>>= 1;
|
|
113
|
+
out.push(negative ? -value : value);
|
|
114
|
+
value = 0;
|
|
115
|
+
shift = 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalise a source-map `sources` entry to a plain path. Bundlers prefix
|
|
123
|
+
* their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
|
|
124
|
+
* URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
|
|
125
|
+
* The server maps these onto the repo by suffix, so all that matters here
|
|
126
|
+
* is stripping what is not a path.
|
|
127
|
+
*/
|
|
128
|
+
export function normalizeSourcePath(src: string, sourceRoot?: string): string {
|
|
129
|
+
let s = src;
|
|
130
|
+
if (sourceRoot && !/^[a-z]+:/i.test(s)) {
|
|
131
|
+
s = sourceRoot.replace(/\/?$/, "/") + s;
|
|
132
|
+
}
|
|
133
|
+
s = s.replace(/^webpack:\/\/\/?/, "").replace(/^file:\/\//, "");
|
|
134
|
+
// A URL (`http://host/src/a.ts`, Vite's `/@fs/…`) keeps only its path.
|
|
135
|
+
const m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(s);
|
|
136
|
+
if (m) s = m[1];
|
|
137
|
+
s = s.replace(/^\/@fs\//, "/");
|
|
138
|
+
s = s.replace(/\?.*$/, "");
|
|
139
|
+
while (s.startsWith("./")) s = s.slice(2);
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
144
|
+
* runtime shims. */
|
|
145
|
+
export function isForeignSource(path: string): boolean {
|
|
146
|
+
return (
|
|
147
|
+
path.length === 0 ||
|
|
148
|
+
path.includes("node_modules/") ||
|
|
149
|
+
path.startsWith("webpack/") ||
|
|
150
|
+
path.startsWith("(webpack)") ||
|
|
151
|
+
path.startsWith("\0") ||
|
|
152
|
+
path.startsWith("vite/") ||
|
|
153
|
+
path.startsWith("@vite/")
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Decode a source map document. Index maps (`sections`) are not
|
|
159
|
+
* supported and decode to `null`, as does anything malformed — the
|
|
160
|
+
* caller then falls back to attributing the script to its own URL, which
|
|
161
|
+
* the repo mapping cannot resolve, which is the safe answer.
|
|
162
|
+
*/
|
|
163
|
+
export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
|
|
164
|
+
if (!doc || typeof doc !== "object") return null;
|
|
165
|
+
const m = doc as {
|
|
166
|
+
version?: number;
|
|
167
|
+
sources?: unknown;
|
|
168
|
+
sourceRoot?: unknown;
|
|
169
|
+
mappings?: unknown;
|
|
170
|
+
sections?: unknown;
|
|
171
|
+
};
|
|
172
|
+
if (m.sections || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
|
|
176
|
+
const sources = m.sources.map((s) =>
|
|
177
|
+
typeof s === "string" ? normalizeSourcePath(s, sourceRoot) : "",
|
|
178
|
+
);
|
|
179
|
+
const lines: Segment[][] = [];
|
|
180
|
+
let src = 0;
|
|
181
|
+
let line = 0;
|
|
182
|
+
try {
|
|
183
|
+
for (const group of m.mappings.split(";")) {
|
|
184
|
+
const segs: Segment[] = [];
|
|
185
|
+
let col = 0;
|
|
186
|
+
if (group.length > 0) {
|
|
187
|
+
for (const seg of group.split(",")) {
|
|
188
|
+
if (seg.length === 0) continue;
|
|
189
|
+
const f = decodeVlq(seg);
|
|
190
|
+
col += f[0];
|
|
191
|
+
if (f.length >= 4) {
|
|
192
|
+
src += f[1];
|
|
193
|
+
line += f[2];
|
|
194
|
+
segs.push({ col, src, line });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push(segs);
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return { sources, lines };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Attribute generated line counts to original (file, line)s through the
|
|
208
|
+
* map. Every original line a generated line's segments name gets that
|
|
209
|
+
* line's count (max over the generated lines that touch it). Foreign
|
|
210
|
+
* sources are dropped.
|
|
211
|
+
*/
|
|
212
|
+
export function mapToOriginal(map: DecodedSourceMap, generated: LineCounts): FileCounts {
|
|
213
|
+
const out: FileCounts = new Map();
|
|
214
|
+
for (const [genLine, count] of generated) {
|
|
215
|
+
const segs = map.lines[genLine - 1];
|
|
216
|
+
if (!segs || segs.length === 0) continue;
|
|
217
|
+
for (const seg of segs) {
|
|
218
|
+
const file = map.sources[seg.src];
|
|
219
|
+
if (file === undefined || isForeignSource(file)) continue;
|
|
220
|
+
let lc = out.get(file);
|
|
221
|
+
if (!lc) {
|
|
222
|
+
lc = new Map();
|
|
223
|
+
out.set(file, lc);
|
|
224
|
+
}
|
|
225
|
+
const l = seg.line + 1;
|
|
226
|
+
lc.set(l, Math.max(lc.get(l) ?? 0, count));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Accumulation + lcov ─────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/** Add `from` into `into` (sums — takes reset V8's counters). */
|
|
235
|
+
export function mergeLineCounts(into: LineCounts, from: LineCounts): void {
|
|
236
|
+
for (const [l, c] of from) into.set(l, (into.get(l) ?? 0) + c);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function mergeFileCounts(into: FileCounts, from: FileCounts): void {
|
|
240
|
+
for (const [file, counts] of from) {
|
|
241
|
+
let target = into.get(file);
|
|
242
|
+
if (!target) {
|
|
243
|
+
target = new Map();
|
|
244
|
+
into.set(file, target);
|
|
245
|
+
}
|
|
246
|
+
mergeLineCounts(target, counts);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One lcov document for a set of files. */
|
|
251
|
+
export function lcovDocument(files: FileCounts): string {
|
|
252
|
+
let out = "TN:\n";
|
|
253
|
+
for (const [file, counts] of [...files].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
254
|
+
out += `SF:${file}\n`;
|
|
255
|
+
let found = 0;
|
|
256
|
+
let hit = 0;
|
|
257
|
+
for (const [l, c] of [...counts].sort((a, b) => a[0] - b[0])) {
|
|
258
|
+
out += `DA:${l},${c}\n`;
|
|
259
|
+
found++;
|
|
260
|
+
if (c > 0) hit++;
|
|
261
|
+
}
|
|
262
|
+
out += `LF:${found}\nLH:${hit}\nend_of_record\n`;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Host → service ──────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/** The slice of the lowered ingress that names hosts. */
|
|
270
|
+
export interface HostTable {
|
|
271
|
+
proxies: readonly { hostname: string; service: string }[];
|
|
272
|
+
aliasesByService: Readonly<Record<string, readonly string[]>>;
|
|
273
|
+
wildcards: readonly {
|
|
274
|
+
pattern: string;
|
|
275
|
+
target: { service?: string } | { ingress?: boolean } | Record<string, unknown>;
|
|
276
|
+
}[];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Which service a script URL's host belongs to: the service key itself,
|
|
281
|
+
* `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
|
|
282
|
+
* hostname routed to it, or a wildcard pointed at it. Exact first, then
|
|
283
|
+
* the longest wildcard suffix — the resolver's own precedence.
|
|
284
|
+
*/
|
|
285
|
+
export function serviceForHost(
|
|
286
|
+
host: string,
|
|
287
|
+
services: readonly string[],
|
|
288
|
+
table: HostTable,
|
|
289
|
+
): string | undefined {
|
|
290
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
291
|
+
for (const s of services) {
|
|
292
|
+
if (h === s.toLowerCase() || h === `${s.toLowerCase()}.internal`) return s;
|
|
293
|
+
}
|
|
294
|
+
for (const [svc, aliases] of Object.entries(table.aliasesByService)) {
|
|
295
|
+
if (aliases.some((a) => a.toLowerCase() === h)) return svc;
|
|
296
|
+
}
|
|
297
|
+
for (const p of table.proxies) {
|
|
298
|
+
if (p.hostname.toLowerCase() === h) return p.service;
|
|
299
|
+
}
|
|
300
|
+
let best: { len: number; service: string } | undefined;
|
|
301
|
+
for (const w of table.wildcards) {
|
|
302
|
+
const service = (w.target as { service?: string }).service;
|
|
303
|
+
if (!service) continue;
|
|
304
|
+
const suffix = w.pattern.toLowerCase().replace(/^\*/, "");
|
|
305
|
+
if (h.endsWith(suffix) && h.length > suffix.length) {
|
|
306
|
+
if (!best || suffix.length > best.len) best = { len: suffix.length, service };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return best?.service;
|
|
310
|
+
}
|