@specific.dev/spectest 0.132.0 → 0.132.2

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.
@@ -9,18 +9,23 @@ import {
9
9
  function session() {
10
10
  const events = new EventEmitter();
11
11
  let source = "globalThis.ran = true;";
12
+ let isolateId = "renderer-1";
12
13
  let url = "http://app.test/app.js";
13
14
  const cdp = Object.assign(events, {
14
15
  async send(method: string): Promise<any> {
16
+ if (method === "Runtime.getIsolateId") return { id: isolateId };
15
17
  if (method === "Profiler.takePreciseCoverage") return { result: [{ scriptId: "4", url,
16
18
  functions: [{ ranges: [{ startOffset: 0, endOffset: source.length, count: 1 }] }] }] };
17
19
  if (method === "Debugger.getScriptSource") return { scriptSource: source };
18
20
  return {};
19
21
  },
20
22
  }) as unknown as CDPSession;
21
- return { cdp, parsed(nextUrl = url, nextSource = source, sourceMapURL?: string) {
23
+ return { cdp, navigate(id = isolateId) {
24
+ isolateId = id;
25
+ events.emit("Runtime.executionContextsCleared");
26
+ }, parsed(nextUrl = url, nextSource = source, sourceMapURL?: string, hash?: string) {
22
27
  url = nextUrl; source = nextSource;
23
- events.emit("Debugger.scriptParsed", { scriptId: "4", url, sourceMapURL });
28
+ events.emit("Debugger.scriptParsed", { scriptId: "4", url, sourceMapURL, hash, executionContextId: 1 });
24
29
  } };
25
30
  }
26
31
 
@@ -85,3 +90,110 @@ for (const phase of ["Profiler.takePreciseCoverage", "Debugger.getScriptSource"]
85
90
  } finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
86
91
  });
87
92
  }
