@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.
@@ -2,12 +2,15 @@
2
2
  //
3
3
  // A service that opts in (`coverage: true | { command }`) gets a writable
4
4
  // directory bind-mounted at `/spectest/coverage/` in its container. The
5
- // service's own tool writes lcov reports there. The daemon reads the
5
+ // service's own tool, or its coverage adapters, write reports there —
6
+ // lcov, or the V8 coverage JSON that `NODE_V8_COVERAGE` writes. The daemon reads the
6
7
  // directory from the VM side (no `docker exec` to fetch) after bring-up
7
8
  // and after each test, and ships the reports **verbatim** to the control
8
9
  // plane as a gzipped bundle. Nothing is interpreted here beyond the one
9
- // contract the harness must fail a case on: at least one report, and at
10
- // least one lcov `SF:` record among them. The server derives whatever it
10
+ // contract the harness must fail a case on: at least one report, every
11
+ // one well-formed. Counters may reset between dumps and a dump may be a
12
+ // new file: the directory forks with the environment, so it accumulates
13
+ // along the ancestor chain by itself. The server derives whatever it
11
14
  // needs (today the set of source files; later, finer things) from the
12
15
  // stored bytes, so a change in that derivation is a re-read of stored
13
16
  // runs, never a hole in the history.
@@ -19,7 +22,7 @@ import { gzipSync } from "node:zlib";
19
22
  /** Where the directory is mounted inside an opted-in container. */
20
23
  export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
21
24
  /** Raw bytes of reports one service may ship per capture. Reports are
22
- * cumulative and bounded by code size, so anything past this is a
25
+ * bounded by code size times chain depth, so anything past this is a
23
26
  * runaway (a tool writing a new file per request), not coverage. */
24
27
  export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
