@specific.dev/spectest 0.57.0 → 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,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
+ }
package/dist/index.d.ts CHANGED
@@ -669,7 +669,22 @@ export type ServiceImage = {
669
669
  * spectest must run a command in the container to produce one.
670
670
  */
671
671
  export type ServiceCoverage = true | {
672
- command: string;
672
+ /** Run this (via `sh -c`) in the container before the coverage
673
+ * directory is read, so the service writes a fresh lcov report. */
674
+ command?: string;
675
+ /**
676
+ * Collect **frontend** coverage for the app this service serves.
677
+ * The code runs in the guest browser — spectest's own process — so
678
+ * the user cannot write a report for it: spectest turns on V8
679
+ * coverage for every script `ctx.browser()`/`ctx.mobile()` loads
680
+ * from this service's origin, maps it back to original sources
681
+ * through the served source maps, and adds one lcov report to this
682
+ * service's capture. Scripts a source map can't resolve are
683
+ * attributed to their served path (which the repo mapping then
684
+ * treats as unknown — the safe answer). Needs the app to serve
685
+ * source maps to the test environment.
686
+ */
687
+ browser?: boolean;
673
688
  };
674
689
  export interface VolumeMount {
675
690
  /**
@@ -888,6 +903,12 @@ export interface FakeContext {
888
903
  * same rule to a runtime `startService` spec.
889
904
  */
890
905
  export declare function validateCoverage(service: string, cov: unknown): void;
906
+ /** Whether a `coverage` value collects reports from the container's own
907
+ * filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
908
+ * any object that isn't browser-only. */
909
+ export declare function coverageUsesContainer(cov: ServiceCoverage | undefined): boolean;
910
+ /** Whether a `coverage` value collects frontend (browser) coverage. */
911
+ export declare function coverageUsesBrowser(cov: ServiceCoverage | undefined): boolean;
891
912
  /**
892
913
  * A single test step. Created via `test(...)` or `createTest(services)`.
893
914
  * Tests are referenced (not named by string) when one test depends on
package/dist/index.js CHANGED
@@ -222,13 +222,29 @@ function expandServiceGroups(input) {
222
222
  export function validateCoverage(service, cov) {
223
223
  if (cov === undefined || cov === true)
224
224
  return;
225
- if (typeof cov === "object" &&
226
- cov !== null &&
227
- typeof cov.command === "string" &&
228
- cov.command.trim().length > 0) {
229
- return;
225
+ if (typeof cov === "object" && cov !== null) {
226
+ const o = cov;
227
+ const cmdOk = o.command === undefined ||
228
+ (typeof o.command === "string" && o.command.trim().length > 0);
229
+ const browserOk = o.browser === undefined || typeof o.browser === "boolean";
230
+ if (cmdOk && browserOk && (o.command !== undefined || o.browser === true))
231
+ return;
230
232
  }
231
- throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``);
233
+ throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\`, \`{ command: "<shell command>" }\`, or \`{ browser: true }\``);
234
+ }
235
+ /** Whether a `coverage` value collects reports from the container's own
236
+ * filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
237
+ * any object that isn't browser-only. */
238
+ export function coverageUsesContainer(cov) {
239
+ if (cov === undefined)
240
+ return false;
241
+ if (cov === true)
242
+ return true;
243
+ return cov.command !== undefined || cov.browser !== true;
244
+ }
245
+ /** Whether a `coverage` value collects frontend (browser) coverage. */
246
+ export function coverageUsesBrowser(cov) {
247
+ return typeof cov === "object" && cov.browser === true;
232
248
  }
233
249
  function validateEnvironmentConfig(config) {
234
250
  const entries = Object.entries(config.services);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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