93
+
94
+
95
+ test("a new renderer can reuse an ID whose parse event arrives during the take", async () => {
96
+ const s = session();
97
+ const service = "identity-renderer";
98
+ configureBrowserCoverage({ serviceForHost: () => service });
99
+ try {
100
+ await attachBrowserCoverage(s.cdp);
101
+ s.parsed(undefined, undefined, inlineMap("src/old.ts"));
102
+ await harvestBrowserCoverage(s.cdp);
103
+ s.navigate("renderer-2");
104
+ const send = s.cdp.send.bind(s.cdp);
105
+ s.cdp.send = (async (method: string) => {
106
+ if (method === "Profiler.takePreciseCoverage") {
107
+ s.parsed("http://app.test/new.js", "globalThis.newApp = true;", inlineMap("src/new.ts"));
108
+ }
109
+ return send(method as any);
110
+ }) as CDPSession["send"];
111
+ await harvestBrowserCoverage(s.cdp);
112
+ const report = takeBrowserCoverageReport(service);
113
+ expect(report).toContain("SF:src/old.ts\nDA:1,1");
114
+ expect(report).toContain("SF:src/new.ts\nDA:1,1");
115
+ } finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
116
+ });
117
+
118
+ test("same-renderer document changes retain metadata for outgoing script counters", async () => {
119
+ const s = session();
120
+ const service = "identity-same-renderer";
121
+ configureBrowserCoverage({ serviceForHost: () => service });
122
+ try {
123
+ await attachBrowserCoverage(s.cdp);
124
+ s.parsed(undefined, undefined, inlineMap("src/original.ts"));
125
+ await harvestBrowserCoverage(s.cdp);
126
+ takeBrowserCoverageReport(service);
127
+ s.navigate();
128
+ await harvestBrowserCoverage(s.cdp);
129
+ expect(takeBrowserCoverageReport(service)).toContain("SF:src/original.ts\nDA:1,1");
130
+ } finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
131
+ });
132
+
133
+ for (const phase of ["Profiler.takePreciseCoverage", "Debugger.getScriptSource"]) {
134
+ test(`a renderer swap during ${phase} retains an incomplete error even with matching script metadata`, async () => {
135
+ const s = session();
136
+ const service = `identity-renderer-race-${phase}`;
137
+ configureBrowserCoverage({ serviceForHost: () => service });
138
+ try {
139
+ await attachBrowserCoverage(s.cdp);
140
+ s.parsed(undefined, undefined, undefined, "same-source-hash");
141
+ const send = s.cdp.send.bind(s.cdp);
142
+ s.cdp.send = (async (method: string) => {
143
+ const result = await send(method as any);
144
+ if (method === phase) {
145
+ s.navigate("renderer-2");
146
+ s.parsed(undefined, undefined, undefined, "same-source-hash");
147
+ }
148
+ return result;
149
+ }) as CDPSession["send"];
150
+ await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("coverage is incomplete");
151
+ expect(takeBrowserCoverageReport(service)).toBe("TN:\n");
152
+ await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("coverage is incomplete");
153
+ } finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
154
+ });
155
+
156
+ test(`duplicate parse notifications during ${phase} preserve the same script identity`, async () => {
157
+ const s = session();
158
+ const service = `identity-duplicate-${phase}`;
159
+ configureBrowserCoverage({ serviceForHost: () => service });
160
+ try {
161
+ await attachBrowserCoverage(s.cdp);
162
+ s.parsed(undefined, undefined, inlineMap("src/original.ts"), "same-source-hash");
163
+ const send = s.cdp.send.bind(s.cdp);
164
+ s.cdp.send = (async (method: string) => {
165
+ const result = await send(method as any);
166
+ if (method === phase) s.parsed(undefined, undefined, inlineMap("src/original.ts"), "same-source-hash");
167
+ return result;
168
+ }) as CDPSession["send"];
169
+ await harvestBrowserCoverage(s.cdp);
170
+ expect(takeBrowserCoverageReport(service)).toContain("SF:src/original.ts\nDA:1,1");
171
+ } finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
172
+ });
173
+ }
174
+
175
+ for (const failure of ["take", "after take", "source"]) {
176
+ test(`a protocol error ${failure} retains an incomplete-capture error`, async () => {
177
+ const s = session();
178
+ const service = `identity-protocol-error-${failure}`;
179
+ configureBrowserCoverage({ serviceForHost: () => service });
180
+ try {
181
+ await attachBrowserCoverage(s.cdp);
182
+ s.parsed();
183
+ const send = s.cdp.send.bind(s.cdp);
184
+ let taken = false;
185
+ s.cdp.send = (async (method: string) => {
186
+ if (method === "Profiler.takePreciseCoverage") {
187
+ taken = true;
188
+ if (failure === "take") throw new Error("CDP disconnected");
189
+ }
190
+ if ((failure === "after take" && taken && method === "Runtime.getIsolateId") ||
191
+ (failure === "source" && method === "Debugger.getScriptSource")) throw new Error("CDP disconnected");
192
+ return send(method as any);
193
+ }) as CDPSession["send"];
194
+ await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("coverage is incomplete");
195
+ expect(takeBrowserCoverageReport(service)).toBe("TN:\n");
196
+ await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("coverage is incomplete");
197
+ } finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
198
+ });
199
+ }
@@ -6,8 +6,7 @@ import {
6
6
  takeBrowserCoverageReport,
7
7
  } from "./browser-coverage.js";
8
8
 
9
- const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ??
10
- Bun.which("google-chrome") ?? chromium.executablePath();
9
+ const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome") ?? chromium.executablePath();
11
10
 
12
11
  test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attribute anonymous code to an earlier empty script", async () => {
13
12
  const emptyScript = "/ingest/static/1.438.19/exception-autocapture.js";
