@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
|
@@ -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 {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Portable source-change policy, evaluated identically by the SDK and controller.
|
|
2
|
+
* Globs match whole root-relative paths: *, ?, [], [!], ** and backslash escapes.
|
|
3
|
+
* Braces/extglobs have no special meaning. Directory rules explicitly use /**. */
|
|
4
|
+
export type ChangeAction = "coverage" | "full" | "ignore";
|
|
5
|
+
export interface ChangeRule {
|
|
6
|
+
root?: string;
|
|
7
|
+
pattern: string;
|
|
8
|
+
action: ChangeAction;
|
|
9
|
+
}
|
|
10
|
+
export interface ResolvedChangeRule extends ChangeRule {
|
|
11
|
+
root: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ChangePolicy {
|
|
14
|
+
defaults: readonly (readonly ResolvedChangeRule[])[];
|
|
15
|
+
overrides: readonly ResolvedChangeRule[];
|
|
16
|
+
}
|
|
17
|
+
export interface ChangeRuleAdapter {
|
|
18
|
+
changeRules?: readonly ChangeRule[];
|
|
19
|
+
/** Wrappers preserve independent nested adapter policies, including roots. */
|
|
20
|
+
changeRuleGroups?: readonly (readonly ChangeRule[])[];
|
|
21
|
+
sourceOptions?: {
|
|
22
|
+
sourceRoot?: string;
|
|
23
|
+
} | ((...args: any[]) => unknown);
|
|
24
|
+
}
|
|
25
|
+
export interface ChangeRuleService {
|
|
26
|
+
/** Explicit rules override all defaults. false disables defaults. */
|
|
27
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
28
|
+
coverage?: boolean | {
|
|
29
|
+
sourceRoot?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export declare function compileChangeGlob(pattern: string): RegExp;
|
|
33
|
+
export declare function matchesChangeGlob(pattern: string, path: string): boolean;
|
|
34
|
+
export declare function validateChangeRules(value: unknown): asserts value is readonly ChangeRule[];
|
|
35
|
+
export declare const COMMON_CHANGE_RULES: readonly ChangeRule[];
|
|
36
|
+
export declare const JAVASCRIPT_CHANGE_RULES: readonly ChangeRule[];
|
|
37
|
+
export declare const NODE_CHANGE_RULES: readonly ChangeRule[];
|
|
38
|
+
export declare const BROWSER_CHANGE_RULES: readonly ChangeRule[];
|
|
39
|
+
export declare const BUN_CHANGE_RULES: readonly ChangeRule[];
|
|
40
|
+
export declare const NEXT_CHANGE_RULES: readonly ChangeRule[];
|
|
41
|
+
export declare const RUST_CHANGE_RULES: readonly ChangeRule[];
|
|
42
|
+
export declare function resolveChangePolicy(service: ChangeRuleService, adapters?: readonly ChangeRuleAdapter[]): ChangePolicy;
|
|
43
|
+
/** Compile once for scans over a source tree; regexes carry no mutable flags. */
|
|
44
|
+
export declare function compileChangePolicy(policy: ChangePolicy): (path: string) => ChangeAction | undefined;
|
|
45
|
+
export declare function evaluateChangePolicy(policy: ChangePolicy, path: string): ChangeAction | undefined;
|