@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
|
@@ -19,6 +19,9 @@ export declare function attachBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
|
19
19
|
export declare function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSignal): Promise<void>;
|
|
20
20
|
/** Fold one script's per-file counts into a service's totals. */
|
|
21
21
|
export declare function foldBrowserCoverage(service: string, files: FileCounts): void;
|
|
22
|
+
/** Called only after harvesting a page that the SDK is about to destroy.
|
|
23
|
+
* Keep failed collectors visible so closing a page cannot erase a gap. */
|
|
24
|
+
export declare function releaseBrowserCoverage(cdp: CDPSession): void;
|
|
22
25
|
/** Harvest every live session, so a capture sees the counters of code
|
|
23
26
|
* that ran since the last browser op. */
|
|
24
27
|
export declare function harvestAllBrowserCoverage(signal?: AbortSignal): Promise<void>;
|
package/dist/browser-coverage.js
CHANGED
|
@@ -32,10 +32,11 @@ export function browserCoverageActive() {
|
|
|
32
32
|
/** service → file → line → count, summed over every harvest. */
|
|
33
33
|
const TOTALS = new Map();
|
|
34
34
|
const COLLECTORS = new WeakMap();
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
35
|
+
/** Collectors awaiting service capture. Explicit close releases a completed
|
|
36
|
+
* collector; unexpected disconnects remain coverage failures. */
|
|
37
37
|
const LIVE_COLLECTORS = new Set();
|
|
38
|
-
/**
|
|
38
|
+
/** Inline maps are immutable and cached by content hash. External maps are
|
|
39
|
+
* cached only on ScriptInfo: a restarted server may reuse their URLs. */
|
|
39
40
|
const SOURCE_MAPS = new Map();
|
|
40
41
|
const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
|
|
41
42
|
/** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
|
|
@@ -43,18 +44,27 @@ const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
|
|
|
43
44
|
export async function attachBrowserCoverage(cdp) {
|
|
44
45
|
if (!CONFIG || COLLECTORS.has(cdp))
|
|
45
46
|
return;
|
|
46
|
-
const collector = { cdp, scripts: new Map(), inFlight: null };
|
|
47
|
+
const collector = { cdp, document: { scripts: new Map() }, inFlight: null };
|
|
47
48
|
COLLECTORS.set(cdp, collector);
|
|
48
49
|
LIVE_COLLECTORS.add(collector);
|
|
50
|
+
cdp.on("Runtime.executionContextsCleared", () => {
|
|
51
|
+
// A document change may or may not replace the renderer. Buffer its
|
|
52
|
+
// metadata separately until getIsolateId identifies the new namespace.
|
|
53
|
+
collector.document = { scripts: new Map() };
|
|
54
|
+
});
|
|
49
55
|
cdp.on("Debugger.scriptParsed", (ev) => {
|
|
50
|
-
|
|
51
|
-
// cross-site navigation can reuse an old named ID for anonymous code.
|
|
52
|
-
// Replace anonymous entries too, invalidating the previous URL/source/map.
|
|
53
|
-
collector.scripts.set(ev.scriptId, {
|
|
56
|
+
const info = {
|
|
54
57
|
url: ev.url,
|
|
58
|
+
hash: ev.hash,
|
|
59
|
+
executionContextId: ev.executionContextId,
|
|
55
60
|
sourceMapURL: ev.sourceMapURL || undefined,
|
|
56
|
-
}
|
|
61
|
+
};
|
|
62
|
+
const document = collector.document;
|
|
63
|
+
setScript(document.scripts, ev.scriptId, info);
|
|
64
|
+
if (document.renderer)
|
|
65
|
+
setScript(document.renderer.scripts, ev.scriptId, info);
|
|
57
66
|
});
|
|
67
|
+
await cdp.send("Runtime.enable");
|
|
58
68
|
await cdp.send("Debugger.enable");
|
|
59
69
|
await cdp.send("Profiler.enable");
|
|
60
70
|
await cdp.send("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
@@ -84,10 +94,10 @@ export async function harvestBrowserCoverage(cdp, signal) {
|
|
|
84
94
|
reject(c.incomplete);
|
|
85
95
|
}, { once: true });
|
|
86
96
|
});
|
|
87
|
-
c.inFlight = Promise.race([harvestInner(c, config, abort.signal), interrupted]).catch(() => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
c.inFlight = Promise.race([harvestInner(c, config, abort.signal), interrupted]).catch((cause) => {
|
|
98
|
+
// A failed protocol command can leave reset or unavailable counters.
|
|
99
|
+
// Expected page closure explicitly releases its collector beforehand.
|
|
100
|
+
throw c.incomplete ??= new Error("Browser coverage collection failed; coverage is incomplete", { cause });
|
|
91
101
|
}).finally(() => {
|
|
92
102
|
c.inFlight = null;
|
|
93
103
|
c.abort = undefined;
|
|
@@ -108,18 +118,59 @@ export async function harvestBrowserCoverage(cdp, signal) {
|
|
|
108
118
|
signal?.removeEventListener("abort", interrupt);
|
|
109
119
|
}
|
|
110
120
|
}
|
|
121
|
+
/** Replayed Debugger events for the same script do not invalidate a source
|
|
122
|
+
* read. A matching URL alone is insufficient: recompilations may reuse it. */
|
|
123
|
+
function setScript(scripts, id, info) {
|
|
124
|
+
const before = scripts.get(id);
|
|
125
|
+
if (before && info.hash && before.hash === info.hash &&
|
|
126
|
+
before.executionContextId === info.executionContextId &&
|
|
127
|
+
before.url === info.url && before.sourceMapURL === info.sourceMapURL)
|
|
128
|
+
return;
|
|
129
|
+
scripts.set(id, info);
|
|
130
|
+
}
|
|
131
|
+
function identityChanged(c) {
|
|
132
|
+
return c.incomplete ??= new Error("Browser script identity changed during coverage capture; coverage is incomplete");
|
|
133
|
+
}
|
|
134
|
+
/** Resolve the current document's script IDs against V8's renderer identity.
|
|
135
|
+
* Numeric context IDs and script IDs are both reused across renderer swaps. */
|
|
136
|
+
async function currentRenderer(c, signal) {
|
|
137
|
+
for (;;) {
|
|
138
|
+
const document = c.document;
|
|
139
|
+
const { id } = await c.cdp.send("Runtime.getIsolateId");
|
|
140
|
+
signal.throwIfAborted();
|
|
141
|
+
// Navigation overlapped this non-destructive query. Retry before taking
|
|
142
|
+
// counters; no coverage has been reset yet.
|
|
143
|
+
if (document !== c.document)
|
|
144
|
+
continue;
|
|
145
|
+
if (!id)
|
|
146
|
+
throw identityChanged(c);
|
|
147
|
+
if (c.renderer?.isolateId !== id)
|
|
148
|
+
c.renderer = { isolateId: id, scripts: new Map() };
|
|
149
|
+
const renderer = c.renderer;
|
|
150
|
+
if (!document.renderer) {
|
|
151
|
+
for (const [scriptId, info] of document.scripts)
|
|
152
|
+
setScript(renderer.scripts, scriptId, info);
|
|
153
|
+
document.renderer = renderer;
|
|
154
|
+
}
|
|
155
|
+
return renderer;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
111
158
|
async function harvestInner(c, config, signal) {
|
|
112
|
-
const
|
|
159
|
+
const renderer = await currentRenderer(c, signal);
|
|
160
|
+
const scriptsAtTake = new Map(renderer.scripts);
|
|
113
161
|
let result;
|
|
114
162
|
try {
|
|
115
163
|
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")));
|
|
116
164
|
signal.throwIfAborted();
|
|
117
165
|
}
|
|
118
|
-
catch (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
throw err;
|
|
166
|
+
catch (cause) {
|
|
167
|
+
signal.throwIfAborted();
|
|
168
|
+
throw c.incomplete ??= new Error("Browser coverage take failed; coverage is incomplete", { cause });
|
|
122
169
|
}
|
|
170
|
+
// A take is destructive. Once sent, crossing a renderer boundary is
|
|
171
|
+
// ambiguous and must remain incomplete rather than retrying the take.
|
|
172
|
+
if (await currentRenderer(c, signal) !== renderer)
|
|
173
|
+
throw identityChanged(c);
|
|
123
174
|
for (const script of result) {
|
|
124
175
|
// The profiler's URL identifies this execution. Never supply a URL from
|
|
125
176
|
// an earlier renderer to an anonymous result.
|
|
@@ -137,17 +188,14 @@ async function harvestInner(c, config, signal) {
|
|
|
137
188
|
const service = config.serviceForHost(url.hostname);
|
|
138
189
|
if (!service)
|
|
139
190
|
continue;
|
|
140
|
-
let info =
|
|
191
|
+
let info = renderer.scripts.get(script.scriptId);
|
|
141
192
|
const before = scriptsAtTake.get(script.scriptId);
|
|
142
|
-
const changed = () =>
|
|
143
|
-
c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
|
|
144
|
-
return c.incomplete;
|
|
145
|
-
};
|
|
193
|
+
const changed = () => identityChanged(c);
|
|
146
194
|
if ((before && before !== info) || (info && info.url !== script.url))
|
|
147
195
|
throw changed();
|
|
148
196
|
if (!info) {
|
|
149
197
|
info = { url: script.url };
|
|
150
|
-
|
|
198
|
+
renderer.scripts.set(script.scriptId, info);
|
|
151
199
|
}
|
|
152
200
|
if (info.source === undefined) {
|
|
153
201
|
try {
|
|
@@ -155,15 +203,17 @@ async function harvestInner(c, config, signal) {
|
|
|
155
203
|
scriptId: script.scriptId,
|
|
156
204
|
}));
|
|
157
205
|
signal.throwIfAborted();
|
|
158
|
-
if (c.scripts.get(script.scriptId) !== info)
|
|
206
|
+
if (await currentRenderer(c, signal) !== renderer || renderer.scripts.get(script.scriptId) !== info)
|
|
159
207
|
throw changed();
|
|
160
208
|
info.source = r.scriptSource;
|
|
161
209
|
}
|
|
162
|
-
catch {
|
|
210
|
+
catch (cause) {
|
|
163
211
|
signal.throwIfAborted();
|
|
164
212
|
if (c.incomplete)
|
|
165
213
|
throw c.incomplete;
|
|
166
|
-
|
|
214
|
+
// An executed covered script whose source was lost cannot yield a
|
|
215
|
+
// trustworthy report. Keep the failure for service capture.
|
|
216
|
+
throw c.incomplete = new Error("Browser script source unavailable; coverage is incomplete", { cause });
|
|
167
217
|
}
|
|
168
218
|
}
|
|
169
219
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
@@ -215,9 +265,6 @@ async function sourceMapFor(info, scriptUrl) {
|
|
|
215
265
|
catch {
|
|
216
266
|
return null;
|
|
217
267
|
}
|
|
218
|
-
const cached = SOURCE_MAPS.get(mapUrl);
|
|
219
|
-
if (cached !== undefined)
|
|
220
|
-
return cached;
|
|
221
268
|
let map = null;
|
|
222
269
|
try {
|
|
223
270
|
const res = await rawFetch(mapUrl, { signal: AbortSignal.timeout(SOURCE_MAP_FETCH_TIMEOUT_MS) });
|
|
@@ -227,7 +274,6 @@ async function sourceMapFor(info, scriptUrl) {
|
|
|
227
274
|
catch {
|
|
228
275
|
map = null;
|
|
229
276
|
}
|
|
230
|
-
SOURCE_MAPS.set(mapUrl, map);
|
|
231
277
|
return map;
|
|
232
278
|
}
|
|
233
279
|
function parseDataUrl(ref) {
|
|
@@ -246,6 +292,13 @@ function parseDataUrl(ref) {
|
|
|
246
292
|
return null;
|
|
247
293
|
}
|
|
248
294
|
}
|
|
295
|
+
/** Called only after harvesting a page that the SDK is about to destroy.
|
|
296
|
+
* Keep failed collectors visible so closing a page cannot erase a gap. */
|
|
297
|
+
export function releaseBrowserCoverage(cdp) {
|
|
298
|
+
const collector = COLLECTORS.get(cdp);
|
|
299
|
+
if (collector && !collector.incomplete && !collector.inFlight)
|
|
300
|
+
LIVE_COLLECTORS.delete(collector);
|
|
301
|
+
}
|
|
249
302
|
/** Harvest every live session, so a capture sees the counters of code
|
|
250
303
|
* that ran since the last browser op. */
|
|
251
304
|
export async function harvestAllBrowserCoverage(signal) {
|
package/dist/browser.d.ts
CHANGED
|
@@ -149,6 +149,10 @@ export interface Browser {
|
|
|
149
149
|
goForward(options?: NavigationOptions): Promise<void>;
|
|
150
150
|
/** Reload the current page. */
|
|
151
151
|
reload(options?: NavigationOptions): Promise<void>;
|
|
152
|
+
/** Read native clipboard text from an isolated observer in the same browser.
|
|
153
|
+
* Requires a secure page and honors its clipboard Permissions-Policy.
|
|
154
|
+
* Leaves application permissions unchanged and links assertions to the read. */
|
|
155
|
+
readClipboard(): Promise<Wrapped<string>>;
|
|
152
156
|
readonly keyboard: Keyboard;
|
|
153
157
|
readonly mouse: Mouse;
|
|
154
158
|
/** Root CSS query. */
|
package/dist/browser.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Headless browser handle for tests. Thin wrapper around playwright-core
|
|
2
|
-
// driving the guest's
|
|
2
|
+
// driving the guest's full Chromium (pipe transport — `chromium.launch`
|
|
3
3
|
// with an explicit `executablePath`). Electron attaches to the app-owned
|
|
4
4
|
// Chromium over CDP instead; its client also lives inside the VM. The wrapper
|
|
5
5
|
// keeps the public surface stable (we swapped from Bun.WebView to Playwright without
|
|
@@ -40,7 +40,7 @@ import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./r
|
|
|
40
40
|
import { wrap } from "./inspect.js";
|
|
41
41
|
import { capturePageStructure } from "./page-snapshot.js";
|
|
42
42
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
43
|
-
import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
|
|
43
|
+
import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, releaseBrowserCoverage, } from "./browser-coverage.js";
|
|
44
44
|
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
45
45
|
import { chromium } from "playwright-core";
|
|
46
46
|
import { reduceBrowserSurfaces, awaitBrowserSurface, captureBrowserScreenshot } from "./browser-surface.js";
|
|
@@ -109,7 +109,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
109
109
|
// ────────────────────────────────────────────────────────────────────────
|
|
110
110
|
// Shared Chromium (playwright-core)
|
|
111
111
|
// ────────────────────────────────────────────────────────────────────────
|
|
112
|
-
// Browser and mobile sessions share one
|
|
112
|
+
// Browser and mobile sessions share one full Chromium per daemon, launched lazily and kept
|
|
113
113
|
// for the daemon's lifetime. Fixture sessions own a process because the audio
|
|
114
114
|
// source is a process-wide flag. Both ride snapshots: the process, playwright's
|
|
115
115
|
// pipe connection to it, and every open page freeze into the VM snapshot
|
|
@@ -127,9 +127,10 @@ const VIEW_CLEANUPS = new WeakMap();
|
|
|
127
127
|
// the page only — cookies survived and the next session was still
|
|
128
128
|
// authenticated.
|
|
129
129
|
function browserExecutable() {
|
|
130
|
-
const executable = Bun.which("chromium
|
|
130
|
+
const executable = Bun.which("chromium") ?? Bun.which("chromium-browser") ??
|
|
131
|
+
Bun.which("google-chrome") ?? Bun.which("chrome");
|
|
131
132
|
if (!executable) {
|
|
132
|
-
throw new Error("Chromium
|
|
133
|
+
throw new Error("Full Chromium is required by this SDK but is missing from the runtime image. Install chromium on PATH; Chromium Headless Shell does not provide compatible browser permissions and background services.");
|
|
133
134
|
}
|
|
134
135
|
return executable;
|
|
135
136
|
}
|
|
@@ -166,11 +167,12 @@ function deviceContextOptions(d) {
|
|
|
166
167
|
async function newViewContext(width, height, device, opts = {}) {
|
|
167
168
|
const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
|
|
168
169
|
let browser;
|
|
170
|
+
let context;
|
|
169
171
|
try {
|
|
170
172
|
browser = fixture ? await chromium.launch({
|
|
171
173
|
executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
|
|
172
174
|
}) : await ensurePlaywrightBrowser();
|
|
173
|
-
|
|
175
|
+
context = await browser.newContext({
|
|
174
176
|
...(device
|
|
175
177
|
? deviceContextOptions(device)
|
|
176
178
|
: {
|
|
@@ -198,6 +200,7 @@ async function newViewContext(width, height, device, opts = {}) {
|
|
|
198
200
|
return context;
|
|
199
201
|
}
|
|
200
202
|
catch (err) {
|
|
203
|
+
await context?.close().catch(() => { });
|
|
201
204
|
if (fixture) {
|
|
202
205
|
try {
|
|
203
206
|
await browser?.close();
|
|
@@ -788,7 +791,7 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
|
788
791
|
? `if (window === window.top) {\n${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}\n}`
|
|
789
792
|
: "";
|
|
790
793
|
// Page-side expression that atomically swaps in a fresh buffer and
|
|
791
|
-
// returns the old
|
|
794
|
+
// returns the old batch as JSON. Wrapped as a single expression so view.evaluate's
|
|
792
795
|
// `await (...)` wrapper accepts it.
|
|
793
796
|
//
|
|
794
797
|
// `forceFullIfMissing` (inlined by `drainExpr`) guards a recovery path
|
|
@@ -832,9 +835,11 @@ function drainExpr(forceFullIfMissing, timestamp = Date.now()) {
|
|
|
832
835
|
window.__spectestSyncInputs(${timestamp});
|
|
833
836
|
}
|
|
834
837
|
var buf = window.__spectestRrwebEvents;
|
|
835
|
-
if (!buf || !buf.length) return [];
|
|
838
|
+
if (!buf || !buf.length) return "[]";
|
|
839
|
+
// Serialize before clearing: an encoding failure must leave the batch intact.
|
|
840
|
+
var json = JSON.stringify(buf);
|
|
836
841
|
window.__spectestRrwebEvents = [];
|
|
837
|
-
return
|
|
842
|
+
return json;
|
|
838
843
|
})()`;
|
|
839
844
|
}
|
|
840
845
|
// Pre-opened view pool. Renderer spawn is the expensive part of
|
|
@@ -1259,12 +1264,6 @@ async function daemonResolves(url) {
|
|
|
1259
1264
|
* retry once.
|
|
1260
1265
|
*/
|
|
1261
1266
|
async function rebuildView(holder) {
|
|
1262
|
-
try {
|
|
1263
|
-
await holder.page.close();
|
|
1264
|
-
}
|
|
1265
|
-
catch {
|
|
1266
|
-
/* page may already be gone */
|
|
1267
|
-
}
|
|
1268
1267
|
// The old renderer's coverage counters die with its page; harvest what
|
|
1269
1268
|
// it had before the swap (rebuild is triggered on a fresh navigation).
|
|
1270
1269
|
try {
|
|
@@ -1273,6 +1272,13 @@ async function rebuildView(holder) {
|
|
|
1273
1272
|
catch {
|
|
1274
1273
|
/* best-effort */
|
|
1275
1274
|
}
|
|
1275
|
+
releaseBrowserCoverage(holder.cdp);
|
|
1276
|
+
try {
|
|
1277
|
+
await holder.page.close();
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
/* page may already be gone */
|
|
1281
|
+
}
|
|
1276
1282
|
const fresh = await spawnPage(holder.context);
|
|
1277
1283
|
holder.page = fresh.page;
|
|
1278
1284
|
holder.cdp = fresh.cdp;
|
|
@@ -1351,12 +1357,16 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1351
1357
|
// Finalization only reads the buffer. A timed-out evaluation may run
|
|
1352
1358
|
// after a child/eval attaches; it must not clear that session's events.
|
|
1353
1359
|
const expression = action === "close"
|
|
1354
|
-
? "window.__spectestRrwebEvents || []"
|
|
1360
|
+
? "JSON.stringify(window.__spectestRrwebEvents || [])"
|
|
1355
1361
|
: drainExpr(urlChanged, timestamp);
|
|
1356
|
-
|
|
1362
|
+
// Replay batches are JSON data. Returning one string avoids Playwright's
|
|
1363
|
+
// recursive SerializedValue validation/copying for every event field.
|
|
1364
|
+
// Keep general user evaluations on Playwright's normal value transport.
|
|
1365
|
+
const encoded = await holder.page.evaluate(expression);
|
|
1357
1366
|
signal?.throwIfAborted();
|
|
1358
1367
|
if (recordingEnded)
|
|
1359
1368
|
return;
|
|
1369
|
+
const events = JSON.parse(encoded);
|
|
1360
1370
|
lastDrainUrl = holder.page.url();
|
|
1361
1371
|
if (Array.isArray(events) && events.length > 0) {
|
|
1362
1372
|
recorder.recordStep({
|
|
@@ -1378,6 +1388,15 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1378
1388
|
const t = Date.now();
|
|
1379
1389
|
const resv = reserveEvent();
|
|
1380
1390
|
try {
|
|
1391
|
+
// Any action (including a click or evaluation) can navigate and replace
|
|
1392
|
+
// the renderer. Drain its counters while it is still alive. Collection
|
|
1393
|
+
// failures remain on the collector and fail coverage capture later.
|
|
1394
|
+
if (browserCoverageActive()) {
|
|
1395
|
+
try {
|
|
1396
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1397
|
+
}
|
|
1398
|
+
catch { /* retained by collector */ }
|
|
1399
|
+
}
|
|
1381
1400
|
const result = await fn();
|
|
1382
1401
|
// Refresh the sync title cache (playwright's title() is async).
|
|
1383
1402
|
try {
|
|
@@ -1387,7 +1406,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1387
1406
|
/* page mid-navigation or closed — keep the stale cache */
|
|
1388
1407
|
}
|
|
1389
1408
|
if (recordingEnded)
|
|
1390
|
-
return result;
|
|
1409
|
+
return opts?.wrap ? wrap(result, undefined) : result;
|
|
1391
1410
|
// `sessionTimestamp` is the post-op wall clock — that's where we
|
|
1392
1411
|
// want the dashboard's seek to land, so clicking "type 'foo'"
|
|
1393
1412
|
// shows the input *with* the text, not the empty field just
|
|
@@ -1401,9 +1420,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1401
1420
|
durationMs: endT - t,
|
|
1402
1421
|
}, resv);
|
|
1403
1422
|
await drain(action, endT);
|
|
1404
|
-
//
|
|
1405
|
-
// navigation to another origin replaces the renderer and its
|
|
1406
|
-
// counters, so harvesting per op is what keeps them.
|
|
1423
|
+
// Collect the action's effects too, including scripts in a new renderer.
|
|
1407
1424
|
if (browserCoverageActive()) {
|
|
1408
1425
|
try {
|
|
1409
1426
|
await harvestBrowserCoverage(holder.cdp);
|
|
@@ -1415,7 +1432,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1415
1432
|
// Reads (`opts.wrap`) return user-visible JS values someone is likely to
|
|
1416
1433
|
// assert on — provenance-wrap so `expect(...)` nests under this step.
|
|
1417
1434
|
// Actions return void/internals; leave them raw to avoid Proxy surprises.
|
|
1418
|
-
if (
|
|
1435
|
+
if (opts?.wrap) {
|
|
1419
1436
|
return wrap(result, seq);
|
|
1420
1437
|
}
|
|
1421
1438
|
return result;
|
|
@@ -1551,6 +1568,46 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1551
1568
|
await holder.page.reload(options);
|
|
1552
1569
|
});
|
|
1553
1570
|
},
|
|
1571
|
+
readClipboard() {
|
|
1572
|
+
return instrumented("readClipboard", {}, async () => {
|
|
1573
|
+
const page = holder.page;
|
|
1574
|
+
const { origin, policyAllowsRead } = await page.evaluate(() => {
|
|
1575
|
+
if (!isSecureContext || !/^https?:$/.test(location.protocol)) {
|
|
1576
|
+
throw new Error("Clipboard reads require a secure HTTP(S) page");
|
|
1577
|
+
}
|
|
1578
|
+
const policy = document.featurePolicy;
|
|
1579
|
+
return { origin: location.origin, policyAllowsRead: policy?.allowsFeature("clipboard-read") ?? true };
|
|
1580
|
+
});
|
|
1581
|
+
if (!policyAllowsRead)
|
|
1582
|
+
return page.evaluate(() => navigator.clipboard.readText());
|
|
1583
|
+
const browser = holder.context.browser();
|
|
1584
|
+
if (!browser)
|
|
1585
|
+
throw new Error("Clipboard reads require an attached Chromium browser");
|
|
1586
|
+
// Native clipboard contents belong to the browser, not the context.
|
|
1587
|
+
// Observe from a disposable context: overriding an app permission and
|
|
1588
|
+
// restoring its queried state cannot restore native prompt behavior
|
|
1589
|
+
// or preserve overrides owned by another DevTools session.
|
|
1590
|
+
const observer = await browser.newContext();
|
|
1591
|
+
try {
|
|
1592
|
+
const observerOrigin = "https://spectest-clipboard.invalid";
|
|
1593
|
+
await observer.route("**/*", route => route.fulfill({
|
|
1594
|
+
status: 200, contentType: "text/html", body: "<!doctype html><title>Clipboard observation</title>",
|
|
1595
|
+
}));
|
|
1596
|
+
await observer.grantPermissions(["clipboard-read"], { origin: observerOrigin });
|
|
1597
|
+
const observerPage = await observer.newPage();
|
|
1598
|
+
await observerPage.goto(observerOrigin);
|
|
1599
|
+
const text = await observerPage.evaluate(() => navigator.clipboard.readText());
|
|
1600
|
+
await page.evaluate(expectedOrigin => {
|
|
1601
|
+
if (location.origin !== expectedOrigin)
|
|
1602
|
+
throw new Error("Page navigated during clipboard read");
|
|
1603
|
+
}, origin);
|
|
1604
|
+
return text;
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
await observer.close();
|
|
1608
|
+
}
|
|
1609
|
+
}, { wrap: true });
|
|
1610
|
+
},
|
|
1554
1611
|
locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
|
|
1555
1612
|
getByRole: (role, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByRole", args: [role, opts] }] }),
|
|
1556
1613
|
getByText: (text, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByText", args: [text, opts] }] }),
|
|
@@ -1697,6 +1754,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1697
1754
|
}
|
|
1698
1755
|
catch { /* capture reports the error */ }
|
|
1699
1756
|
}
|
|
1757
|
+
releaseBrowserCoverage(holder.cdp);
|
|
1700
1758
|
viewClosed = true;
|
|
1701
1759
|
buildOpts.onDestroy?.();
|
|
1702
1760
|
try {
|
package/package.json
CHANGED
|
@@ -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 {};
|