@@ -0,0 +1,91 @@
1
+ import { expect, test } from "bun:test";
2
+ import { openBrowser } from "./browser.js";
3
+ import { configureBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
4
+
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
6
+
7
+ test.skipIf(!available)("SDK navigation collects outgoing counters across about:blank, server restart, and cross-site return", async () => {
8
+ let version: "before" | "after" = "before";
9
+ function gate() {
10
+ let release!: () => void;
11
+ let ran!: () => void;
12
+ const released = new Promise<void>(resolve => { release = resolve; });
13
+ const outgoingRan = new Promise<void>(resolve => { ran = resolve; });
14
+ return { release, ran, released, outgoingRan };
15
+ }
16
+ const gates = { before: gate(), after: gate() };
17
+ const serve = (port: number) => Bun.serve({ hostname: "0.0.0.0", port, async fetch(request) {
18
+ const url = new URL(request.url);
19
+ if (url.pathname === "/release") { await gates[url.searchParams.get("version") as typeof version].released; return new Response("released"); }
20
+ if (url.pathname === "/ran") { gates[url.searchParams.get("version") as typeof version].ran(); return new Response("ran"); }
21
+ if (url.pathname === "/app.js.map") return Response.json({ version: 3, sources: [`src/${version}.ts`], names: [], mappings: "AAAA;AACA;AACA;AACA;AACA" });
22
+ if (url.pathname === "/app.js") {
23
+ return new Response(`globalThis.ranOnLoad = true;\nfetch("/release?version=${version}").then(() => {\n globalThis.ranBeforeLeaving = true;\n fetch("/ran?version=${version}");\n});\n//# sourceMappingURL=/app.js.map`,
24
+ { headers: { "content-type": "application/javascript", "cache-control": "no-store" } });
25
+ }
26
+ return new Response('<script src="/app.js"></script><title>Coverage navigation</title>',
27
+ { headers: { "content-type": "text/html", "cache-control": "no-store" } });
28
+ } });
29
+ let server = serve(0);
30
+ const port = server.port!;
31
+ const origin = `http://127.0.0.1:${port}`;
32
+ const service = "sdk-coverage-navigation";
33
+ configureBrowserCoverage({ serviceForHost: host => host === "127.0.0.1" ? service : undefined });
34
+ const browser = await openBrowser({ recorder: { sessionId: "coverage-navigation", recordStep() {} } });
35
+ try {
36
+ await browser.goto(origin);
37
+ const initial = takeBrowserCoverageReport(service);
38
+ expect(initial).toContain("SF:src/before.ts");
39
+ expect(initial).toContain("DA:3,0");
40
+ // App code executes while the SDK is idle. Only the pre-navigation harvest
41
+ // can preserve it when goto discards this document/renderer.
42
+ gates.before.release();
43
+ await gates.before.outgoingRan;
44
+ await browser.goto("about:blank");
45
+ const outgoing = takeBrowserCoverageReport(service);
46
+ expect(outgoing).toContain("SF:src/before.ts");
47
+ expect(outgoing).toContain("DA:3,1");
48
+ server.stop(true);
49
+ version = "after";
50
+ server = serve(port);
51
+ await browser.goto(origin);
52
+ expect(takeBrowserCoverageReport(service)).toContain("DA:3,0");
53
+ gates.after.release();
54
+ await gates.after.outgoingRan;
55
+ // A distinct site forces a renderer transition even in Shell versions
56
+ // where about:blank retains the original renderer.
57
+ await browser.goto(`http://127.0.0.2:${port}`);
58
+ const beforeSwap = takeBrowserCoverageReport(service);
59
+ expect(beforeSwap).toContain("SF:src/after.ts");
60
+ expect(beforeSwap).toContain("DA:3,1");
61
+ await browser.goto(origin);
62
+ const returned = takeBrowserCoverageReport(service);
63
+ expect(returned).toContain("SF:src/after.ts");
64
+ expect(returned).not.toContain("src/before.ts");
65
+ expect(returned).toContain("DA:1,1");
66
+ } finally {
67
+ await browser.close();
68
+ configureBrowserCoverage(null);
69
+ takeBrowserCoverageReport(service);
70
+ server.stop(true);
71
+ }
72
+ }, 15000);
73
+
74
+ test.skipIf(!available)("closing an SDK browser releases complete coverage before the next service capture", async () => {
75
+ // Isolate this global service-capture check from unit tests that deliberately
76
+ // retain failed collectors for the remainder of their harness lifetime.
77
+ const browserModule = new URL("./browser.ts", import.meta.url).href;
78
+ const coverageModule = new URL("./browser-coverage.ts", import.meta.url).href;
79
+ const child = Bun.spawn([process.execPath, "-e", `
80
+ import { openBrowser } from ${JSON.stringify(browserModule)};
81
+ import { configureBrowserCoverage, harvestAllBrowserCoverage } from ${JSON.stringify(coverageModule)};
82
+ configureBrowserCoverage({ serviceForHost: () => 'close-test' });
83
+ const browser = await openBrowser();
84
+ await browser.goto('about:blank');
85
+ await browser.close();
86
+ await harvestAllBrowserCoverage();
87
+ process.exit(0);
88
+ `], { stdout: "pipe", stderr: "pipe" });
89
+ const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
90
+ expect({ code, stderr }).toEqual({ code: 0, stderr: "" });
91
+ }, 15000);
@@ -55,26 +55,40 @@ const TOTALS = new Map<string, FileCounts>();
55
55
 
56
56
  interface ScriptInfo {
57
57
  url: string;
58
+ hash?: string;
59
+ executionContextId?: number;
58
60
  sourceMapURL?: string;
59
61
  source?: string;
60
62
  /** `undefined` = not looked up yet; `null` = none / unusable. */
61
63
  map?: DecodedSourceMap | null;
62
64
  }
63
65
 
66
+ interface Renderer {
67
+ isolateId: string;
68
+ scripts: Map<string, ScriptInfo>;
69
+ }
70
+
71
+ interface DocumentScripts {
72
+ scripts: Map<string, ScriptInfo>;
73
+ renderer?: Renderer;
74
+ }
75
+
64
76
  interface Collector {
65
77
  cdp: CDPSession;
66
- scripts: Map<string, ScriptInfo>;
78
+ document: DocumentScripts;
79
+ renderer?: Renderer;
67
80
  inFlight: Promise<void> | null;
68
81
  incomplete?: Error;
69
82
  abort?: AbortController;
70
83
  }
71
84
 
72
85
  const COLLECTORS = new WeakMap<CDPSession, Collector>();
73
- /** The collectors of every session attached so far, for the harvest at
74
- * capture; a closed session's take fails and is dropped there. */
86
+ /** Collectors awaiting service capture. Explicit close releases a completed
87
+ * collector; unexpected disconnects remain coverage failures. */
75
88
  const LIVE_COLLECTORS = new Set<Collector>();
76
89
 
77
- /** Decoded maps by URL (or content hash for inline `data:` maps). */
90
+ /** Inline maps are immutable and cached by content hash. External maps are
91
+ * cached only on ScriptInfo: a restarted server may reuse their URLs. */
78
92
  const SOURCE_MAPS = new Map<string, DecodedSourceMap | null>();
79
93
 
80
94
  const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
@@ -83,18 +97,26 @@ const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
83
97
  * service opted in. */
84
98
  export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
85
99
  if (!CONFIG || COLLECTORS.has(cdp)) return;
86
- const collector: Collector = { cdp, scripts: new Map(), inFlight: null };
100
+ const collector: Collector = { cdp, document: { scripts: new Map() }, inFlight: null };
87
101
  COLLECTORS.set(cdp, collector);
88
102
  LIVE_COLLECTORS.add(collector);
89
- cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
90
- // IDs belong to a renderer, not the lifetime of this CDP session. A
91
- // cross-site navigation can reuse an old named ID for anonymous code.
92
- // Replace anonymous entries too, invalidating the previous URL/source/map.
93
- collector.scripts.set(ev.scriptId, {
103
+ cdp.on("Runtime.executionContextsCleared", () => {
104
+ // A document change may or may not replace the renderer. Buffer its
105
+ // metadata separately until getIsolateId identifies the new namespace.
106
+ collector.document = { scripts: new Map() };
107
+ });
108
+ cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string; hash?: string; executionContextId?: number }) => {
109
+ const info: ScriptInfo = {
94
110
  url: ev.url,
111
+ hash: ev.hash,
112
+ executionContextId: ev.executionContextId,
95
113
  sourceMapURL: ev.sourceMapURL || undefined,
96
- });
114
+ };
115
+ const document = collector.document;
116
+ setScript(document.scripts, ev.scriptId, info);
117
+ if (document.renderer) setScript(document.renderer.scripts, ev.scriptId, info);
97
118
  });