25
28
  /** Root of the per-service host directories, under the workspace so the
@@ -31,6 +34,34 @@ export function coverageHostDir(workspace, service) {
31
34
  export function hasLcovSourceFile(text) {
32
35
  return /^SF:\S/m.test(text);
33
36
  }
37
+ /** True when `text` is lcov-shaped: at least one line, every non-blank
38
+ * line an `XX:` record or `end_of_record`. An lcov with no `SF:` record
39
+ * at all (`TN:` alone) is well-formed — it is a report that says
40
+ * nothing ran. */
41
+ export function isLcovShaped(text) {
42
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
43
+ return lines.length > 0 && lines.every((l) => /^[A-Z]{2,5}:/.test(l) || l.trim() === "end_of_record");
44
+ }
45
+ /**
46
+ * True when `text` looks like a V8 coverage JSON document — what
47
+ * `NODE_V8_COVERAGE` / `v8.takeCoverage()` write: an object with a
48
+ * `result` array of scripts. A cheap shape check, not a parse (a report
49
+ * can be megabytes); the control plane parses for real.
50
+ */
51
+ export function isV8CoverageJson(text) {
52
+ return /^\s*\{/.test(text) && /"result"\s*:\s*\[/.test(text);
53
+ }
54
+ /** True when `text` is a report spectest stores: lcov or V8 JSON. */
55
+ export function isWellFormedReport(text) {
56
+ return isLcovShaped(text) || isV8CoverageJson(text);
57
+ }
58
+ /** True when a well-formed report names no source file at all (an lcov
59
+ * with no `SF:`, a V8 document with an empty `result`). */
60
+ export function isEmptyReport(text) {
61
+ if (isV8CoverageJson(text))
62
+ return /"result"\s*:\s*\[\s*\]/.test(text);
63
+ return !hasLcovSourceFile(text);
64
+ }
34
65
  /** Every regular file under `dir`, recursively, as paths relative to it.
35
66
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
36
67
  * a tmp file mid-write — is not a report). */
@@ -58,9 +89,11 @@ export async function listReportFiles(dir) {
58
89
  return out.sort();
59
90
  }
60
91
  /**
61
- * Read every report under `dir`, verbatim. An empty directory, or one with
62
- * no `SF:` record anywhere, is an error rather than an empty capture: the
63
- * contract is that an opted-in service always has a report to read.
92
+ * Read every report under `dir`, verbatim. An empty directory, or a file
93
+ * that is neither lcov nor V8 JSON, is an error rather than an empty
94
+ * capture: the contract is that an opted-in service always has a
95
+ * well-formed report to read. A report that names no file is fine — it
96
+ * says nothing ran.
64
97
  */
65
98
  export async function readCoverageDir(service, dir) {
66
99
  const names = await listReportFiles(dir);
@@ -68,12 +101,13 @@ export async function readCoverageDir(service, dir) {
68
101
  return {
69
102
  service,
70
103
  reports: [],
71
- error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov file there`,
104
+ error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov or V8 coverage JSON file there`,
72
105
  };
73
106
  }
74
107
  const reports = [];
75
108
  let total = 0;
76
- let anyLcov = false;
109
+ let anyReport = false;
110
+ const malformed = [];
77
111
  for (const name of names) {
78
112
  let content;
79
113
  try {
@@ -87,18 +121,20 @@ export async function readCoverageDir(service, dir) {
87
121
  return {
88
122
  service,
89
123
  reports: [],
90
- error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — reports must be cumulative, not one file per dump`,
124
+ error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — that is a runaway, not coverage`,
91
125
  };
92
126
  }
93
- if (hasLcovSourceFile(content))
94
- anyLcov = true;
127
+ if (isWellFormedReport(content))
128
+ anyReport = true;
129
+ else
130
+ malformed.push(name);
95
131
  reports.push({ name, content });
96
132
  }
97
- if (!anyLcov) {
133
+ if (!anyReport || malformed.length > 0) {
98
134
  return {
99
135
  service,
100
136
  reports,
101
- error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none holds an lcov \`SF:\` record — only lcov reports are read`,
137
+ error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but ${anyReport ? `${malformed.slice(0, 5).join(", ")} ${malformed.length === 1 ? "is" : "are"} neither` : "none is"} an lcov report or a V8 coverage JSON document — only those two formats are read`,
102
138
  };
103
139
  }
104
140
  return { service, reports };
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ export type { Mobile, MobileApp } from "./mobile.js";
20
20
  import type { Mobile, MobileApp } from "./mobile.js";
21
21
  export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
22
22
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
23
+ export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
24
+ import { type ServiceCoverage } from "./coverage.js";
23
25
  export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
24
26
  export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
25
27
  import type { DnsTarget } from "./ingress.js";
@@ -119,20 +121,24 @@ export interface ServiceConfig {
119
121
  * Opt this service in to **code coverage collection** (experimental).
120
122
  *
121
123
  * The service gets a writable directory bind-mounted at
122
- * `/spectest/coverage/`. The service's own coverage tool writes **lcov**
123
- * reports there — cumulative from process start, never reset. After
124
- * bring-up and after every test, spectest runs `command` (if given) inside
125
- * the container, then reads every file in the directory and records the
126
- * set of source files that ran. Only the `SF:` records are read.
124
+ * `/spectest/coverage/`. After bring-up and after every test, spectest
125
+ * reads every file in it — **lcov** or **V8 coverage JSON**, verbatim —
126
+ * and stores them with the test; the control plane derives what ran.
127
127
  *
128
- * - `true`: the service writes its reports by itself (a dump on a timer,
129
- * an exit-time dump for short-lived processes).
130
- * - `{ command }`: run this (via `sh -c`) in the container to make the
131
- * service write a report, before the directory is read.
128
+ * - `true`: the program writes its own reports there (an exit-time dump
129
+ * for a short-lived process, a dump on a timer, its own hook).
130
+ * - `{ adapters: [...] }`: spectest gets the reports out. From
131
+ * `@specific.dev/spectest/coverage`: `node()` (V8 JSON from a node
132
+ * service, through a hook spectest mounts), `browser()` (the frontend
133
+ * this service serves, collected from the guest browser),
134
+ * `command(cmd)` (run a command in the container first), or your own
135
+ * via `defineAdapter`.
132
136
  *
133
- * The directory must hold at least one lcov report each time it is read;
134
- * an empty directory or a failed command fails the test with a clear
135
- * error, so a hole in the coverage data is never silent. See
137
+ * The directory must hold at least one well-formed report each time it
138
+ * is read; an empty directory or a failing adapter fails the test with a
139
+ * clear error, so a hole in the coverage data is never silent. Reports
140
+ * accumulate along the test's ancestor chain by themselves (the
141
+ * directory forks with the environment). See
136
142
  * `spectest docs /services/coverage`.
137
143
  */
138
144
  coverage?: ServiceCoverage;
@@ -663,14 +669,6 @@ export type ServiceImage = {
663
669
  /** Extra glob patterns to exclude from the build context. */
664
670
  exclude?: readonly string[];
665
671
  };
666
- /**
667
- * A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
668
- * `true` when the service writes its own reports; `{ command }` when
669
- * spectest must run a command in the container to produce one.
670
- */
671
- export type ServiceCoverage = true | {
672
- command: string;
673
- };
674
672
  export interface VolumeMount {
675
673
  /**
676
674
  * Named shared volume. Two services mounting the same `name` share one
@@ -883,11 +881,6 @@ export interface FakeContext {
883
881
  * for a TLS-provisioning provider hands back to the app under test. */
884
882
  certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
885
883
  }
886
- /**
887
- * Check one service's `coverage` field. Exported so the daemon applies the
888
- * same rule to a runtime `startService` spec.
889
- */
890
- export declare function validateCoverage(service: string, cov: unknown): void;
891
884
  /**
892
885
  * A single test step. Created via `test(...)` or `createTest(services)`.
893
886
  * Tests are referenced (not named by string) when one test depends on
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
40
40
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
41
41
  import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
42
42
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
43
+ import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
43
44
  // Low-level ingress primitives + the framework lowering that the friendly
44
45
  // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
45
46
  // `ingress.ts`.
@@ -215,21 +216,6 @@ function expandServiceGroups(input) {
215
216
  }
216
217
  return out;
217
218
  }
218
- /**
219
- * Check one service's `coverage` field. Exported so the daemon applies the
220
- * same rule to a runtime `startService` spec.
221
- */
222
- export function validateCoverage(service, cov) {
223
- if (cov === undefined || cov === true)
224
- return;
225
- if (typeof cov === "object" &&
226
- cov !== null &&
227
- typeof cov.command === "string" &&
228
- cov.command.trim().length > 0) {
229
- return;
230
- }
231
- throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``);
232
- }
233
219
  function validateEnvironmentConfig(config) {
234
220
  const entries = Object.entries(config.services);
235
221
  const serviceNames = new Set(entries.map(([n]) => n));
@@ -337,9 +323,13 @@ export function defineEnvironment(input) {
337
323
  // { name, services, timeoutSecs } — with plain, fully-expanded services —
338
324
  // is stored as `config` and shipped to the control plane.
339
325
  const { fakes, services: rawServices, ...rest } = input;
326
+ const expanded = expandServiceGroups(rawServices);
327
+ for (const [key, svc] of Object.entries(expanded)) {
328
+ expanded[key] = applyCoverageAdapters(key, svc);
329
+ }
340
330
  const config = {
341
331
  ...rest,
342
- services: expandServiceGroups(rawServices),
332
+ services: expanded,
343
333
  };
344
334
  validateEnvironmentConfig(config);
345
335
  if (fakes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,11 @@
16
16
  "bun": "./src/components/index.ts",
17
17
  "default": "./dist/components/index.js"
18
18
  },
19
+ "./coverage": {
20
+ "types": "./dist/coverage.d.ts",
21
+ "bun": "./src/coverage.ts",
22
+ "default": "./dist/coverage.js"
23
+ },
19
24
  "./browser": {
20
25
  "types": "./dist/browser.d.ts",
21
26
  "bun": "./src/browser.ts",
@@ -0,0 +1,209 @@
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
+
22
+ import { createHash } from "node:crypto";
23
+ import type { CDPSession } from "playwright-core";
24
+ import {
25
+ type DecodedSourceMap,
26
+ type FileCounts,
27
+ decodeSourceMap,
28
+ lcovDocument,
29
+ mapToOriginal,
30
+ mergeFileCounts,
31
+ rangesToLineCounts,
32
+ type V8FunctionCoverage,
33
+ } from "./harness/browser-coverage.js";
34
+ import { rawFetch } from "./harness/raw-fetch.js";
35
+
36
+ export interface BrowserCoverageConfig {
37
+ /** Which covered service (if any) serves scripts from this host. */
38
+ serviceForHost(host: string): string | undefined;
39
+ }
40
+
41
+ let CONFIG: BrowserCoverageConfig | null = null;
42
+
43
+ /** Set (or clear, with `null`) at /load. */
44
+ export function configureBrowserCoverage(config: BrowserCoverageConfig | null): void {
45
+ CONFIG = config;
46
+ }
47
+
48
+ export function browserCoverageActive(): boolean {
49
+ return CONFIG !== null;
50
+ }
51
+
52
+ /** service → file → line → count, summed over every harvest. */
53
+ const TOTALS = new Map<string, FileCounts>();
54
+
55
+ interface ScriptInfo {
56
+ url: string;
57
+ sourceMapURL?: string;
58
+ source?: string;
59
+ /** `undefined` = not looked up yet; `null` = none / unusable. */
60
+ map?: DecodedSourceMap | null;
61
+ }
62
+
63
+ interface Collector {
64
+ cdp: CDPSession;
65
+ scripts: Map<string, ScriptInfo>;
66
+ inFlight: Promise<void> | null;
67
+ }
68
+
69
+ const COLLECTORS = new WeakMap<CDPSession, Collector>();
70
+
71
+ /** Decoded maps by URL (or content hash for inline `data:` maps). */
72
+ const SOURCE_MAPS = new Map<string, DecodedSourceMap | null>();
73
+
74
+ const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
75
+
76
+ /** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
77
+ * service opted in. */
78
+ export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
79
+ if (!CONFIG || COLLECTORS.has(cdp)) return;
80
+ const collector: Collector = { cdp, scripts: new Map(), inFlight: null };
81
+ COLLECTORS.set(cdp, collector);
82
+ cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
83
+ if (!ev.url) return;
84
+ collector.scripts.set(ev.scriptId, {
85
+ url: ev.url,
86
+ sourceMapURL: ev.sourceMapURL || undefined,
87
+ });
88
+ });
89
+ await cdp.send("Debugger.enable");
90
+ await cdp.send("Profiler.enable");
91
+ await cdp.send("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
92
+ }
93
+
94
+ /**
95
+ * Take the session's counters and fold them into the totals. Concurrent
96
+ * calls coalesce onto the in-flight one. Errors (a page mid-navigation,
97
+ * a closed view) are swallowed: the counters then stay in V8 for the next
98
+ * take, nothing is lost.
99
+ */
100
+ export async function harvestBrowserCoverage(cdp: CDPSession): Promise<void> {
101
+ const c = COLLECTORS.get(cdp);
102
+ if (!c || !CONFIG) return;
103
+ if (c.inFlight) return c.inFlight;
104
+ c.inFlight = harvestInner(c, CONFIG).catch(() => {}).finally(() => {
105
+ c.inFlight = null;
106
+ });
107
+ return c.inFlight;
108
+ }
109
+
110
+ async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promise<void> {
111
+ const { result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as {
112
+ result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
113
+ };
114
+ for (const script of result) {
115
+ const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
116
+ if (!info) continue;
117
+ let url: URL;
118
+ try {
119
+ url = new URL(info.url);
120
+ } catch {
121
+ continue;
122
+ }
123
+ if (url.protocol !== "http:" && url.protocol !== "https:") continue;
124
+ const service = config.serviceForHost(url.hostname);
125
+ if (!service) continue;
126
+ if (info.source === undefined) {
127
+ try {
128
+ const r = (await c.cdp.send("Debugger.getScriptSource", {
129
+ scriptId: script.scriptId,
130
+ })) as { scriptSource: string };
131
+ info.source = r.scriptSource;
132
+ } catch {
133
+ continue; // script gone with its document
134
+ }
135
+ c.scripts.set(script.scriptId, info);
136
+ }
137
+ const generated = rangesToLineCounts(info.source, script.functions);
138
+ if (generated.size === 0) continue;
139
+ if (info.map === undefined) info.map = await sourceMapFor(info, url);
140
+ const files: FileCounts = info.map
141
+ ? mapToOriginal(info.map, generated)
142
+ : new Map([[url.pathname, generated]]);
143
+ let totals = TOTALS.get(service);
144
+ if (!totals) {
145
+ totals = new Map();
146
+ TOTALS.set(service, totals);
147
+ }
148
+ mergeFileCounts(totals, files);
149
+ }
150
+ }
151
+
152
+ /** Resolve, fetch (or inline-decode) and decode a script's source map. */
153
+ async function sourceMapFor(info: ScriptInfo, scriptUrl: URL): Promise<DecodedSourceMap | null> {
154
+ let ref = info.sourceMapURL;
155
+ if (!ref && info.source) {
156
+ const m = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/.exec(info.source.slice(-4096));
157
+ if (m) ref = m[1];
158
+ }
159
+ if (!ref) return null;
160
+ if (ref.startsWith("data:")) {
161
+ const key = "data:" + createHash("sha1").update(ref).digest("hex");
162
+ const cached = SOURCE_MAPS.get(key);
163
+ if (cached !== undefined) return cached;
164
+ const map = decodeSourceMap(parseDataUrl(ref));
165
+ SOURCE_MAPS.set(key, map);
166
+ return map;
167
+ }
168
+ let mapUrl: string;
169
+ try {
170
+ mapUrl = new URL(ref, scriptUrl).toString();
171
+ } catch {
172
+ return null;
173
+ }
174
+ const cached = SOURCE_MAPS.get(mapUrl);
175
+ if (cached !== undefined) return cached;
176
+ let map: DecodedSourceMap | null = null;
177
+ try {
178
+ const res = await rawFetch(mapUrl, { signal: AbortSignal.timeout(SOURCE_MAP_FETCH_TIMEOUT_MS) });
179
+ if (res.ok) map = decodeSourceMap(await res.json());
180
+ } catch {
181
+ map = null;
182
+ }
183
+ SOURCE_MAPS.set(mapUrl, map);
184
+ return map;
185
+ }
186
+
187
+ function parseDataUrl(ref: string): unknown {
188
+ const comma = ref.indexOf(",");
189
+ if (comma < 0) return null;
190
+ const meta = ref.slice(5, comma);
191
+ const payload = ref.slice(comma + 1);
192
+ try {
193
+ const text = /;base64$/i.test(meta)
194
+ ? Buffer.from(payload, "base64").toString("utf8")
195
+ : decodeURIComponent(payload);
196
+ return JSON.parse(text);
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /** One lcov document per service that has any browser coverage. */
203
+ export function browserCoverageReports(): Map<string, string> {
204
+ const out = new Map<string, string>();
205
+ for (const [service, files] of TOTALS) {
206
+ if (files.size > 0) out.set(service, lcovDocument(files));
207
+ }
208
+ return out;
209
+ }
package/src/browser.ts CHANGED
@@ -38,6 +38,11 @@ import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./r
38
38
  import { wrap } from "./inspect.js";
39
39
  import type { Wrapped } from "./inspect.js";
40
40
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
41
+ import {
42
+ attachBrowserCoverage,
43
+ browserCoverageActive,
44
+ harvestBrowserCoverage,
45
+ } from "./browser-coverage.js";
41
46
  import type { UrlPattern } from "./url-match.js";
42
47
  export type { UrlPattern } from "./url-match.js";
43
48
  import {
@@ -1223,6 +1228,13 @@ async function spawnPage(context: BrowserContext): Promise<SpawnedPage> {
1223
1228
  console.warn("[spectest] failed to install rrweb recorder:", err);
1224
1229
  }
1225
1230
  }
1231
+ // Frontend coverage (opt-in): turn on V8 precise coverage for this page.
1232
+ // A no-op unless a service declared `coverage: { browser: true }`.
1233
+ try {
1234
+ await attachBrowserCoverage(cdp);
1235
+ } catch {
1236
+ /* coverage is best-effort; a page without it still works */
1237
+ }
1226
1238
  return { page, cdp, recordingInstalled };
1227
1239
  }
1228
1240
 
@@ -1558,6 +1570,13 @@ async function rebuildView(holder: ViewHolder): Promise<void> {
1558
1570
  } catch {
1559
1571
  /* page may already be gone */
1560
1572
  }
1573
+ // The old renderer's coverage counters die with its page; harvest what
1574
+ // it had before the swap (rebuild is triggered on a fresh navigation).
1575
+ try {
1576
+ await harvestBrowserCoverage(holder.cdp);
1577
+ } catch {
1578
+ /* best-effort */
1579
+ }
1561
1580
  const fresh = await spawnPage(holder.context);
1562
1581
  holder.page = fresh.page;
1563
1582
  holder.cdp = fresh.cdp;
@@ -1660,6 +1679,16 @@ function buildBackend(
1660
1679
  durationMs: endT - t,
1661
1680
  }, resv);
1662
1681
  await drain(action);
1682
+ // Fold this page's V8 counters into the per-service totals now — a
1683
+ // navigation to another origin replaces the renderer and its
1684
+ // counters, so harvesting per op is what keeps them.
1685
+ if (browserCoverageActive()) {
1686
+ try {
1687
+ await harvestBrowserCoverage(holder.cdp);
1688
+ } catch {
1689
+ /* best-effort */
1690
+ }
1691
+ }
1663
1692
  // Reads (`opts.wrap`) return user-visible JS values someone is likely to
1664
1693
  // assert on — provenance-wrap so `expect(...)` nests under this step.
1665
1694
  // Actions return void/internals; leave them raw to avoid Proxy surprises.
@@ -1688,6 +1717,13 @@ function buildBackend(
1688
1717
  if (recordingEnded) return;
1689
1718
  // Final drain before we stop writing to this recorder.
1690
1719
  await drain("close");
1720
+ if (browserCoverageActive()) {
1721
+ try {
1722
+ await harvestBrowserCoverage(holder.cdp);
1723
+ } catch {
1724
+ /* best-effort */
1725
+ }
1726
+ }
1691
1727
  recordingEnded = true;
1692
1728
  }
1693
1729