@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/daemon.js CHANGED
@@ -21,8 +21,11 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
21
21
  import net from "node:net";
22
22
  import path from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
- import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, validateCoverage, } from "./index.js";
25
- import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
24
+ import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
25
+ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, } from "./harness/coverage.js";
26
+ import { configureBrowserCoverage } from "./browser-coverage.js";
27
+ import { applyCoverageAdapters, coverageAdapters, validateCoverage, } from "./coverage.js";
28
+ import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
26
29
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
27
30
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
28
31
  // Pure ingress hostname matching, ported out of this file (see
@@ -1422,6 +1425,11 @@ function buildIngress(project) {
1422
1425
  // generic decl set the daemon executes. The special-casing lives in the
1423
1426
  // SDK's lowerIngress, not here.
1424
1427
  LOWERED = lowerIngress(project);
1428
+ BROWSER_COVERAGE_SERVICES.clear();
1429
+ void runCoverageLoadHooks(namedServices(project.environment)).catch((err) => {
1430
+ // eslint-disable-next-line no-console
1431
+ console.warn("[coverage] load hook failed:", err);
1432
+ });
1425
1433
  if (!project.fakes)
1426
1434
  return;
1427
1435
  for (const [name, def] of Object.entries(project.fakes)) {
@@ -2306,7 +2314,9 @@ const RUNTIME_SERVICES = new Map();
2306
2314
  // the orchestration helpers want a NamedService, which is the same shape.
2307
2315
  function specToNamedService(spec) {
2308
2316
  const { name, ...rest } = spec;
2309
- return { name, ...rest };
2317
+ // A runtime spec skipped `defineEnvironment`, so its coverage adapters
2318
+ // get their config-time pass here.
2319
+ return { name, ...applyCoverageAdapters(name, rest) };
2310
2320
  }
2311
2321
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
2312
2322
  * Prepares the image (pulling on first use through the host cache), runs
@@ -2319,6 +2329,7 @@ async function startRuntimeService(spec) {
2319
2329
  const t0 = Date.now();
2320
2330
  const resv = reserveEvent();
2321
2331
  const svc = specToNamedService(spec);
2332
+ await runCoverageLoadHooks([svc]);
2322
2333
  const aliases = (spec.hostnames ?? []).map((h) => h.toLowerCase());
2323
2334
  const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
2324
2335
  try {
@@ -2970,16 +2981,118 @@ async function captureServiceLogDeltas() {
2970
2981
  }));
2971
2982
  }
2972
2983
  // ────────────────────────────────────────────────────────────────────────
2973
- // Coverage capture (opt-in per service; see harness/coverage.ts)
2984
+ // Coverage capture (opt-in per service; see coverage.ts + harness/coverage.ts)
2974
2985
  // ────────────────────────────────────────────────────────────────────────
2975
2986
  //
2976
- // Read after bring-up and after every test, on every case. The reports are
2977
- // cumulative from process start and live on the rootfs, so what a child
2978
- // fork reads already includes everything its ancestors ran — no markers,
2979
- // no deltas (unlike the service logs above). A `command` runs in the
2980
- // container first so the service writes a fresh report. The reports ship
2981
- // verbatim; the server derives what it needs from the stored bytes.
2982
- const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
2987
+ // Read after bring-up and after every test, on every case. A service's
2988
+ // adapters run first (each makes its report appear in the directory —
2989
+ // `nodeCoverage` asks the hook for a dump, `browserCoverage` writes the
2990
+ // harness's totals, `commandCoverage` runs the command), then the
2991
+ // directory is read the same way for every service, `coverage: true`
2992
+ // included. The reports live on the rootfs, so what a child fork reads
2993
+ // already includes everything its ancestors ran — no markers, no deltas
2994
+ // (unlike the service logs above). The reports ship verbatim; the server
2995
+ // derives what it needs from the stored bytes.
2996
+ /** Budget for one service's adapters at one capture, all of them. */
2997
+ const COVERAGE_CAPTURE_TIMEOUT_MS = 60_000;
2998
+ /** Services whose adapters asked for browser scripts at /load
2999
+ * (`CoverageLoadContext.collectBrowserScripts`). Cleared on every /load;
3000
+ * a runtime service's adapters add to it when it starts. */
3001
+ const BROWSER_COVERAGE_SERVICES = new Set();
3002
+ /** Run every adapter's `load` hook for `services`, then rebuild the
3003
+ * browser-coverage attribution from whoever asked. The attribution is a
3004
+ * host → service resolver over the lowered ingress, restricted to those
3005
+ * services, so a script from an uncovered service is ignored; null when
3006
+ * nobody asked, which costs nothing. */
3007
+ async function runCoverageLoadHooks(services) {
3008
+ for (const svc of services) {
3009
+ for (const a of coverageAdapters(svc.coverage)) {
3010
+ if (!a.load)
3011
+ continue;
3012
+ await a.load({
3013
+ service: svc.name,
3014
+ collectBrowserScripts: () => {
3015
+ BROWSER_COVERAGE_SERVICES.add(svc.name);
3016
+ },
3017
+ });
3018
+ }
3019
+ }
3020
+ rebuildBrowserCoverageResolver();
3021
+ }
3022
+ function rebuildBrowserCoverageResolver() {
3023
+ if (BROWSER_COVERAGE_SERVICES.size === 0 || !loaded) {
3024
+ configureBrowserCoverage(null);
3025
+ return;
3026
+ }
3027
+ const all = [...namedServices(loaded.project.environment).map((s) => s.name), ...RUNTIME_SERVICES.keys()];
3028
+ const table = {
3029
+ proxies: LOWERED.proxies,
3030
+ aliasesByService: LOWERED.aliasesByService,
3031
+ wildcards: LOWERED.wildcards,
3032
+ };
3033
+ configureBrowserCoverage({
3034
+ serviceForHost(host) {
3035
+ const svc = hostToService(host, all, table);
3036
+ return svc && BROWSER_COVERAGE_SERVICES.has(svc) ? svc : undefined;
3037
+ },
3038
+ });
3039
+ }
3040
+ /** The context one adapter's `capture` runs against. */
3041
+ function coverageCaptureContext(svc, signal) {
3042
+ const reportDir = coverageHostDir(WORKSPACE, svc.name);
3043
+ return {
3044
+ service: svc.name,
3045
+ reportDir,
3046
+ signal,
3047
+ async exec(command) {
3048
+ const r = await docker(["exec", svc.name, "sh", "-c", command], COVERAGE_CAPTURE_TIMEOUT_MS);
3049
+ if (r.code !== 0) {
3050
+ const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
3051
+ throw new Error(`${JSON.stringify(command)} exited ${r.code}${tail ? `\n${tail}` : ""}`);
3052
+ }
3053
+ return { stdout: r.stdout, stderr: r.stderr };
3054
+ },
3055
+ async writeReport(name, content) {
3056
+ const file = path.join(reportDir, name);
3057
+ if (path.relative(reportDir, file).startsWith("..")) {
3058
+ throw new Error(`writeReport: ${JSON.stringify(name)} leaves the coverage directory`);
3059
+ }
3060
+ await fs.mkdir(path.dirname(file), { recursive: true });
3061
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
3062
+ await fs.writeFile(tmp, content);
3063
+ await fs.rename(tmp, file);
3064
+ },
3065
+ };
3066
+ }
3067
+ /** Run one service's adapters in order under one budget. Returns the
3068
+ * error to fail the capture with, naming the adapter. */
3069
+ async function runCoverageAdapters(svc) {
3070
+ const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
3071
+ if (adapters.length === 0)
3072
+ return undefined;
3073
+ const ac = new AbortController();
3074
+ const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
3075
+ const ctx = coverageCaptureContext(svc, ac.signal);
3076
+ try {
3077
+ for (const a of adapters) {
3078
+ try {
3079
+ await Promise.race([
3080
+ a.capture(ctx),
3081
+ new Promise((_, reject) => {
3082
+ ac.signal.addEventListener("abort", () => reject(new Error(`no report within ${COVERAGE_CAPTURE_TIMEOUT_MS / 1000}s`)), { once: true });
3083
+ }),
3084
+ ]);
3085
+ }
3086
+ catch (err) {
3087
+ return `coverage adapter "${a.name}" on service "${svc.name}" failed: ${err instanceof Error ? err.message : String(err)}`;
3088
+ }
3089
+ }
3090
+ return undefined;
3091
+ }
3092
+ finally {
3093
+ clearTimeout(timer);
3094
+ }
3095
+ }
2983
3096
  async function captureServiceCoverage() {
2984
3097
  const l = loaded;
2985
3098
  if (!l)
@@ -2991,18 +3104,9 @@ async function captureServiceCoverage() {
2991
3104
  byName.set(name, s);
2992
3105
  const services = [...byName.values()].filter((s) => s.coverage !== undefined);
2993
3106
  return Promise.all(services.map(async (svc) => {
2994
- const cov = svc.coverage;
2995
- if (typeof cov === "object" && cov !== null) {
2996
- const r = await docker(["exec", svc.name, "sh", "-c", cov.command], COVERAGE_COMMAND_TIMEOUT_MS);
2997
- if (r.code !== 0) {
2998
- const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
2999
- return {
3000
- service: svc.name,
3001
- reports: [],
3002
- error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
3003
- };
3004
- }
3005
- }
3107
+ const error = await runCoverageAdapters(svc);
3108
+ if (error)
3109
+ return { service: svc.name, reports: [], error };
3006
3110
  return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3007
3111
  }));
3008
3112
  }
@@ -5009,7 +5113,17 @@ export function harnessMethods(state) {
5009
5113
  // the control plane logs it.
5010
5114
  let coverage;
5011
5115
  try {
5012
- coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
5116
+ const captures = await captureServiceCoverage();
5117
+ for (const c of captures) {
5118
+ // After bring-up the code that started the service has run, so a
5119
+ // report set that names no file at all is a misconfigured tool —
5120
+ // visible in the boot log, never a failure (there is no case).
5121
+ if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
5122
+ // eslint-disable-next-line no-console
5123
+ console.warn(`[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`);
5124
+ }
5125
+ }
5126
+ coverage = parkCoverageBundle(SETUP_CASE_ID, captures);
5013
5127
  }
5014
5128
  catch (err) {
5015
5129
  // eslint-disable-next-line no-console
@@ -0,0 +1,95 @@
1
+ /** Per-line hit counts, 1-based line numbers. */
2
+ export type LineCounts = Map<number, number>;
3
+ /** Per-file line counts. Keys are source paths as the source map (or the
4
+ * script URL, when there is no map) reports them. */
5
+ export type FileCounts = Map<string, LineCounts>;
6
+ /** Byte offset at which each line starts. */
7
+ export declare function lineStartsOf(source: string): number[];
8
+ /** 1-based line holding `offset`. */
9
+ export declare function lineOf(starts: number[], offset: number): number;
10
+ /** One V8 `FunctionCoverage` as `Profiler.takePreciseCoverage` reports it. */
11
+ export interface V8FunctionCoverage {
12
+ functionName?: string;
13
+ ranges: {
14
+ startOffset: number;
15
+ endOffset: number;
16
+ count: number;
17
+ }[];
18
+ isBlockCoverage?: boolean;
19
+ }
20
+ /**
21
+ * Fold a script's block ranges onto its lines. Ranges come outer-first
22
+ * and a narrower range overrides the lines it spans, so a line inside an
23
+ * unexecuted branch ends at 0 even though its enclosing function ran.
24
+ * Blank lines are dropped. Approximate — a line holding two blocks gets
25
+ * the narrower one's count — which is exact at the file level, the only
26
+ * level anything reads today.
27
+ */
28
+ export declare function rangesToLineCounts(source: string, functions: readonly V8FunctionCoverage[]): LineCounts;
29
+ /** One mapping segment: generated column → original (source, line). */
30
+ export interface Segment {
31
+ col: number;
32
+ src: number;
33
+ line: number;
34
+ }
35
+ export interface DecodedSourceMap {
36
+ /** Normalised source paths, indexed by segment `src`. */
37
+ sources: string[];
38
+ /** Segments per generated line (0-based), sorted by column. */
39
+ lines: Segment[][];
40
+ }
41
+ /** Decode one VLQ-encoded segment into its fields. */
42
+ export declare function decodeVlq(segment: string): number[];
43
+ /**
44
+ * Normalise a source-map `sources` entry to a plain path. Bundlers prefix
45
+ * their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
46
+ * URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
47
+ * The server maps these onto the repo by suffix, so all that matters here
48
+ * is stripping what is not a path.
49
+ */
50
+ export declare function normalizeSourcePath(src: string, sourceRoot?: string): string;
51
+ /** Sources nobody's change can be attributed to: dependencies and bundler
52
+ * runtime shims. */
53
+ export declare function isForeignSource(path: string): boolean;
54
+ /**
55
+ * Decode a source map document. Index maps (`sections`) are not
56
+ * supported and decode to `null`, as does anything malformed — the
57
+ * caller then falls back to attributing the script to its own URL, which
58
+ * the repo mapping cannot resolve, which is the safe answer.
59
+ */
60
+ export declare function decodeSourceMap(doc: unknown): DecodedSourceMap | null;
61
+ /**
62
+ * Attribute generated line counts to original (file, line)s through the
63
+ * map. Every original line a generated line's segments name gets that
64
+ * line's count (max over the generated lines that touch it). Foreign
65
+ * sources are dropped.
66
+ */
67
+ export declare function mapToOriginal(map: DecodedSourceMap, generated: LineCounts): FileCounts;
68
+ /** Add `from` into `into` (sums — takes reset V8's counters). */
69
+ export declare function mergeLineCounts(into: LineCounts, from: LineCounts): void;
70
+ export declare function mergeFileCounts(into: FileCounts, from: FileCounts): void;
71
+ /** One lcov document for a set of files. */
72
+ export declare function lcovDocument(files: FileCounts): string;
73
+ /** The slice of the lowered ingress that names hosts. */
74
+ export interface HostTable {
75
+ proxies: readonly {
76
+ hostname: string;
77
+ service: string;
78
+ }[];
79
+ aliasesByService: Readonly<Record<string, readonly string[]>>;
80
+ wildcards: readonly {
81
+ pattern: string;
82
+ target: {
83
+ service?: string;
84
+ } | {
85
+ ingress?: boolean;
86
+ } | Record<string, unknown>;
87
+ }[];
88
+ }
89
+ /**
90
+ * Which service a script URL's host belongs to: the service key itself,
91
+ * `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
92
+ * hostname routed to it, or a wildcard pointed at it. Exact first, then
93
+ * the longest wildcard suffix — the resolver's own precedence.
94
+ */
95
+ export declare function serviceForHost(host: string, services: readonly string[], table: HostTable): string | undefined;
@@ -0,0 +1,257 @@
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
+ // ── V8 precise coverage → generated line counts ─────────────────────────
15
+ /** Byte offset at which each line starts. */
16
+ export function lineStartsOf(source) {
17
+ const starts = [0];
18
+ for (let i = 0; i < source.length; i++) {
19
+ if (source.charCodeAt(i) === 10)
20
+ starts.push(i + 1);
21
+ }
22
+ return starts;
23
+ }
24
+ /** 1-based line holding `offset`. */
25
+ export function lineOf(starts, offset) {
26
+ let lo = 0;
27
+ let hi = starts.length - 1;
28
+ while (lo < hi) {
29
+ const mid = (lo + hi + 1) >> 1;
30
+ if (starts[mid] <= offset)
31
+ lo = mid;
32
+ else
33
+ hi = mid - 1;
34
+ }
35
+ return lo + 1;
36
+ }
37
+ /**
38
+ * Fold a script's block ranges onto its lines. Ranges come outer-first
39
+ * and a narrower range overrides the lines it spans, so a line inside an
40
+ * unexecuted branch ends at 0 even though its enclosing function ran.
41
+ * Blank lines are dropped. Approximate — a line holding two blocks gets
42
+ * the narrower one's count — which is exact at the file level, the only
43
+ * level anything reads today.
44
+ */
45
+ export function rangesToLineCounts(source, functions) {
46
+ const starts = lineStartsOf(source);
47
+ const counts = new Map();
48
+ for (const fn of functions) {
49
+ for (const r of fn.ranges) {
50
+ const a = lineOf(starts, r.startOffset);
51
+ const b = lineOf(starts, Math.max(r.startOffset, r.endOffset - 1));
52
+ for (let l = a; l <= b; l++)
53
+ counts.set(l, r.count);
54
+ }
55
+ }
56
+ for (const l of [...counts.keys()]) {
57
+ const text = source.slice(starts[l - 1], starts[l] ?? source.length);
58
+ if (text.trim().length === 0)
59
+ counts.delete(l);
60
+ }
61
+ return counts;
62
+ }
63
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
64
+ const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
65
+ /** Decode one VLQ-encoded segment into its fields. */
66
+ export function decodeVlq(segment) {
67
+ const out = [];
68
+ let value = 0;
69
+ let shift = 0;
70
+ for (const ch of segment) {
71
+ const digit = B64_INDEX.get(ch);
72
+ if (digit === undefined)
73
+ throw new Error(`bad VLQ char ${JSON.stringify(ch)}`);
74
+ value += (digit & 31) << shift;
75
+ if (digit & 32) {
76
+ shift += 5;
77
+ }
78
+ else {
79
+ const negative = value & 1;
80
+ value >>>= 1;
81
+ out.push(negative ? -value : value);
82
+ value = 0;
83
+ shift = 0;
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Normalise a source-map `sources` entry to a plain path. Bundlers prefix
90
+ * their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
91
+ * URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
92
+ * The server maps these onto the repo by suffix, so all that matters here
93
+ * is stripping what is not a path.
94
+ */
95
+ export function normalizeSourcePath(src, sourceRoot) {
96
+ let s = src;
97
+ if (sourceRoot && !/^[a-z]+:/i.test(s)) {
98
+ s = sourceRoot.replace(/\/?$/, "/") + s;
99
+ }
100
+ s = s.replace(/^webpack:\/\/\/?/, "").replace(/^file:\/\//, "");
101
+ // A URL (`http://host/src/a.ts`, Vite's `/@fs/…`) keeps only its path.
102
+ const m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(s);
103
+ if (m)
104
+ s = m[1];
105
+ s = s.replace(/^\/@fs\//, "/");
106
+ s = s.replace(/\?.*$/, "");
107
+ while (s.startsWith("./"))
108
+ s = s.slice(2);
109
+ return s;
110
+ }
111
+ /** Sources nobody's change can be attributed to: dependencies and bundler
112
+ * runtime shims. */
113
+ export function isForeignSource(path) {
114
+ return (path.length === 0 ||
115
+ path.includes("node_modules/") ||
116
+ path.startsWith("webpack/") ||
117
+ path.startsWith("(webpack)") ||
118
+ path.startsWith("\0") ||
119
+ path.startsWith("vite/") ||
120
+ path.startsWith("@vite/"));
121
+ }
122
+ /**
123
+ * Decode a source map document. Index maps (`sections`) are not
124
+ * supported and decode to `null`, as does anything malformed — the
125
+ * caller then falls back to attributing the script to its own URL, which
126
+ * the repo mapping cannot resolve, which is the safe answer.
127
+ */
128
+ export function decodeSourceMap(doc) {
129
+ if (!doc || typeof doc !== "object")
130
+ return null;
131
+ const m = doc;
132
+ if (m.sections || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
133
+ return null;
134
+ }
135
+ const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
136
+ const sources = m.sources.map((s) => typeof s === "string" ? normalizeSourcePath(s, sourceRoot) : "");
137
+ const lines = [];
138
+ let src = 0;
139
+ let line = 0;
140
+ try {
141
+ for (const group of m.mappings.split(";")) {
142
+ const segs = [];
143
+ let col = 0;
144
+ if (group.length > 0) {
145
+ for (const seg of group.split(",")) {
146
+ if (seg.length === 0)
147
+ continue;
148
+ const f = decodeVlq(seg);
149
+ col += f[0];
150
+ if (f.length >= 4) {
151
+ src += f[1];
152
+ line += f[2];
153
+ segs.push({ col, src, line });
154
+ }
155
+ }
156
+ }
157
+ lines.push(segs);
158
+ }
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ return { sources, lines };
164
+ }
165
+ /**
166
+ * Attribute generated line counts to original (file, line)s through the
167
+ * map. Every original line a generated line's segments name gets that
168
+ * line's count (max over the generated lines that touch it). Foreign
169
+ * sources are dropped.
170
+ */
171
+ export function mapToOriginal(map, generated) {
172
+ const out = new Map();
173
+ for (const [genLine, count] of generated) {
174
+ const segs = map.lines[genLine - 1];
175
+ if (!segs || segs.length === 0)
176
+ continue;
177
+ for (const seg of segs) {
178
+ const file = map.sources[seg.src];
179
+ if (file === undefined || isForeignSource(file))
180
+ continue;
181
+ let lc = out.get(file);
182
+ if (!lc) {
183
+ lc = new Map();
184
+ out.set(file, lc);
185
+ }
186
+ const l = seg.line + 1;
187
+ lc.set(l, Math.max(lc.get(l) ?? 0, count));
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ // ── Accumulation + lcov ─────────────────────────────────────────────────
193
+ /** Add `from` into `into` (sums — takes reset V8's counters). */
194
+ export function mergeLineCounts(into, from) {
195
+ for (const [l, c] of from)
196
+ into.set(l, (into.get(l) ?? 0) + c);
197
+ }
198
+ export function mergeFileCounts(into, from) {
199
+ for (const [file, counts] of from) {
200
+ let target = into.get(file);
201
+ if (!target) {
202
+ target = new Map();
203
+ into.set(file, target);
204
+ }
205
+ mergeLineCounts(target, counts);
206
+ }
207
+ }
208
+ /** One lcov document for a set of files. */
209
+ export function lcovDocument(files) {
210
+ let out = "TN:\n";
211
+ for (const [file, counts] of [...files].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
212
+ out += `SF:${file}\n`;
213
+ let found = 0;
214
+ let hit = 0;
215
+ for (const [l, c] of [...counts].sort((a, b) => a[0] - b[0])) {
216
+ out += `DA:${l},${c}\n`;
217
+ found++;
218
+ if (c > 0)
219
+ hit++;
220
+ }
221
+ out += `LF:${found}\nLH:${hit}\nend_of_record\n`;
222
+ }
223
+ return out;
224
+ }
225
+ /**
226
+ * Which service a script URL's host belongs to: the service key itself,
227
+ * `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
228
+ * hostname routed to it, or a wildcard pointed at it. Exact first, then
229
+ * the longest wildcard suffix — the resolver's own precedence.
230
+ */
231
+ export function serviceForHost(host, services, table) {
232
+ const h = host.toLowerCase().replace(/\.$/, "");
233
+ for (const s of services) {
234
+ if (h === s.toLowerCase() || h === `${s.toLowerCase()}.internal`)
235
+ return s;
236
+ }
237
+ for (const [svc, aliases] of Object.entries(table.aliasesByService)) {
238
+ if (aliases.some((a) => a.toLowerCase() === h))
239
+ return svc;
240
+ }
241
+ for (const p of table.proxies) {
242
+ if (p.hostname.toLowerCase() === h)
243
+ return p.service;
244
+ }
245
+ let best;
246
+ for (const w of table.wildcards) {
247
+ const service = w.target.service;
248
+ if (!service)
249
+ continue;
250
+ const suffix = w.pattern.toLowerCase().replace(/^\*/, "");
251
+ if (h.endsWith(suffix) && h.length > suffix.length) {
252
+ if (!best || suffix.length > best.len)
253
+ best = { len: suffix.length, service };
254
+ }
255
+ }
256
+ return best?.service;
257
+ }
@@ -1,7 +1,7 @@
1
1
  /** Where the directory is mounted inside an opted-in container. */
2
2
  export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
3
3
  /** Raw bytes of reports one service may ship per capture. Reports are
4
- * cumulative and bounded by code size, so anything past this is a
4
+ * bounded by code size times chain depth, so anything past this is a
5
5
  * runaway (a tool writing a new file per request), not coverage. */
6
6
  export declare const COVERAGE_MAX_BYTES_PER_SERVICE: number;
7
7
  /** Root of the per-service host directories, under the workspace so the
@@ -44,14 +44,33 @@ export interface CoverageBundle {
44
44
  }
45
45
  /** True when `text` holds at least one lcov `SF:` record. */
46
46
  export declare function hasLcovSourceFile(text: string): boolean;
47
+ /** True when `text` is lcov-shaped: at least one line, every non-blank
48
+ * line an `XX:` record or `end_of_record`. An lcov with no `SF:` record
49
+ * at all (`TN:` alone) is well-formed — it is a report that says
50
+ * nothing ran. */
51
+ export declare function isLcovShaped(text: string): boolean;
52
+ /**
53
+ * True when `text` looks like a V8 coverage JSON document — what
54
+ * `NODE_V8_COVERAGE` / `v8.takeCoverage()` write: an object with a
55
+ * `result` array of scripts. A cheap shape check, not a parse (a report
56
+ * can be megabytes); the control plane parses for real.
57
+ */
58
+ export declare function isV8CoverageJson(text: string): boolean;
59
+ /** True when `text` is a report spectest stores: lcov or V8 JSON. */
60
+ export declare function isWellFormedReport(text: string): boolean;
61
+ /** True when a well-formed report names no source file at all (an lcov
62
+ * with no `SF:`, a V8 document with an empty `result`). */
63
+ export declare function isEmptyReport(text: string): boolean;
47
64
  /** Every regular file under `dir`, recursively, as paths relative to it.
48
65
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
49
66
  * a tmp file mid-write — is not a report). */
50
67
  export declare function listReportFiles(dir: string): Promise<string[]>;
51
68
  /**
52
- * Read every report under `dir`, verbatim. An empty directory, or one with
53
- * no `SF:` record anywhere, is an error rather than an empty capture: the
54
- * contract is that an opted-in service always has a report to read.
69
+ * Read every report under `dir`, verbatim. An empty directory, or a file
70
+ * that is neither lcov nor V8 JSON, is an error rather than an empty
71
+ * capture: the contract is that an opted-in service always has a
72
+ * well-formed report to read. A report that names no file is fine — it
73
+ * says nothing ran.
55
74
  */
56
75
  export declare function readCoverageDir(service: string, dir: string): Promise<ServiceCoverageCapture>;
57
76
  /** Gzip a case's captures into the bundle `coverageChunk` serves. */