@specific.dev/spectest 0.131.21 → 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.
Files changed (71) hide show
  1. package/dist/browser-coverage.d.ts +3 -0
  2. package/dist/browser-coverage.js +84 -31
  3. package/dist/browser.d.ts +4 -0
  4. package/dist/browser.js +80 -22
  5. package/dist/change-rules.d.ts +45 -0
  6. package/dist/change-rules.js +179 -0
  7. package/dist/components/k3s-coverage.js +4 -4
  8. package/dist/components/supabase-coverage.js +12 -0
  9. package/dist/components/supabase.js +25 -12
  10. package/dist/coverage-bun.js +2 -1
  11. package/dist/coverage-next.d.ts +5 -0
  12. package/dist/coverage-next.js +27 -3
  13. package/dist/coverage-rust.d.ts +2 -0
  14. package/dist/coverage-rust.js +20 -0
  15. package/dist/coverage.d.ts +27 -3
  16. package/dist/coverage.js +68 -15
  17. package/dist/daemon.js +13 -10
  18. package/dist/harness/component-images.d.ts +5 -0
  19. package/dist/harness/component-images.js +15 -2
  20. package/dist/harness/coverage-target.d.ts +2 -0
  21. package/dist/harness/coverage-target.js +3 -2
  22. package/dist/harness/reach.d.ts +6 -1
  23. package/dist/harness/reach.js +8 -34
  24. package/dist/harness/shadow-builder.d.ts +2 -1
  25. package/dist/harness/shadow-builder.js +6 -2
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +5 -0
  28. package/package.json +1 -1
  29. package/src/browser-clipboard.test.ts +177 -0
  30. package/src/browser-coverage-deadline.test.ts +2 -0
  31. package/src/browser-coverage-identity.test.ts +114 -2
  32. package/src/browser-coverage-navigation.test.ts +1 -2
  33. package/src/browser-coverage-sdk.test.ts +91 -0
  34. package/src/browser-coverage.ts +95 -30
  35. package/src/browser-executable.test.ts +7 -5
  36. package/src/browser-finalization.test.ts +1 -1
  37. package/src/browser-image-loading.test.ts +1 -1
  38. package/src/browser-navigation.test.ts +1 -1
  39. package/src/browser-permissions.test.ts +143 -0
  40. package/src/browser-replay-transport.test.ts +60 -0
  41. package/src/browser-screenshot.test.ts +1 -2
  42. package/src/browser-timezone.test.ts +1 -1
  43. package/src/browser.ts +79 -20
  44. package/src/change-rules.fixtures.json +35 -0
  45. package/src/change-rules.test.ts +125 -0
  46. package/src/change-rules.ts +147 -0
  47. package/src/components/k3s-coverage.test.ts +32 -0
  48. package/src/components/k3s-coverage.ts +4 -4
  49. package/src/components/supabase-coverage.ts +12 -0
  50. package/src/components/supabase-dir.test.ts +26 -0
  51. package/src/components/supabase-functions-reach.test.ts +41 -1
  52. package/src/components/supabase-migrations.test.ts +1 -1
  53. package/src/components/supabase.ts +26 -12
  54. package/src/coverage-bun.ts +2 -1
  55. package/src/coverage-next.ts +29 -3
  56. package/src/coverage-rust.test.ts +34 -0
  57. package/src/coverage-rust.ts +22 -0
  58. package/src/coverage.test.ts +2 -1
  59. package/src/coverage.ts +76 -7
  60. package/src/daemon.ts +14 -9
  61. package/src/harness/component-images.test.ts +42 -0
  62. package/src/harness/component-images.ts +20 -3
  63. package/src/harness/coverage-target.ts +5 -2
  64. package/src/harness/reach.test.ts +40 -6
  65. package/src/harness/reach.ts +13 -3
  66. package/src/harness/shadow-builder.test.ts +8 -0
  67. package/src/harness/shadow-builder.ts +7 -2
  68. package/src/index.ts +11 -0
  69. package/src/microphone-fixture.test.ts +106 -15
  70. package/src/playwright-assertions.test.ts +3 -3
  71. package/src/recorder.ts +1 -1
