@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/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
- return { name, ...(rest as ServiceConfig) };
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. The reports are
3596
- // cumulative from process start and live on the rootfs, so what a child
3597
- // fork reads already includes everything its ancestors ran — no markers,
3598
- // no deltas (unlike the service logs above). A `command` runs in the
3599
- // container first so the service writes a fresh report. The reports ship
3600
- // verbatim; the server derives what it needs from the stored bytes.
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
- const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
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 cov = svc.coverage;
3614
- if (typeof cov === "object" && cov !== null) {
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
- coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
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
+ }