@specific.dev/spectest 0.131.21 → 0.132.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/browser-coverage.d.ts +3 -0
  2. package/dist/browser-coverage.js +84 -31
  3. package/dist/browser.d.ts +4 -0
  4. package/dist/browser.js +80 -22
  5. package/dist/change-rules.d.ts +45 -0
  6. package/dist/change-rules.js +179 -0
  7. package/dist/components/k3s-coverage.js +4 -4
  8. package/dist/components/supabase-coverage.js +12 -0
  9. package/dist/components/supabase.js +25 -12
  10. package/dist/coverage-bun.js +2 -1
  11. package/dist/coverage-next.d.ts +5 -0
  12. package/dist/coverage-next.js +27 -3
  13. package/dist/coverage-rust.d.ts +2 -0
  14. package/dist/coverage-rust.js +20 -0
  15. package/dist/coverage.d.ts +27 -3
  16. package/dist/coverage.js +68 -15
  17. package/dist/daemon.js +13 -10
  18. package/dist/harness/component-images.d.ts +5 -0
  19. package/dist/harness/component-images.js +15 -2
  20. package/dist/harness/coverage-target.d.ts +2 -0
  21. package/dist/harness/coverage-target.js +3 -2
  22. package/dist/harness/reach.d.ts +6 -1
  23. package/dist/harness/reach.js +8 -34
  24. package/dist/harness/shadow-builder.d.ts +2 -1
  25. package/dist/harness/shadow-builder.js +6 -2
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +5 -0
  28. package/package.json +1 -1
  29. package/src/browser-clipboard.test.ts +177 -0
  30. package/src/browser-coverage-deadline.test.ts +2 -0
  31. package/src/browser-coverage-identity.test.ts +114 -2
  32. package/src/browser-coverage-navigation.test.ts +1 -2
  33. package/src/browser-coverage-sdk.test.ts +91 -0
  34. package/src/browser-coverage.ts +95 -30
  35. package/src/browser-executable.test.ts +7 -5
  36. package/src/browser-finalization.test.ts +1 -1
  37. package/src/browser-image-loading.test.ts +1 -1
  38. package/src/browser-navigation.test.ts +1 -1
  39. package/src/browser-permissions.test.ts +143 -0
  40. package/src/browser-replay-transport.test.ts +60 -0
  41. package/src/browser-screenshot.test.ts +1 -2
  42. package/src/browser-timezone.test.ts +1 -1
  43. package/src/browser.ts +79 -20
  44. package/src/change-rules.fixtures.json +35 -0
  45. package/src/change-rules.test.ts +125 -0
  46. package/src/change-rules.ts +147 -0
  47. package/src/components/k3s-coverage.test.ts +32 -0
  48. package/src/components/k3s-coverage.ts +4 -4
  49. package/src/components/supabase-coverage.ts +12 -0
  50. package/src/components/supabase-dir.test.ts +26 -0
  51. package/src/components/supabase-functions-reach.test.ts +41 -1
  52. package/src/components/supabase-migrations.test.ts +1 -1
  53. package/src/components/supabase.ts +26 -12
  54. package/src/coverage-bun.ts +2 -1
  55. package/src/coverage-next.ts +29 -3
  56. package/src/coverage-rust.test.ts +34 -0
  57. package/src/coverage-rust.ts +22 -0
  58. package/src/coverage.test.ts +2 -1
  59. package/src/coverage.ts +76 -7
  60. package/src/daemon.ts +14 -9
  61. package/src/harness/component-images.test.ts +42 -0
  62. package/src/harness/component-images.ts +20 -3
  63. package/src/harness/coverage-target.ts +5 -2
  64. package/src/harness/reach.test.ts +40 -6
  65. package/src/harness/reach.ts +13 -3
  66. package/src/harness/shadow-builder.test.ts +8 -0
  67. package/src/harness/shadow-builder.ts +7 -2
  68. package/src/index.ts +11 -0
  69. package/src/microphone-fixture.test.ts +106 -15
  70. package/src/playwright-assertions.test.ts +3 -3
  71. package/src/recorder.ts +1 -1
@@ -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>;
@@ -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
- /** The collectors of every session attached so far, for the harvest at
36
- * capture; a closed session's take fails and is dropped there. */
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
- /** Decoded maps by URL (or content hash for inline `data:` maps). */
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
- // IDs belong to a renderer, not the lifetime of this CDP session. A
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
- if (c.incomplete)
89
- throw c.incomplete;
90
- // Preserve the existing handling of closed/navigating pages.
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 scriptsAtTake = new Map(c.scripts);
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 (err) {
119
- if (!signal.aborted)
120
- LIVE_COLLECTORS.delete(c); // the session is gone
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 = c.scripts.get(script.scriptId);
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
- c.scripts.set(script.scriptId, info);
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
- continue; // script gone with its document
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 Headless Shell (pipe transport — `chromium.launch`
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 Headless Shell per daemon, launched lazily and kept
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-headless-shell") ?? Bun.which("chrome-headless-shell");
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 Headless Shell is required by this SDK but is missing from the runtime image. Rebuild the environment with a current Spectest golden image; for local development, install chromium-headless-shell on PATH.");
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
- const context = await browser.newContext({
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 one. Wrapped as a single expression so view.evaluate's
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 buf;
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
- const events = (await holder.page.evaluate(expression));
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
- // Fold this page's V8 counters into the per-service totals now — a
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 (seq !== undefined && opts?.wrap) {
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;