@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.
- 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/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/microphone-fixture.test.ts +106 -15
- package/src/playwright-assertions.test.ts +3 -3
- package/src/recorder.ts +1 -1
|
@@ -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> {
|
|
@@ -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
|
|
7
|
-
const dir = await mkdtemp(join(tmpdir(), "spectest-no-
|
|
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
|
-
|
|
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("
|
|
19
|
-
expect(stderr).not.toContain("
|
|
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-
|
|
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-
|
|
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-
|
|
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
|