119
+ await cdp.send("Runtime.enable");
98
120
  await cdp.send("Debugger.enable");
99
121
  await cdp.send("Profiler.enable");
100
122
  await cdp.send("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
@@ -123,9 +145,10 @@ export async function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSign
123
145
  reject(c.incomplete);
124
146
  }, { once: true });
125
147
  });
126
- c.inFlight = Promise.race([harvestInner(c, config, abort.signal), interrupted]).catch(() => {
127
- if (c.incomplete) throw c.incomplete;
128
- // Preserve the existing handling of closed/navigating pages.
148
+ c.inFlight = Promise.race([harvestInner(c, config, abort.signal), interrupted]).catch((cause) => {
149
+ // A failed protocol command can leave reset or unavailable counters.
150
+ // Expected page closure explicitly releases its collector beforehand.
151
+ throw c.incomplete ??= new Error("Browser coverage collection failed; coverage is incomplete", { cause });
129
152
  }).finally(() => {
130
153
  c.inFlight = null;
131
154
  c.abort = undefined;
@@ -142,16 +165,55 @@ export async function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSign
142
165
  finally { signal?.removeEventListener("abort", interrupt); }
143
166
  }
144
167
 
168
+ /** Replayed Debugger events for the same script do not invalidate a source
169
+ * read. A matching URL alone is insufficient: recompilations may reuse it. */
170
+ function setScript(scripts: Map<string, ScriptInfo>, id: string, info: ScriptInfo): void {
171
+ const before = scripts.get(id);
172
+ if (before && info.hash && before.hash === info.hash &&
173
+ before.executionContextId === info.executionContextId &&
174
+ before.url === info.url && before.sourceMapURL === info.sourceMapURL) return;
175
+ scripts.set(id, info);
176
+ }
177
+
178
+ function identityChanged(c: Collector): Error {
179
+ return c.incomplete ??= new Error("Browser script identity changed during coverage capture; coverage is incomplete");
180
+ }
181
+
182
+ /** Resolve the current document's script IDs against V8's renderer identity.
183
+ * Numeric context IDs and script IDs are both reused across renderer swaps. */
184
+ async function currentRenderer(c: Collector, signal: AbortSignal): Promise<Renderer> {
185
+ for (;;) {
186
+ const document = c.document;
187
+ const { id } = await c.cdp.send("Runtime.getIsolateId");
188
+ signal.throwIfAborted();
189
+ // Navigation overlapped this non-destructive query. Retry before taking
190
+ // counters; no coverage has been reset yet.
191
+ if (document !== c.document) continue;
192
+ if (!id) throw identityChanged(c);
193
+ if (c.renderer?.isolateId !== id) c.renderer = { isolateId: id, scripts: new Map() };
194
+ const renderer = c.renderer;
195
+ if (!document.renderer) {
196
+ for (const [scriptId, info] of document.scripts) setScript(renderer.scripts, scriptId, info);
197
+ document.renderer = renderer;
198
+ }
199
+ return renderer;
200
+ }
201
+ }
202
+
145
203
  async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal: AbortSignal): Promise<void> {
146
- const scriptsAtTake = new Map(c.scripts);
204
+ const renderer = await currentRenderer(c, signal);
205
+ const scriptsAtTake = new Map(renderer.scripts);
147
206
  let result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
148
207
  try {
149
208
  ({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as { result: typeof result });
150
209
  signal.throwIfAborted();
151
- } catch (err) {
152
- if (!signal.aborted) LIVE_COLLECTORS.delete(c); // the session is gone
153
- throw err;
210
+ } catch (cause) {
211
+ signal.throwIfAborted();
212
+ throw c.incomplete ??= new Error("Browser coverage take failed; coverage is incomplete", { cause });
154
213
  }
214
+ // A take is destructive. Once sent, crossing a renderer boundary is
215
+ // ambiguous and must remain incomplete rather than retrying the take.
216
+ if (await currentRenderer(c, signal) !== renderer) throw identityChanged(c);
155
217
  for (const script of result) {
156
218
  // The profiler's URL identifies this execution. Never supply a URL from
157
219
  // an earlier renderer to an anonymous result.
@@ -165,16 +227,13 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal:
165
227
  if (url.protocol !== "http:" && url.protocol !== "https:") continue;
166
228
  const service = config.serviceForHost(url.hostname);
167
229
  if (!service) continue;
168
- let info = c.scripts.get(script.scriptId);
230
+ let info = renderer.scripts.get(script.scriptId);
169
231
  const before = scriptsAtTake.get(script.scriptId);
170
- const changed = () => {
171
- c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
172
- return c.incomplete;
173
- };
232
+ const changed = () => identityChanged(c);
174
233
  if ((before && before !== info) || (info && info.url !== script.url)) throw changed();
175
234
  if (!info) {
176
235
  info = { url: script.url };
177
- c.scripts.set(script.scriptId, info);
236
+ renderer.scripts.set(script.scriptId, info);
178
237
  }
179
238
  if (info.source === undefined) {
180
239
  try {
@@ -182,12 +241,14 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal:
182
241
  scriptId: script.scriptId,
183
242
  })) as { scriptSource: string };
184
243
  signal.throwIfAborted();
185
- if (c.scripts.get(script.scriptId) !== info) throw changed();
244
+ if (await currentRenderer(c, signal) !== renderer || renderer.scripts.get(script.scriptId) !== info) throw changed();
186
245
  info.source = r.scriptSource;
187
- } catch {
246
+ } catch (cause) {
188
247
  signal.throwIfAborted();
189
248
  if (c.incomplete) throw c.incomplete;
190
- continue; // script gone with its document
249
+ // An executed covered script whose source was lost cannot yield a
250
+ // trustworthy report. Keep the failure for service capture.
251
+ throw c.incomplete = new Error("Browser script source unavailable; coverage is incomplete", { cause });
191
252
  }
192
253
  }
193
254
  const generated = rangesToLineCounts(info.source, script.functions);
@@ -234,8 +295,6 @@ async function sourceMapFor(info: ScriptInfo, scriptUrl: URL): Promise<DecodedSo
234
295
  } catch {
235
296
  return null;
236
297
  }
237
- const cached = SOURCE_MAPS.get(mapUrl);
238
- if (cached !== undefined) return cached;
239
298
  let map: DecodedSourceMap | null = null;
240
299
  try {
241
300
  const res = await rawFetch(mapUrl, { signal: AbortSignal.timeout(SOURCE_MAP_FETCH_TIMEOUT_MS) });
@@ -243,7 +302,6 @@ async function sourceMapFor(info: ScriptInfo, scriptUrl: URL): Promise<DecodedSo
243
302
  } catch {
244
303
  map = null;
245
304
  }
246
- SOURCE_MAPS.set(mapUrl, map);
247
305
  return map;
248
306
  }
