@specific.dev/spectest 0.56.2 → 0.58.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.
@@ -0,0 +1,20 @@
1
+ import type { CDPSession } from "playwright-core";
2
+ export interface BrowserCoverageConfig {
3
+ /** Which covered service (if any) serves scripts from this host. */
4
+ serviceForHost(host: string): string | undefined;
5
+ }
6
+ /** Set (or clear, with `null`) at /load. */
7
+ export declare function configureBrowserCoverage(config: BrowserCoverageConfig | null): void;
8
+ export declare function browserCoverageActive(): boolean;
9
+ /** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
10
+ * service opted in. */
11
+ export declare function attachBrowserCoverage(cdp: CDPSession): Promise<void>;
12
+ /**
13
+ * Take the session's counters and fold them into the totals. Concurrent
14
+ * calls coalesce onto the in-flight one. Errors (a page mid-navigation,
15
+ * a closed view) are swallowed: the counters then stay in V8 for the next
16
+ * take, nothing is lost.
17
+ */
18
+ export declare function harvestBrowserCoverage(cdp: CDPSession): Promise<void>;
19
+ /** One lcov document per service that has any browser coverage. */
20
+ export declare function browserCoverageReports(): Map<string, string>;
@@ -0,0 +1,185 @@
1
+ // Browser coverage — the collector.
2
+ //
3
+ // One collector per CDP session (i.e. per page view). `attach` turns on
4
+ // V8 precise coverage and tracks parsed scripts; `harvest` takes the
5
+ // counters (which RESETS them — V8 reports nothing for a script that ran
6
+ // nothing since the last take, so the counts are summed here across
7
+ // takes), maps each script the browser loaded from a covered service back
8
+ // to its original sources through the served source map, and folds the
9
+ // result into a per-service total in module memory — which forks with the
10
+ // daemon, so a child test inherits its ancestors' browser coverage exactly
11
+ // as a container's counters are inherited.
12
+ //
13
+ // Harvest runs before every browser step (a click that navigates
14
+ // cross-site replaces the renderer, and the old counters with it) and at
15
+ // every coverage capture. `browserCoverageReports` renders the totals as
16
+ // one lcov per service for the `case-coverage` bundle.
17
+ //
18
+ // Configured by the daemon at /load with the host → service table; a
19
+ // project with no `coverage: { browser: true }` service never attaches
20
+ // anything, so it costs nothing.
21
+ import { createHash } from "node:crypto";
22
+ import { decodeSourceMap, lcovDocument, mapToOriginal, mergeFileCounts, rangesToLineCounts, } from "./harness/browser-coverage.js";
23
+ import { rawFetch } from "./harness/raw-fetch.js";
24
+ let CONFIG = null;
25
+ /** Set (or clear, with `null`) at /load. */
26
+ export function configureBrowserCoverage(config) {
27
+ CONFIG = config;
28
+ }
29
+ export function browserCoverageActive() {
30
+ return CONFIG !== null;
31
+ }
32
+ /** service → file → line → count, summed over every harvest. */
33
+ const TOTALS = new Map();
34
+ const COLLECTORS = new WeakMap();
35
+ /** Decoded maps by URL (or content hash for inline `data:` maps). */
36
+ const SOURCE_MAPS = new Map();
37
+ const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
38
+ /** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
39
+ * service opted in. */
40
+ export async function attachBrowserCoverage(cdp) {
41
+ if (!CONFIG || COLLECTORS.has(cdp))
42
+ return;
43
+ const collector = { cdp, scripts: new Map(), inFlight: null };
44
+ COLLECTORS.set(cdp, collector);
45
+ cdp.on("Debugger.scriptParsed", (ev) => {
46
+ if (!ev.url)
47
+ return;
48
+ collector.scripts.set(ev.scriptId, {
49
+ url: ev.url,
50
+ sourceMapURL: ev.sourceMapURL || undefined,
51
+ });
52
+ });
53
+ await cdp.send("Debugger.enable");
54
+ await cdp.send("Profiler.enable");
55
+ await cdp.send("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
56
+ }
57
+ /**
58
+ * Take the session's counters and fold them into the totals. Concurrent
59
+ * calls coalesce onto the in-flight one. Errors (a page mid-navigation,
60
+ * a closed view) are swallowed: the counters then stay in V8 for the next
61
+ * take, nothing is lost.
62
+ */
63
+ export async function harvestBrowserCoverage(cdp) {
64
+ const c = COLLECTORS.get(cdp);
65
+ if (!c || !CONFIG)
66
+ return;
67
+ if (c.inFlight)
68
+ return c.inFlight;
69
+ c.inFlight = harvestInner(c, CONFIG).catch(() => { }).finally(() => {
70
+ c.inFlight = null;
71
+ });
72
+ return c.inFlight;
73
+ }
74
+ async function harvestInner(c, config) {
75
+ const { result } = (await c.cdp.send("Profiler.takePreciseCoverage"));
76
+ for (const script of result) {
77
+ const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
78
+ if (!info)
79
+ continue;
80
+ let url;
81
+ try {
82
+ url = new URL(info.url);
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ if (url.protocol !== "http:" && url.protocol !== "https:")
88
+ continue;
89
+ const service = config.serviceForHost(url.hostname);
90
+ if (!service)
91
+ continue;
92
+ if (info.source === undefined) {
93
+ try {
94
+ const r = (await c.cdp.send("Debugger.getScriptSource", {
95
+ scriptId: script.scriptId,
96
+ }));
97
+ info.source = r.scriptSource;
98
+ }
99
+ catch {
100
+ continue; // script gone with its document
101
+ }
102
+ c.scripts.set(script.scriptId, info);
103
+ }
104
+ const generated = rangesToLineCounts(info.source, script.functions);
105
+ if (generated.size === 0)
106
+ continue;
107
+ if (info.map === undefined)
108
+ info.map = await sourceMapFor(info, url);
109
+ const files = info.map
110
+ ? mapToOriginal(info.map, generated)
111
+ : new Map([[url.pathname, generated]]);
112
+ let totals = TOTALS.get(service);
113
+ if (!totals) {
114
+ totals = new Map();
115
+ TOTALS.set(service, totals);
116
+ }
117
+ mergeFileCounts(totals, files);
118
+ }
119
+ }
120
+ /** Resolve, fetch (or inline-decode) and decode a script's source map. */
121
+ async function sourceMapFor(info, scriptUrl) {
122
+ let ref = info.sourceMapURL;
123
+ if (!ref && info.source) {
124
+ const m = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/.exec(info.source.slice(-4096));
125
+ if (m)
126
+ ref = m[1];
127
+ }
128
+ if (!ref)
129
+ return null;
130
+ if (ref.startsWith("data:")) {
131
+ const key = "data:" + createHash("sha1").update(ref).digest("hex");
132
+ const cached = SOURCE_MAPS.get(key);
133
+ if (cached !== undefined)
134
+ return cached;
135
+ const map = decodeSourceMap(parseDataUrl(ref));
136
+ SOURCE_MAPS.set(key, map);
137
+ return map;
138
+ }
139
+ let mapUrl;
140
+ try {
141
+ mapUrl = new URL(ref, scriptUrl).toString();
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ const cached = SOURCE_MAPS.get(mapUrl);
147
+ if (cached !== undefined)
148
+ return cached;
149
+ let map = null;
150
+ try {
151
+ const res = await rawFetch(mapUrl, { signal: AbortSignal.timeout(SOURCE_MAP_FETCH_TIMEOUT_MS) });
152
+ if (res.ok)
153
+ map = decodeSourceMap(await res.json());
154
+ }
155
+ catch {
156
+ map = null;
157
+ }
158
+ SOURCE_MAPS.set(mapUrl, map);
159
+ return map;
160
+ }
161
+ function parseDataUrl(ref) {
162
+ const comma = ref.indexOf(",");
163
+ if (comma < 0)
164
+ return null;
165
+ const meta = ref.slice(5, comma);
166
+ const payload = ref.slice(comma + 1);
167
+ try {
168
+ const text = /;base64$/i.test(meta)
169
+ ? Buffer.from(payload, "base64").toString("utf8")
170
+ : decodeURIComponent(payload);
171
+ return JSON.parse(text);
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
177
+ /** One lcov document per service that has any browser coverage. */
178
+ export function browserCoverageReports() {
179
+ const out = new Map();
180
+ for (const [service, files] of TOTALS) {
181
+ if (files.size > 0)
182
+ out.set(service, lcovDocument(files));
183
+ }
184
+ return out;
185
+ }
package/dist/browser.js CHANGED
@@ -35,6 +35,7 @@ import { generateId } from "./ids.js";
35
35
  import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
36
36
  import { wrap } from "./inspect.js";
37
37
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
38
+ import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
38
39
  import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
39
40
  import { chromium } from "playwright-core";
40
41
  /** Decoded byte count of a base64 string, without decoding it. */
@@ -792,6 +793,14 @@ async function spawnPage(context) {
792
793
  console.warn("[spectest] failed to install rrweb recorder:", err);
793
794
  }
794
795
  }
796
+ // Frontend coverage (opt-in): turn on V8 precise coverage for this page.
797
+ // A no-op unless a service declared `coverage: { browser: true }`.
798
+ try {
799
+ await attachBrowserCoverage(cdp);
800
+ }
801
+ catch {
802
+ /* coverage is best-effort; a page without it still works */
803
+ }
795
804
  return { page, cdp, recordingInstalled };
796
805
  }
797
806
  /** Create a default-desktop view (plus its context) for the pool. */
@@ -1080,6 +1089,14 @@ async function rebuildView(holder) {
1080
1089
  catch {
1081
1090
  /* page may already be gone */
1082
1091
  }
1092
+ // The old renderer's coverage counters die with its page; harvest what
1093
+ // it had before the swap (rebuild is triggered on a fresh navigation).
1094
+ try {
1095
+ await harvestBrowserCoverage(holder.cdp);
1096
+ }
1097
+ catch {
1098
+ /* best-effort */
1099
+ }
1083
1100
  const fresh = await spawnPage(holder.context);
1084
1101
  holder.page = fresh.page;
1085
1102
  holder.cdp = fresh.cdp;
@@ -1164,6 +1181,17 @@ function buildBackend(holder, recorder, buildOpts) {
1164
1181
  durationMs: endT - t,
1165
1182
  }, resv);
1166
1183
  await drain(action);
1184
+ // Fold this page's V8 counters into the per-service totals now — a
1185
+ // navigation to another origin replaces the renderer and its
1186
+ // counters, so harvesting per op is what keeps them.
1187
+ if (browserCoverageActive()) {
1188
+ try {
1189
+ await harvestBrowserCoverage(holder.cdp);
1190
+ }
1191
+ catch {
1192
+ /* best-effort */
1193
+ }
1194
+ }
1167
1195
  // Reads (`opts.wrap`) return user-visible JS values someone is likely to
1168
1196
  // assert on — provenance-wrap so `expect(...)` nests under this step.
1169
1197
  // Actions return void/internals; leave them raw to avoid Proxy surprises.
@@ -1193,6 +1221,14 @@ function buildBackend(holder, recorder, buildOpts) {
1193
1221
  return;
1194
1222
  // Final drain before we stop writing to this recorder.
1195
1223
  await drain("close");
1224
+ if (browserCoverageActive()) {
1225
+ try {
1226
+ await harvestBrowserCoverage(holder.cdp);
1227
+ }
1228
+ catch {
1229
+ /* best-effort */
1230
+ }
1231
+ }
1196
1232
  recordingEnded = true;
1197
1233
  }
1198
1234
  const strategy = holder.device ? mobileStrategy : desktopStrategy;
package/dist/daemon.js CHANGED
@@ -21,7 +21,10 @@ 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, } from "./index.js";
24
+ import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, validateCoverage, coverageUsesBrowser, coverageUsesContainer, } from "./index.js";
25
+ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
26
+ import { browserCoverageReports, configureBrowserCoverage, } from "./browser-coverage.js";
27
+ import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
25
28
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
26
29
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
27
30
  // Pure ingress hostname matching, ported out of this file (see
@@ -510,6 +513,27 @@ async function ensureVolumes(svc) {
510
513
  }
511
514
  return flags;
512
515
  }