@@ -0,0 +1,177 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
+ import { acquirePersistentBrowser, openBrowser, openMobileBackend, prewarmViewPool, type Browser } from "./browser.js";
3
+ import { expect as assert } from "./index.js";
4
+ import { readRaw } from "./inspect.js";
5
+ import { startRecording, stopRecording } from "./recorder.js";
6
+
7
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
8
+
9
+ describe.skipIf(!available)("native browser clipboard", () => {
10
+ let server: ReturnType<typeof Bun.serve>;
11
+ beforeAll(() => {
12
+ server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(`
13
+ <button id="text">Copy text</button><button id="item">Copy item</button><output></output>
14
+ <script>
15
+ window.initialReadPermission = navigator.permissions.query({name: 'clipboard-read'}).then(p => p.state);
16
+ for (const kind of ['text', 'item']) document.getElementById(kind).onclick = async () => {
17
+ try {
18
+ const text = kind === 'text' ? 'Copied native text' : 'Copied native item';
19
+ if (kind === 'text') await navigator.clipboard.writeText(text);
20
+ else await navigator.clipboard.write([new ClipboardItem({'text/plain': new Blob([text], {type: 'text/plain'})})]);
21
+ document.querySelector('output').textContent = text;
22
+ } catch (e) { document.querySelector('output').textContent = e.message; }
23
+ };
24
+ </script>`, { headers: { "content-type": "text/html" } }) });
25
+ });
26
+ afterAll(() => server.stop(true));
27
+ afterEach(() => stopRecording());
28
+ const state = async (browser: Browser) => readRaw(await browser.evaluate("read clipboard permission", "navigator.permissions.query({name: 'clipboard-read'}).then(p => p.state)"));
29
+
30
+ test.each(["browser", "mobile"] as const)("%s: default real clicks copy with both native APIs and reads record assertion provenance", async frame => {
31
+ const browser = await openBrowser({ frame, url: server.url.href, recorder: { sessionId: "clipboard", recordStep() {} } });
32
+ try {
33
+ // Loopback is a secure context, so this exercises native web clipboard APIs.
34
+ expect(readRaw(await browser.evaluate("secure context", "isSecureContext"))).toBe(true);
35
+ expect(readRaw(await browser.evaluate("permission at first document", "window.initialReadPermission"))).toBe("prompt");
36
+ for (const [button, text] of [["Copy text", "Copied native text"], ["Copy item", "Copied native item"]]) {
37
+ await browser.getByRole("button", { name: button, exact: true }).click();
38
+ await assert(browser.locator("output")).toHaveText(text);
39
+ startRecording();
40
+ assert(await browser.readClipboard()).toBe(text);
41
+ const events = stopRecording();
42
+ expect(events).toHaveLength(2);
43
+ expect(events[0]).toMatchObject({ kind: "browser", action: "readClipboard", sessionId: "clipboard" });
44
+ expect(events[1]).toMatchObject({ kind: "assertion", passed: true, sourceSeq: events[0]!.seq });
45
+ expect((await browser.readClipboard()).unwrap()).toBe(text); // same shape without recording
46
+ }
47
+ } finally { await browser.close(); }
48
+ }, 20000);
49
+
50
+ test("reads restore native permission defaults and leave other origins unchanged", async () => {
51
+ const browser = await openBrowser({ url: server.url.href });
52
+ try {
53
+ const readStates = () => browser.evaluate("read permission states", async () => {
54
+ const names = ["clipboard-read", "clipboard-write", "camera", "microphone", "geolocation", "notifications", "background-sync"];
55
+ return Object.fromEntries(await Promise.all(names.map(async name => [name,
56
+ (await navigator.permissions.query({ name: name as PermissionName })).state])));
57
+ });
58
+ const before = readRaw(await readStates());
59
+ expect(before).toMatchObject({ "clipboard-read": "prompt", "clipboard-write": "granted" });
60
+ await browser.readClipboard();
61
+ expect(readRaw(await readStates())).toEqual(before);
62
+ await browser.goto(server.url.href.replace("127.0.0.1", "localhost"));
63
+ expect(readRaw(await readStates())).toEqual(before);
64
+ await browser.readClipboard();
65
+ expect(readRaw(await readStates())).toEqual(before);
66
+ await browser.goto(server.url.href);
67
+ expect(readRaw(await readStates())).toEqual(before);
68
+ } finally { await browser.close(); }
69
+ }, 20000);
70
+
71
+ test("native policy failures are recorded without changing permission defaults", async () => {
72
+ const blocked = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<p>Clipboard blocked</p>", {
73
+ headers: { "content-type": "text/html", "permissions-policy": "clipboard-read=()" },
74
+ }) });
75
+ const browser = await openBrowser({ url: blocked.url.href });
76
+ try {
77
+ startRecording();
78
+ await expect(browser.readClipboard()).rejects.toThrow(/permission|policy|denied/i);
79
+ expect(stopRecording()).toMatchObject([{ kind: "browser", action: "readClipboard", error: expect.any(String) }]);
80
+ blocked.reload({ fetch: () => new Response("<p>Clipboard allowed</p>", { headers: { "content-type": "text/html" } }) });
81
+ await browser.reload();
82
+ expect(await state(browser)).toBe("prompt");
83
+ await browser.goto("about:blank");
84
+ await expect(browser.readClipboard()).rejects.toThrow("secure HTTP(S) page");
85
+ } finally { await browser.close(); blocked.stop(true); }
86
+ }, 20000);
87
+
88
+ test("reads leave pooled, named and inherited sessions at their defaults", async () => {
89
+ await prewarmViewPool();
90
+ const a = await acquirePersistentBrowser("clipboard-reader", { url: server.url.href });
91
+ const b = await acquirePersistentBrowser("clipboard-other", { url: server.url.href });
92
+ const pooled = await openBrowser({ url: server.url.href });
93
+ try {
94
+ await a.browser.getByRole("button", { name: "Copy text", exact: true }).click();
95
+ await assert(a.browser.locator("output")).toHaveText("Copied native text");
96
+ expect((await a.browser.readClipboard()).unwrap()).toBe("Copied native text");
97
+ expect(await state(a.browser)).toBe("prompt");
98
+ expect(await state(b.browser)).toBe("prompt");
99
+ expect(await state(pooled)).toBe("prompt");
100
+ await a.detach();
101
+ const attached = await acquirePersistentBrowser("clipboard-reader");
102
+ expect(attached.attached).toBe(true);
103
+ await attached.browser.reload();
104
+ expect(await state(attached.browser)).toBe("prompt");
105
+ await attached.detach();
106
+ } finally { await a.browser.close(); await b.browser.close(); await pooled.close(); }
107
+ }, 20000);
108
+
109
+ test("observation leaves native prompt decisions, page focus and context lifetime unchanged", async () => {
110
+ const browser = await openMobileBackend({ url: server.url.href });
111
+ const page = await browser.silentRead(async page => page);
112
+ const owner = page.context().browser()!;
113
+ try {
114
+ await browser.getByRole("button", { name: "Copy text", exact: true }).click();
115
+ await assert(browser.locator("output")).toHaveText("Copied native text");
116
+ const contexts = owner.contexts().length;
117
+ const focused = await page.evaluate(() => document.hasFocus());
118
+ expect(await state(browser)).toBe("prompt");
119
+ expect((await browser.readClipboard()).unwrap()).toBe("Copied native text");
120
+ expect(owner.contexts().length).toBe(contexts);
121
+ expect(await page.evaluate(() => document.hasFocus())).toBe(focused);
122
+ expect(await state(browser)).toBe("prompt");
123
+ // A persistent CDP 'prompt' override looks correct immediately after a
124
+ // helper read but prevents this normal prompt -> denied transition.
125
+ await expect(page.evaluate(() => navigator.clipboard.readText())).rejects.toThrow(/denied/i);
126
+ expect(await state(browser)).toBe("denied");
127
+ expect((await browser.readClipboard()).unwrap()).toBe("Copied native text");
128
+ expect(await state(browser)).toBe("denied");
129
+ expect(owner.contexts().length).toBe(contexts);
130
+ } finally { await browser.close(); }
131
+ }, 20000);
132
+
133
+ test("observation preserves denied overrides owned by another DevTools session", async () => {
134
+ const browser = await openMobileBackend({ url: server.url.href });
135
+ const page = await browser.silentRead(async page => page);
136
+ const session = await page.context().browser()!.newBrowserCDPSession();
137
+ const target = await page.context().newCDPSession(page);
138
+ try {
139
+ await browser.getByRole("button", { name: "Copy item", exact: true }).click();
140
+ await assert(browser.locator("output")).toHaveText("Copied native item");
141
+ const { targetInfo } = await target.send("Target.getTargetInfo");
142
+ await session.send("Browser.setPermission", {
143
+ permission: { name: "clipboard-read", allowWithoutSanitization: true },
144
+ origin: server.url.origin, browserContextId: targetInfo.browserContextId, setting: "denied",
145
+ });
146
+ expect(await state(browser)).toBe("denied");
147
+ expect((await browser.readClipboard()).unwrap()).toBe("Copied native item");
148
+ expect(await state(browser)).toBe("denied");
149
+ } finally { await target.detach(); await session.detach(); await browser.close(); }
150
+ }, 20000);
151
+
152
+ test("navigation during observation rejects and closes the observer without changing app permissions", async () => {
153
+ const browser = await openMobileBackend({ url: server.url.href });
154
+ const page = await browser.silentRead(async page => page);
155
+ const owner = page.context().browser()!;
156
+ const createContext = owner.newContext.bind(owner);
157
+ const count = owner.contexts().length;
158
+ // Place a native navigation precisely after observer permission setup,
159
+ // without timing assumptions or replacing any Clipboard API.
160
+ owner.newContext = async options => {
161
+ const observer = await createContext(options);
162
+ const grant = observer.grantPermissions.bind(observer);
163
+ observer.grantPermissions = async (...args) => {
164
+ await grant(...args);
165
+ await page.goto(server.url.href.replace("127.0.0.1", "localhost"));
166
+ };
167
+ return observer;
168
+ };
169
+ try {
170
+ await expect(browser.readClipboard()).rejects.toThrow("Page navigated during clipboard read");
171
+ expect(owner.contexts().length).toBe(count);
172
+ expect(await state(browser)).toBe("prompt");
173
+ await page.goto(server.url.href);
174
+ expect(await state(browser)).toBe("prompt");
175
+ } finally { owner.newContext = createContext; await browser.close(); }
176
+ }, 20000);
177
+ });
@@ -11,6 +11,7 @@ test("an ambiguous coverage timeout is retained and a late CDP result cannot pub
11
11
  const cdp = {
12
12
  on() {},
13
13
  async send(method: string) {
14
+ if (method === "Runtime.getIsolateId") return { id: "renderer-1" };
14
15
  if (method === "Profiler.takePreciseCoverage") { takes++; return take; }
15
16
  if (method === "Debugger.getScriptSource") { sourceReads++; return {scriptSource:"example();"}; }
16
17
  return {};
@@ -44,6 +45,7 @@ test("cancelling during script retrieval discards late source and coverage total
44
45
  const cdp = {
45
46
  on() {},
46
47
  async send(method: string) {
48
+ if (method === "Runtime.getIsolateId") return { id: "renderer-1" };
47
49
  if (method === "Profiler.takePreciseCoverage") return {result:[{scriptId:"1",url:"http://app.test/late.js",functions:[{ranges:[{startOffset:0,endOffset:10,count:1}]}]}]};
48
50
  if (method === "Debugger.getScriptSource") { sourceStarted(); return source; }
49
51
  return {};
@@ -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> {