249
307
 
@@ -262,6 +320,13 @@ function parseDataUrl(ref: string): unknown {
262
320
  }
263
321
  }
264
322
 
323
+ /** Called only after harvesting a page that the SDK is about to destroy.
324
+ * Keep failed collectors visible so closing a page cannot erase a gap. */
325
+ export function releaseBrowserCoverage(cdp: CDPSession): void {
326
+ const collector = COLLECTORS.get(cdp);
327
+ if (collector && !collector.incomplete && !collector.inFlight) LIVE_COLLECTORS.delete(collector);
328
+ }
329
+
265
330
  /** Harvest every live session, so a capture sees the counters of code
266
331
  * that ran since the last browser op. */
267
332
  export async function harvestAllBrowserCoverage(signal?: AbortSignal): Promise<void> {
@@ -3,10 +3,12 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- test("SDK refuses a Chromium-only runtime instead of silently falling back", async () => {
7
- const dir = await mkdtemp(join(tmpdir(), "spectest-no-shell-"));
6
+ test("SDK refuses a Shell-only runtime instead of silently changing browser behavior", async () => {
7
+ const dir = await mkdtemp(join(tmpdir(), "spectest-no-chromium-"));
8
8
  try {
9
- await writeFile(join(dir, "chromium"), "#!/bin/sh\necho UNEXPECTED_CHROMIUM_LAUNCH >&2\nexit 1\n", { mode: 0o755 });
9
+ for (const name of ["chromium-headless-shell", "chrome-headless-shell"]) {
10
+ await writeFile(join(dir, name), "#!/bin/sh\necho UNEXPECTED_SHELL_LAUNCH >&2\nexit 1\n", { mode: 0o755 });
11
+ }
10
12
  const browserModule = new URL("./browser.ts", import.meta.url).href;
11
13
  const child = Bun.spawn([process.execPath, "-e", `
12
14
  import { openBrowser } from ${JSON.stringify(browserModule)};
@@ -15,7 +17,7 @@ test("SDK refuses a Chromium-only runtime instead of silently falling back", asy
15
17
  `], { env: { ...process.env, PATH: dir }, stdout: "pipe", stderr: "pipe" });
16
18
  const stderr = await new Response(child.stderr).text();
17
19
  expect(await child.exited).toBe(1);
18
- expect(stderr).toContain("Headless Shell is required");
19
- expect(stderr).not.toContain("UNEXPECTED_CHROMIUM_LAUNCH");
20
+ expect(stderr).toContain("Chromium is required");
21
+ expect(stderr).not.toContain("UNEXPECTED_SHELL_LAUNCH");
20
22
  } finally { await rm(dir, { recursive: true, force: true }); }
21
23
  });
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { acquirePersistentBrowser } from "./browser.js";
3
3
  import { finalizeBrowserRecordings } from "./harness/deadline.js";
4
4
 
5
- const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
6
6
 
7
7
  test.skipIf(!available)("stuck replay drains are bounded warnings and late replies cannot change the recording or clear a later buffer", async () => {
8
8
  const chunks: unknown[] = [];
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { readRaw } from "./inspect.js";
3
3
  import { openBrowser } from "./browser.js";
4
4
 
5
- const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
6
6
  const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64");
7
7
 
8
8
  for (const late of [false, true]) {
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { openBrowser } from "./browser.js";
3
3
  import { startRecording, stopRecording } from "./recorder.js";
4
4
 
5
- const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
6
6
 
7
7
  test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
8
8
  // The document commits immediately but load waits for an image. This