@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.
- package/dist/browser-coverage.d.ts +3 -0
- package/dist/browser-coverage.js +84 -31
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +80 -22
- package/dist/change-rules.d.ts +45 -0
- package/dist/change-rules.js +179 -0
- package/dist/components/k3s-coverage.js +4 -4
- package/dist/components/supabase-coverage.js +12 -0
- package/dist/components/supabase.js +25 -12
- package/dist/coverage-bun.js +2 -1
- package/dist/coverage-next.d.ts +5 -0
- package/dist/coverage-next.js +27 -3
- package/dist/coverage-rust.d.ts +2 -0
- package/dist/coverage-rust.js +20 -0
- package/dist/coverage.d.ts +27 -3
- package/dist/coverage.js +68 -15
- package/dist/daemon.js +13 -10
- package/dist/harness/component-images.d.ts +5 -0
- package/dist/harness/component-images.js +15 -2
- package/dist/harness/coverage-target.d.ts +2 -0
- package/dist/harness/coverage-target.js +3 -2
- package/dist/harness/reach.d.ts +6 -1
- package/dist/harness/reach.js +8 -34
- package/dist/harness/shadow-builder.d.ts +2 -1
- package/dist/harness/shadow-builder.js +6 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
- package/src/browser-clipboard.test.ts +177 -0
- package/src/browser-coverage-deadline.test.ts +2 -0
- package/src/browser-coverage-identity.test.ts +114 -2
- package/src/browser-coverage-navigation.test.ts +1 -2
- package/src/browser-coverage-sdk.test.ts +91 -0
- package/src/browser-coverage.ts +95 -30
- package/src/browser-executable.test.ts +7 -5
- package/src/browser-finalization.test.ts +1 -1
- package/src/browser-image-loading.test.ts +1 -1
- package/src/browser-navigation.test.ts +1 -1
- package/src/browser-permissions.test.ts +143 -0
- package/src/browser-replay-transport.test.ts +60 -0
- package/src/browser-screenshot.test.ts +1 -2
- package/src/browser-timezone.test.ts +1 -1
- package/src/browser.ts +79 -20
- package/src/change-rules.fixtures.json +35 -0
- package/src/change-rules.test.ts +125 -0
- package/src/change-rules.ts +147 -0
- package/src/components/k3s-coverage.test.ts +32 -0
- package/src/components/k3s-coverage.ts +4 -4
- package/src/components/supabase-coverage.ts +12 -0
- package/src/components/supabase-dir.test.ts +26 -0
- package/src/components/supabase-functions-reach.test.ts +41 -1
- package/src/components/supabase-migrations.test.ts +1 -1
- package/src/components/supabase.ts +26 -12
- package/src/coverage-bun.ts +2 -1
- package/src/coverage-next.ts +29 -3
- package/src/coverage-rust.test.ts +34 -0
- package/src/coverage-rust.ts +22 -0
- package/src/coverage.test.ts +2 -1
- package/src/coverage.ts +76 -7
- package/src/daemon.ts +14 -9
- package/src/harness/component-images.test.ts +42 -0
- package/src/harness/component-images.ts +20 -3
- package/src/harness/coverage-target.ts +5 -2
- package/src/harness/reach.test.ts +40 -6
- package/src/harness/reach.ts +13 -3
- package/src/harness/shadow-builder.test.ts +8 -0
- package/src/harness/shadow-builder.ts +7 -2
- package/src/index.ts +11 -0
- package/src/microphone-fixture.test.ts +106 -15
- package/src/playwright-assertions.test.ts +3 -3
- 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,
|
|
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);
|
package/src/browser-coverage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
74
|
-
*
|
|
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
|
-
/**
|
|
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("
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
128
|
-
//
|
|
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
|
|
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 (
|
|
152
|
-
|
|
153
|
-
throw
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|