516
+ /**
517
+ * The coverage directory for an opted-in service: one host dir per service
518
+ * under the workspace (so the delta-restore teardown wipes it), mounted
519
+ * writable at `/spectest/coverage/`. World-writable because the container
520
+ * may run as any user and spectest creates the dir as root. The reports in
521
+ * it ride snapshots and forks like everything else on the rootfs, which is
522
+ * what makes each read cumulative along the test's ancestor chain.
523
+ */
524
+ async function ensureCoverage(svc) {
525
+ if (svc.coverage === undefined)
526
+ return [];
527
+ validateCoverage(svc.name, svc.coverage);
528
+ // A browser-only opt-in has no container process to write reports, so it
529
+ // gets no mount — its coverage comes from the guest browser.
530
+ if (!coverageUsesContainer(svc.coverage))
531
+ return [];
532
+ const host = coverageHostDir(WORKSPACE, svc.name);
533
+ await fs.mkdir(host, { recursive: true });
534
+ await fs.chmod(host, 0o777);
535
+ return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
536
+ }
513
537
  /** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
514
538
  * new content every rebuild, and a stale uid is silently wrong. */
515
539
  const IMAGE_ID_TABLES = new Map();
@@ -1404,6 +1428,7 @@ function buildIngress(project) {
1404
1428
  // generic decl set the daemon executes. The special-casing lives in the
1405
1429
  // SDK's lowerIngress, not here.
1406
1430
  LOWERED = lowerIngress(project);
1431
+ configureCoverageAttribution(project);
1407
1432
  if (!project.fakes)
1408
1433
  return;
1409
1434
  for (const [name, def] of Object.entries(project.fakes)) {
@@ -2309,6 +2334,7 @@ async function startRuntimeService(spec) {
2309
2334
  ...(await ensureVolumes(svc)),
2310
2335
  ...(await ensureFiles(svc, tag)),
2311
2336
  ...(await ensureCertificates(svc, tag)),
2337
+ ...(await ensureCoverage(svc)),
2312
2338
  ];
2313
2339
  await runContainer(svc, tag, flags, aliases);
2314
2340
  await waitForReady(svc);
@@ -2707,6 +2733,7 @@ async function bootstrapInner() {
2707
2733
  ...(await ensureVolumes(svc)),
2708
2734
  ...(await ensureFiles(svc, tag)),
2709
2735
  ...(await ensureCertificates(svc, tag)),
2736
+ ...(await ensureCoverage(svc)),
2710
2737
  ];
2711
2738
  const tRun = Date.now();
2712
2739
  progressService(svc.name, { status: "starting", detail: undefined });
@@ -2827,18 +2854,37 @@ async function runProjectSetupInner() {
2827
2854
  // bundle in guest RAM — entries older than the last few are always
2828
2855
  // already-fetched leftovers frozen into some snapshot.
2829
2856
  const REPLAY_BUNDLES = new Map();
2830
- const REPLAY_BUNDLES_MAX = 8;
2831
- function stashReplayBundle(caseId, gz) {
2857
+ /** Coverage bundles, same side-channel and same lifetime rules; pulled
2858
+ * through `coverageChunk`. The env-setup baseline parks under the
2859
+ * control plane's synthetic `__setup__` case id. */
2860
+ const COVERAGE_BUNDLES = new Map();
2861
+ const PARKED_BUNDLES_MAX = 8;
2862
+ /** The control plane's synthetic case id for env-setup captures
2863
+ * (`storage::SETUP_CASE_ID`). Not a real case id — those are kebab slugs. */
2864
+ const SETUP_CASE_ID = "__setup__";
2865
+ function stashBundle(map, caseId, gz) {
2832
2866
  // Re-insert to refresh recency (Map iterates in insertion order).
2833
- REPLAY_BUNDLES.delete(caseId);
2834
- REPLAY_BUNDLES.set(caseId, gz);
2835
- while (REPLAY_BUNDLES.size > REPLAY_BUNDLES_MAX) {
2836
- const oldest = REPLAY_BUNDLES.keys().next().value;
2867
+ map.delete(caseId);
2868
+ map.set(caseId, gz);
2869
+ while (map.size > PARKED_BUNDLES_MAX) {
2870
+ const oldest = map.keys().next().value;
2837
2871
  if (oldest === undefined)
2838
2872
  break;
2839
- REPLAY_BUNDLES.delete(oldest);
2873
+ map.delete(oldest);
2840
2874
  }
2841
2875
  }
2876
+ function stashReplayBundle(caseId, gz) {
2877
+ stashBundle(REPLAY_BUNDLES, caseId, gz);
2878
+ }
2879
+ /** Encode + park a case's coverage captures; returns the ref for the reply.
2880
+ * `undefined` when no service opted in, so uncovered projects ship nothing. */
2881
+ function parkCoverageBundle(caseId, captures) {
2882
+ if (captures.length === 0)
2883
+ return undefined;
2884
+ const gz = encodeCoverageBundle(caseId, captures);
2885
+ stashBundle(COVERAGE_BUNDLES, caseId, gz);
2886
+ return coverageBundleRef(gz, captures);
2887
+ }
2842
2888
  // ────────────────────────────────────────────────────────────────────────
2843
2889
  // Cumulative service logs (per-case deltas → S3, reconstructed on the web)
2844
2890
  // ────────────────────────────────────────────────────────────────────────
@@ -2930,6 +2976,97 @@ async function captureServiceLogDeltas() {
2930
2976
  };
2931
2977
  }));
2932
2978
  }
2979
+ // ────────────────────────────────────────────────────────────────────────
2980
+ // Coverage capture (opt-in per service; see harness/coverage.ts)
2981
+ // ────────────────────────────────────────────────────────────────────────
2982
+ //
2983
+ // Read after bring-up and after every test, on every case. The reports are
2984
+ // cumulative from process start and live on the rootfs, so what a child
2985
+ // fork reads already includes everything its ancestors ran — no markers,
2986
+ // no deltas (unlike the service logs above). A `command` runs in the
2987
+ // container first so the service writes a fresh report. The reports ship
2988
+ // verbatim; the server derives what it needs from the stored bytes.
2989
+ const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
2990
+ /** Set the browser-coverage host→service attribution from the loaded
2991
+ * project: only services that opted in to `coverage: { browser: true }`
2992
+ * are attributed, so a script from an uncovered service is ignored. Null
2993
+ * when no service opted in — coverage then attaches nothing. Called on
2994
+ * every /load. */
2995
+ function configureCoverageAttribution(project) {
2996
+ const env = project.environment;
2997
+ const browserServices = new Set();
2998
+ for (const svc of namedServices(env)) {
2999
+ if (coverageUsesBrowser(svc.coverage))
3000
+ browserServices.add(svc.name);
3001
+ }
3002
+ if (browserServices.size === 0) {
3003
+ configureBrowserCoverage(null);
3004
+ return;
3005
+ }
3006
+ const all = namedServices(env).map((s) => s.name);
3007
+ const table = {
3008
+ proxies: LOWERED.proxies,
3009
+ aliasesByService: LOWERED.aliasesByService,
3010
+ wildcards: LOWERED.wildcards,
3011
+ };
3012
+ configureBrowserCoverage({
3013
+ serviceForHost(host) {
3014
+ const svc = hostToService(host, all, table);
3015
+ return svc && browserServices.has(svc) ? svc : undefined;
3016
+ },
3017
+ });
3018
+ }
3019
+ async function captureServiceCoverage() {
3020
+ const l = loaded;
3021
+ if (!l)
3022
+ return [];
3023
+ const byName = new Map();
3024
+ for (const s of namedServices(l.project.environment))
3025
+ byName.set(s.name, s);
3026
+ for (const [name, s] of RUNTIME_SERVICES)
3027
+ byName.set(name, s);
3028
+ const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3029
+ // Browser coverage is per-service lcov, accumulated in module memory
3030
+ // (browser-coverage.ts) across every harvest; read it once here.
3031
+ const browser = browserCoverageReports();
3032
+ return Promise.all(services.map(async (svc) => {
3033
+ const cov = svc.coverage;
3034
+ const reports = [];
3035
+ // Container reports (a `command` runs first to flush a fresh dump).
3036
+ if (coverageUsesContainer(cov)) {
3037
+ if (typeof cov === "object" && cov !== null && cov.command) {
3038
+ const r = await docker(["exec", svc.name, "sh", "-c", cov.command], COVERAGE_COMMAND_TIMEOUT_MS);
3039
+ if (r.code !== 0) {
3040
+ const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
3041
+ return {
3042
+ service: svc.name,
3043
+ reports: [],
3044
+ error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
3045
+ };
3046
+ }
3047
+ }
3048
+ const dir = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3049
+ // A service that ALSO does browser coverage may legitimately have
3050
+ // an empty container dir for a test — don't fail if the browser
3051
+ // side has something. Otherwise the container contract stands.
3052
+ if (dir.error && !(coverageUsesBrowser(cov) && browser.has(svc.name))) {
3053
+ return dir;
3054
+ }
3055
+ reports.push(...dir.reports);
3056
+ }
3057
+ // Browser report for this service, when any script it served ran.
3058
+ const lcov = browser.get(svc.name);
3059
+ if (lcov)
3060
+ reports.push({ name: "browser.lcov", content: lcov });
3061
+ if (reports.length === 0 && coverageUsesBrowser(cov) && !coverageUsesContainer(cov)) {
3062
+ // Browser-only service, no browser activity yet this branch. Not an
3063
+ // error — a test needn't open a browser; the report appears once
3064
+ // one does (and forks to descendants from there).
3065
+ return { service: svc.name, reports: [] };
3066
+ }
3067
+ return { service: svc.name, reports };
3068
+ }));
3069
+ }
2933
3070
  /**
2934
3071
  * Mint a session id. `idScope` (the running test's case id; `"eval"`
2935
3072
  * for eval-context sessions) is baked in because `randomUUID()` alone
@@ -4128,6 +4265,33 @@ async function runOne(testCase) {
4128
4265
  // eslint-disable-next-line no-console
4129
4266
  console.warn("[service-logs] delta capture failed:", err);
4130
4267
  }
4268
+ // Coverage for the opted-in services, read after the clock stops like
4269
+ // the log deltas. Unlike them it is NOT best-effort: a service that opted
4270
+ // in and produced nothing readable fails the case, so a hole in the
4271
+ // coverage data is visible where it happened rather than silently
4272
+ // widening every later subset run.
4273
+ let coverageCaptures = [];
4274
+ try {
4275
+ coverageCaptures = await captureServiceCoverage();
4276
+ }
4277
+ catch (err) {
4278
+ coverageCaptures = [
4279
+ {
4280
+ service: "*",
4281
+ reports: [],
4282
+ error: `coverage capture failed: ${err instanceof Error ? err.message : String(err)}`,
4283
+ },
4284
+ ];
4285
+ }
4286
+ const coverageErrors = coverageCaptures
4287
+ .filter((c) => c.error)
4288
+ .map((c) => c.error);
4289
+ if (coverageErrors.length > 0 && outcome.status === "passed") {
4290
+ outcome = {
4291
+ status: "failed",
4292
+ error: { message: coverageErrors.join("\n") },
4293
+ };
4294
+ }
4131
4295
  const events = stopRecording();
4132
4296
  // Drop sessions whose linking event didn't survive — an exec/terminal
4133
4297
  // inside a failed `ctx.poll` iteration has its events removed by
@@ -4147,6 +4311,7 @@ async function runOne(testCase) {
4147
4311
  browserSessions: sessions.map((s) => s.record),
4148
4312
  terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
4149
4313
  serviceLogDeltas,
4314
+ coverageCaptures,
4150
4315
  error: outcome.error,
4151
4316
  };
4152
4317
  }
@@ -4898,7 +5063,20 @@ export function harnessMethods(state) {
4898
5063
  // eslint-disable-next-line no-console
4899
5064
  console.warn("[service-logs] baseline capture failed:", err);
4900
5065
  }
4901
- return { serviceLogDeltas };
5066
+ // The coverage baseline rides the same call: what the opted-in
5067
+ // services ran during bring-up + project setup, parked under the
5068
+ // synthetic `__setup__` case for `coverageChunk`. A capture error is
5069
+ // reported in the ref, not thrown — there is no case to fail here,
5070
+ // the control plane logs it.
5071
+ let coverage;
5072
+ try {
5073
+ coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
5074
+ }
5075
+ catch (err) {
5076
+ // eslint-disable-next-line no-console
5077
+ console.warn("[coverage] baseline capture failed:", err);
5078
+ }
5079
+ return { serviceLogDeltas, coverage };
4902
5080
  },
4903
5081
  run: async (params) => {
4904
5082
  const caseId = requireString(params, "caseId", "caseId");
@@ -4912,7 +5090,10 @@ export function harnessMethods(state) {
4912
5090
  // (with a warning) rather than failing the case — and never falls
4913
5091
  // back to inlining, which is exactly the oversized reply this path
4914
5092
  // exists to avoid.
4915
- const { browserSessions, ...wire } = result;
5093
+ const { browserSessions, coverageCaptures, ...wire } = result;
5094
+ // Coverage reports take the same side channel: park, reply with a
5095
+ // ref, let the control plane pull through `coverageChunk`.
5096
+ const coverage = parkCoverageBundle(caseId, coverageCaptures);
4916
5097
  let replay;
4917
5098
  if (browserSessions.length > 0) {
4918
5099
  try {
@@ -4925,7 +5106,7 @@ export function harnessMethods(state) {
4925
5106
  console.warn("[replay] bundle encode failed; dropping recordings:", err);
4926
5107
  }
4927
5108
  }
4928
- return { ...wire, replay };
5109
+ return { ...wire, replay, coverage };
4929
5110
  });
4930
5111
  },
4931
5112
  // One chunk of a parked replay bundle. Case ids are arbitrary user
@@ -4937,6 +5118,14 @@ export function harnessMethods(state) {
4937
5118
  throw notFound(`no replay bundle for case: ${caseId}`);
4938
5119
  return replayChunk(gz, params.offset, params.limit);
4939
5120
  },
5121
+ // One chunk of a parked coverage bundle (same shape as replayChunk).
5122
+ coverageChunk: async (params) => {
5123
+ const caseId = requireString(params, "caseId", "caseId");
5124
+ const gz = COVERAGE_BUNDLES.get(caseId);
5125
+ if (!gz)
5126
+ throw notFound(`no coverage bundle for case: ${caseId}`);
5127
+ return replayChunk(gz, params.offset, params.limit);
5128
+ },
4940
5129
  };
4941
5130
  }
4942
5131
  /**
@@ -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;