@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
@@ -3,10 +3,12 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- test("SDK refuses a Chromium-only runtime instead of silently falling back", async () => {
7
- const dir = await mkdtemp(join(tmpdir(), "spectest-no-shell-"));
6
+ test("SDK refuses a Shell-only runtime instead of silently changing browser behavior", async () => {
7
+ const dir = await mkdtemp(join(tmpdir(), "spectest-no-chromium-"));
8
8
  try {
9
- await writeFile(join(dir, "chromium"), "#!/bin/sh\necho UNEXPECTED_CHROMIUM_LAUNCH >&2\nexit 1\n", { mode: 0o755 });
9
+ for (const name of ["chromium-headless-shell", "chrome-headless-shell"]) {
10
+ await writeFile(join(dir, name), "#!/bin/sh\necho UNEXPECTED_SHELL_LAUNCH >&2\nexit 1\n", { mode: 0o755 });
11
+ }
10
12
  const browserModule = new URL("./browser.ts", import.meta.url).href;
11
13
  const child = Bun.spawn([process.execPath, "-e", `
12
14
  import { openBrowser } from ${JSON.stringify(browserModule)};
@@ -15,7 +17,7 @@ test("SDK refuses a Chromium-only runtime instead of silently falling back", asy
15
17
  `], { env: { ...process.env, PATH: dir }, stdout: "pipe", stderr: "pipe" });
16
18
  const stderr = await new Response(child.stderr).text();
17
19
  expect(await child.exited).toBe(1);
18
- expect(stderr).toContain("Headless Shell is required");
19
- expect(stderr).not.toContain("UNEXPECTED_CHROMIUM_LAUNCH");
20
+ expect(stderr).toContain("Chromium is required");
21
+ expect(stderr).not.toContain("UNEXPECTED_SHELL_LAUNCH");
20
22
  } finally { await rm(dir, { recursive: true, force: true }); }
21
23
  });
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { acquirePersistentBrowser } from "./browser.js";
3
3
  import { finalizeBrowserRecordings } from "./harness/deadline.js";
4
4
 
5
- const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
6
6
 
7
7
  test.skipIf(!available)("stuck replay drains are bounded warnings and late replies cannot change the recording or clear a later buffer", async () => {
8
8
  const chunks: unknown[] = [];
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { readRaw } from "./inspect.js";
3
3
  import { openBrowser } from "./browser.js";
4
4
 
5
- const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
6
6
  const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64");
7
7
 
8
8
  for (const late of [false, true]) {
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { openBrowser } from "./browser.js";
3
3
  import { startRecording, stopRecording } from "./recorder.js";
4
4
 
5
- const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
6
6
 
7
7
  test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
8
8
  // The document commits immediately but load waits for an image. This
@@ -0,0 +1,143 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { chromium, type Browser as PlaywrightBrowser, type Page } from "playwright-core";
4
+ import { acquirePersistentBrowser, openBrowser, prewarmViewPool, type Browser } from "./browser.js";
5
+
6
+ const executable = Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome");
7
+ const referenceExecutable = executable ?? chromium.executablePath();
8
+
9
+ // All 18 Chromium permission names in pinned Playwright 1.61.1, expressed as
10
+ // native web descriptors (midi-sysex uses midi + sysex). No overrides: these
11
+ // tests protect the defaults that apps received before the Shell migration.
12
+ const cases = [
13
+ { label: "geolocation", expected: "prompt" },
14
+ { label: "midi", expected: "prompt" },
15
+ { label: "notifications", expected: "prompt" },
16
+ { label: "camera", expected: "granted" },
17
+ { label: "microphone", expected: "granted" },
18
+ { label: "background-sync", expected: "granted" },
19
+ { label: "ambient-light-sensor", expected: "TypeError: Failed to execute 'query' on 'Permissions': GenericSensorExtraClasses flag is not enabled." },
20
+ { label: "accelerometer", expected: "granted" },
21
+ { label: "gyroscope", expected: "granted" },
22
+ { label: "magnetometer", expected: "granted" },
23
+ { label: "clipboard-read", expected: "prompt" },
24
+ { label: "clipboard-write", expected: "granted" },
25
+ { label: "payment-handler", expected: "granted" },
26
+ { label: "midi-sysex", expected: "prompt", descriptor: { name: "midi", sysex: true } },
27
+ { label: "storage-access", expected: "granted" },
28
+ { label: "local-fonts", expected: "prompt" },
29
+ { label: "local-network-access", expected: "prompt" },
30
+ { label: "screen-wake-lock", expected: "granted" },
31
+ ];
32
+ const descriptors = cases.map(row => row.descriptor ?? { name: row.label });
33
+
34
+ async function permissionStates(arg?: unknown): Promise<string[]> {
35
+ const states: string[] = [];
36
+ for (const descriptor of arg as PermissionDescriptor[]) {
37
+ try { states.push((await navigator.permissions.query(descriptor)).state); }
38
+ catch (error) { states.push(`${(error as Error).name}: ${(error as Error).message}`); }
39
+ }
40
+ return states;
41
+ }
42
+
43
+ async function sdkStates(browser: Browser): Promise<string[]> {
44
+ return (await browser.evaluate("read default native permissions", permissionStates, descriptors)).unwrap();
45
+ }
46
+
47
+ // Runs unchanged in the real SDK and an independently launched full Chrome.
48
+ // Do not request prompt-gated capabilities: the contract is to preserve their
49
+ // native prompt state, not grant them globally or install JavaScript shims.
50
+ async function nativeDefaultCapabilities() {
51
+ const media = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
52
+ const tracks = media.getTracks().map(track => `${track.kind}:${track.readyState}`).sort();
53
+ media.getTracks().forEach(track => track.stop());
54
+ const lock = await navigator.wakeLock.request("screen");
55
+ const wakeLock = { type: lock.type, before: lock.released, after: false };
56
+ await lock.release();
57
+ wakeLock.after = lock.released;
58
+ await document.requestStorageAccess();
59
+ const hasStorageAccess = await document.hasStorageAccess();
60
+ const registration = await navigator.serviceWorker.register("/permissions-sw.js");
61
+ await navigator.serviceWorker.ready;
62
+ // This operation succeeds in full Chromium but Shell throws 'Background
63
+ // Sync is disabled' even when its permission has explicitly been granted.
64
+ await (registration as any).sync.register("permission-regression");
65
+ const paymentManager = typeof (registration as any).paymentManager;
66
+ await registration.unregister();
67
+ return {
68
+ tracks, wakeLock, hasStorageAccess, backgroundSync: "registered", paymentManager,
69
+ notificationPermission: Notification.permission,
70
+ ambientConstructor: typeof (window as any).AmbientLightSensor,
71
+ };
72
+ }
73
+
74
+ describe.skipIf(!executable || !existsSync(referenceExecutable))("native browser permission compatibility", () => {
75
+ let server: ReturnType<typeof Bun.serve>;
76
+ let sdk: Browser;
77
+ let reference: PlaywrightBrowser;
78
+ let page: Page;
79
+ let expected: string[];
80
+ beforeAll(async () => {
81
+ server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(request) {
82
+ if (new URL(request.url).pathname === "/permissions-sw.js") return new Response(
83
+ "self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', e => e.waitUntil(clients.claim())); self.addEventListener('sync', () => {});",
84
+ { headers: { "content-type": "application/javascript" } });
85
+ return new Response("<title>Default permissions</title>", { headers: { "content-type": "text/html" } });
86
+ } });
87
+ sdk = await openBrowser({ url: server.url.href });
88
+ reference = await chromium.launch({ executablePath: referenceExecutable, headless: true, args: [
89
+ "--no-sandbox", "--disable-dev-shm-usage", "--disable-features=AsyncDns,DnsOverHttps",
90
+ "--dns-over-https-mode=off", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream",
91
+ ] });
92
+ page = await reference.newPage();
93
+ await page.goto(server.url.href);
94
+ expected = await page.evaluate(permissionStates, descriptors);
95
+ });
96
+ afterAll(async () => {
97
+ await sdk?.close();
98
+ await reference?.close();
99
+ server?.stop(true);
100
+ });
101
+
102
+ test.each(cases)("$label keeps the previous full-Chromium default", async row => {
103
+ const index = cases.indexOf(row);
104
+ const actual = await sdkStates(sdk);
105
+ expect(expected[index]).toBe(row.expected);
106
+ expect(actual[index]).toBe(expected[index]);
107
+ });
108
+
109
+ test("default native media, wake lock, storage access and Background Sync match full Chrome", async () => {
110
+ const actual = (await sdk.evaluate("exercise native default capabilities", nativeDefaultCapabilities)).unwrap();
111
+ expect(actual).toEqual(await page.evaluate(nativeDefaultCapabilities));
112
+ expect(actual).toEqual({
113
+ tracks: ["audio:live", "video:live"], wakeLock: { type: "screen", before: false, after: true },
114
+ hasStorageAccess: true, backgroundSync: "registered", paymentManager: "object",
115
+ notificationPermission: "default", ambientConstructor: "undefined",
116
+ });
117
+ }, 15000);
118
+
119
+ test("navigation away and back retains native permission defaults", async () => {
120
+ await sdk.goto("about:blank");
121
+ await sdk.goto(server.url.href.replace("127.0.0.1", "localhost"));
122
+ expect(await sdkStates(sdk)).toEqual(expected);
123
+ await sdk.goto(server.url.href);
124
+ expect(await sdkStates(sdk)).toEqual(expected);
125
+ });
126
+
127
+ test("pooled, mobile and reattached sessions retain the same native defaults", async () => {
128
+ await prewarmViewPool();
129
+ const pooled = await openBrowser({ url: server.url.href });
130
+ const mobile = await openBrowser({ frame: "mobile", url: server.url.href });
131
+ const persistent = await acquirePersistentBrowser("permission-defaults", { url: server.url.href });
132
+ try {
133
+ expect(await sdkStates(pooled)).toEqual(expected);
134
+ expect(await sdkStates(mobile)).toEqual(expected);
135
+ expect(await sdkStates(persistent.browser)).toEqual(expected);
136
+ await persistent.detach();
137
+ const inherited = await acquirePersistentBrowser("permission-defaults");
138
+ expect(inherited.attached).toBe(true);
139
+ expect(await sdkStates(inherited.browser)).toEqual(expected);
140
+ await inherited.detach();
141
+ } finally { await pooled.close(); await mobile.close(); await persistent.browser.close(); }
142
+ }, 15000);
143
+ });
@@ -0,0 +1,60 @@
1
+ import { expect, test } from "bun:test";
2
+ import { acquirePersistentBrowser, type BrowserSessionStep } from "./browser.js";
3
+
4
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
5
+
6
+ test.skipIf(!available)("replay JSON transport preserves batches, drains once and leaves finalization read-only", async () => {
7
+ const chunks: BrowserSessionStep[] = [];
8
+ const session = await acquirePersistentBrowser("replay-json", {
9
+ url: "data:text/html,<title>one</title><p>Initial page</p>",
10
+ recorder: { sessionId: "replay-json", recordStep: step => chunks.push(step) },
11
+ });
12
+ const events = () => chunks.flatMap(c => c.events) as any[];
13
+ try {
14
+ expect(events().some(e => e.type === 2)).toBe(true);
15
+ const batch = Array.from({ length: 1000 }, (_, i) => ({
16
+ type: 5, timestamp: i, data: { tag: "bulk", payload: { id: i, text: '日本語 🙂 "quotes" \\ newline\n\ud800', nested: [null, true, { css: "a{}".repeat(100) }] } },
17
+ }));
18
+ await session.browser.probe(`window.__spectestRrwebEvents = ${JSON.stringify(batch)}`);
19
+ await session.browser.evaluate("flush replay", "1");
20
+ expect(events().filter(e => e.data?.tag === "bulk")).toEqual(batch);
21
+ expect(await session.browser.probe("window.__spectestRrwebEvents.length")).toBe(0);
22
+ await session.browser.evaluate("empty drain", "1");
23
+ expect(events().filter(e => e.data?.tag === "bulk")).toHaveLength(1000);
24
+
25
+ // General evaluations retain Playwright's richer JS value semantics.
26
+ const rich = await session.browser.probe<any>("({ value: undefined, nan: NaN, minusZero: -0, date: new Date(0) })");
27
+ expect(Object.hasOwn(rich, "value")).toBe(true);
28
+ expect(Number.isNaN(rich.nan)).toBe(true);
29
+ expect(Object.is(rich.minusZero, -0)).toBe(true);
30
+ expect(rich.date).toEqual(new Date(0));
31
+
32
+ await session.browser.goto("data:text/html,<title>two</title><p>New document</p>");
33
+ const last = chunks.at(-1)!;
34
+ expect(last.events.some((e: any) => e.type === 2)).toBe(true);
35
+ const final = [{ type: 5, data: { tag: "final", payload: ["retained"] } }];
36
+ await session.browser.probe(`window.__spectestRrwebEvents = ${JSON.stringify(final)}`);
37
+ await session.detach();
38
+ expect(events().filter(e => e.data?.tag === "final")).toEqual(final);
39
+ expect(await session.browser.probe("window.__spectestRrwebEvents")).toEqual(final);
40
+ const count = chunks.length;
41
+ await session.detach();
42
+ expect(chunks).toHaveLength(count);
43
+ } finally { await session.browser.close(); }
44
+ }, 30000);
45
+
46
+ test.skipIf(!available)("a replay serialization failure retains the buffer for a later drain", async () => {
47
+ const chunks: BrowserSessionStep[] = [];
48
+ const session = await acquirePersistentBrowser("replay-json-failure", {
49
+ url: "data:text/html,<p>Serialization failure</p>",
50
+ recorder: { sessionId: "replay-json-failure", recordStep: step => chunks.push(step) },
51
+ });
52
+ try {
53
+ await session.browser.probe('window.__spectestRrwebEvents = [{type:5,data:{tag:"retry",value:1n}}]');
54
+ await session.browser.evaluate("failed recording drain", "1");
55
+ expect(await session.browser.probe('window.__spectestRrwebEvents[0].data.tag')).toBe("retry");
56
+ await session.browser.probe('window.__spectestRrwebEvents[0].data.value = 1');
57
+ await session.browser.evaluate("retry recording drain", "1");
58
+ expect(chunks.flatMap(c => c.events).filter((e: any) => e.data?.tag === "retry")).toEqual([{type:5,data:{tag:"retry",value:1}}]);
59
+ } finally { await session.browser.close(); }
60
+ }, 15000);
@@ -3,8 +3,7 @@ import { openBrowser, type BrowserSessionRecorder } from "./browser.js";
3
3
  import { readRaw } from "./inspect.js";
4
4
 
5
5
  const available =
6
- Bun.which("chromium-headless-shell") ??
7
- Bun.which("chrome-headless-shell");
6
+ Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome");
8
7
  for (const frame of ["browser", "mobile"] as const) {
9
8
  test.skipIf(!available)(
10
9
  `${frame} public screenshot exports full PNG pixels after DOM changes`,
@@ -21,7 +21,7 @@ async function settings(browser: Browser): Promise<Settings> {
21
21
  return readRaw(await browser.evaluate<Settings>("read initial regional settings", "window.settings")) as unknown as Settings;
22
22
  }
23
23
 
24
- describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser timezone and locale", () => {
24
+ describe.skipIf(!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome")))("browser timezone and locale", () => {
25
25
  let server: ReturnType<typeof Bun.serve>;
26
26
  beforeAll(() => {
27
27
  server = Bun.serve({
package/src/browser.ts 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
@@ -49,6 +49,7 @@ import {
49
49
  attachBrowserCoverage,
50
50
  browserCoverageActive,
51
51
  harvestBrowserCoverage,
52
+ releaseBrowserCoverage,
52
53
  } from "./browser-coverage.js";
53
54
  import type { UrlPattern } from "./url-match.js";
54
55
  export type { UrlPattern } from "./url-match.js";
@@ -236,6 +237,10 @@ export interface Browser {
236
237
  goForward(options?: NavigationOptions): Promise<void>;
237
238
  /** Reload the current page. */
238
239
  reload(options?: NavigationOptions): Promise<void>;
240
+ /** Read native clipboard text from an isolated observer in the same browser.
241
+ * Requires a secure page and honors its clipboard Permissions-Policy.
242
+ * Leaves application permissions unchanged and links assertions to the read. */
243
+ readClipboard(): Promise<Wrapped<string>>;
239
244
 
240
245
  readonly keyboard: Keyboard;
241
246
  readonly mouse: Mouse;
@@ -463,7 +468,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
463
468
  // Shared Chromium (playwright-core)
464
469
  // ────────────────────────────────────────────────────────────────────────
465
470
 
466
- // Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
471
+ // Browser and mobile sessions share one full Chromium per daemon, launched lazily and kept
467
472
  // for the daemon's lifetime. Fixture sessions own a process because the audio
468
473
  // source is a process-wide flag. Both ride snapshots: the process, playwright's
469
474
  // pipe connection to it, and every open page freeze into the VM snapshot
@@ -482,13 +487,15 @@ const VIEW_CLEANUPS = new WeakMap<BrowserContext, () => Promise<void>>();
482
487
  // authenticated.
483
488
 
484
489
  function browserExecutable(): string {
485
- const executable = Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell");
490
+ const executable = Bun.which("chromium") ?? Bun.which("chromium-browser") ??
491
+ Bun.which("google-chrome") ?? Bun.which("chrome");
486
492
  if (!executable) {
487
- 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.");
493
+ 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.");
488
494
  }
489
495
  return executable;
490
496
  }
491
497
 
498
+
492
499
  async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
493
500
  if (PW_BROWSER?.isConnected()) return PW_BROWSER;
494
501
  PW_BROWSER = await chromium.launch({
@@ -528,11 +535,12 @@ async function newViewContext(
528
535
  ): Promise<BrowserContext> {
529
536
  const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
530
537
  let browser: PlaywrightBrowser | undefined;
538
+ let context: BrowserContext | undefined;
531
539
  try {
532
540
  browser = fixture ? await chromium.launch({
533
541
  executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
534
542
  }) : await ensurePlaywrightBrowser();
535
- const context = await browser.newContext({
543
+ context = await browser.newContext({
536
544
  ...(device
537
545
  ? deviceContextOptions(device)
538
546
  : {
@@ -554,6 +562,7 @@ async function newViewContext(
554
562
  }
555
563
  return context;
556
564
  } catch (err) {
565
+ await context?.close().catch(() => {});
557
566
  if (fixture) {
558
567
  try { await browser?.close(); } finally { await fixture.cleanup(); }
559
568
  }
@@ -1176,7 +1185,7 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
1176
1185
  : "";
1177
1186
 
1178
1187
  // Page-side expression that atomically swaps in a fresh buffer and
1179
- // returns the old one. Wrapped as a single expression so view.evaluate's
1188
+ // returns the old batch as JSON. Wrapped as a single expression so view.evaluate's
1180
1189
  // `await (...)` wrapper accepts it.
1181
1190
  //
1182
1191
  // `forceFullIfMissing` (inlined by `drainExpr`) guards a recovery path
@@ -1220,9 +1229,11 @@ function drainExpr(forceFullIfMissing: boolean, timestamp = Date.now()): string
1220
1229
  window.__spectestSyncInputs(${timestamp});
1221
1230
  }
1222
1231
  var buf = window.__spectestRrwebEvents;
1223
- if (!buf || !buf.length) return [];
1232
+ if (!buf || !buf.length) return "[]";
1233
+ // Serialize before clearing: an encoding failure must leave the batch intact.
1234
+ var json = JSON.stringify(buf);
1224
1235
  window.__spectestRrwebEvents = [];
1225
- return buf;
1236
+ return json;
1226
1237
  })()`;
1227
1238
  }
1228
1239
 
@@ -1743,11 +1754,6 @@ async function daemonResolves(url: string): Promise<boolean> {
1743
1754
  * retry once.
1744
1755
  */
1745
1756
  async function rebuildView(holder: ViewHolder): Promise<void> {
1746
- try {
1747
- await holder.page.close();
1748
- } catch {
1749
- /* page may already be gone */
1750
- }
1751
1757
  // The old renderer's coverage counters die with its page; harvest what
1752
1758
  // it had before the swap (rebuild is triggered on a fresh navigation).
1753
1759
  try {
@@ -1755,6 +1761,12 @@ async function rebuildView(holder: ViewHolder): Promise<void> {
1755
1761
  } catch {
1756
1762
  /* best-effort */
1757
1763
  }
1764
+ releaseBrowserCoverage(holder.cdp);
1765
+ try {
1766
+ await holder.page.close();
1767
+ } catch {
1768
+ /* page may already be gone */
1769
+ }
1758
1770
  const fresh = await spawnPage(holder.context);
1759
1771
  holder.page = fresh.page;
1760
1772
  holder.cdp = fresh.cdp;
@@ -1850,11 +1862,15 @@ function buildBackend(
1850
1862
  // Finalization only reads the buffer. A timed-out evaluation may run
1851
1863
  // after a child/eval attaches; it must not clear that session's events.
1852
1864
  const expression = action === "close"
1853
- ? "window.__spectestRrwebEvents || []"
1865
+ ? "JSON.stringify(window.__spectestRrwebEvents || [])"
1854
1866
  : drainExpr(urlChanged, timestamp);
1855
- const events = (await holder.page.evaluate(expression)) as unknown[];
1867
+ // Replay batches are JSON data. Returning one string avoids Playwright's
1868
+ // recursive SerializedValue validation/copying for every event field.
1869
+ // Keep general user evaluations on Playwright's normal value transport.
1870
+ const encoded = await holder.page.evaluate<string>(expression);
1856
1871
  signal?.throwIfAborted();
1857
1872
  if (recordingEnded) return;
1873
+ const events: unknown = JSON.parse(encoded);
1858
1874
  lastDrainUrl = holder.page.url();
1859
1875
  if (Array.isArray(events) && events.length > 0) {
1860
1876
  recorder.recordStep({
@@ -1881,6 +1897,12 @@ function buildBackend(
1881
1897
  const t = Date.now();
1882
1898
  const resv = reserveEvent();
1883
1899
  try {
1900
+ // Any action (including a click or evaluation) can navigate and replace
1901
+ // the renderer. Drain its counters while it is still alive. Collection
1902
+ // failures remain on the collector and fail coverage capture later.
1903
+ if (browserCoverageActive()) {
1904
+ try { await harvestBrowserCoverage(holder.cdp); } catch { /* retained by collector */ }
1905
+ }
1884
1906
  const result = await fn();
1885
1907
  // Refresh the sync title cache (playwright's title() is async).
1886
1908
  try {
@@ -1888,7 +1910,7 @@ function buildBackend(
1888
1910
  } catch {
1889
1911
  /* page mid-navigation or closed — keep the stale cache */
1890
1912
  }
1891
- if (recordingEnded) return result;
1913
+ if (recordingEnded) return opts?.wrap ? wrap(result, undefined) as T : result;
1892
1914
  // `sessionTimestamp` is the post-op wall clock — that's where we
1893
1915
  // want the dashboard's seek to land, so clicking "type 'foo'"
1894
1916
  // shows the input *with* the text, not the empty field just
@@ -1902,9 +1924,7 @@ function buildBackend(
1902
1924
  durationMs: endT - t,
1903
1925
  }, resv);
1904
1926
  await drain(action, endT);
1905
- // Fold this page's V8 counters into the per-service totals now — a
1906
- // navigation to another origin replaces the renderer and its
1907
- // counters, so harvesting per op is what keeps them.
1927
+ // Collect the action's effects too, including scripts in a new renderer.
1908
1928
  if (browserCoverageActive()) {
1909
1929
  try {
1910
1930
  await harvestBrowserCoverage(holder.cdp);
@@ -1915,7 +1935,7 @@ function buildBackend(
1915
1935
  // Reads (`opts.wrap`) return user-visible JS values someone is likely to
1916
1936
  // assert on — provenance-wrap so `expect(...)` nests under this step.
1917
1937
  // Actions return void/internals; leave them raw to avoid Proxy surprises.
1918
- if (seq !== undefined && opts?.wrap) {
1938
+ if (opts?.wrap) {
1919
1939
  return wrap(result, seq) as T;
1920
1940
  }
1921
1941
  return result;
@@ -2054,6 +2074,44 @@ function buildBackend(
2054
2074
  await holder.page.reload(options);
2055
2075
  });
2056
2076
  },
2077
+ readClipboard() {
2078
+ return instrumented<string>(
2079
+ "readClipboard", {},
2080
+ async () => {
2081
+ const page = holder.page;
2082
+ const { origin, policyAllowsRead } = await page.evaluate(() => {
2083
+ if (!isSecureContext || !/^https?:$/.test(location.protocol)) {
2084
+ throw new Error("Clipboard reads require a secure HTTP(S) page");
2085
+ }
2086
+ const policy = (document as Document & { featurePolicy?: { allowsFeature(name: string): boolean } }).featurePolicy;
2087
+ return { origin: location.origin, policyAllowsRead: policy?.allowsFeature("clipboard-read") ?? true };
2088
+ });
2089
+ if (!policyAllowsRead) return page.evaluate(() => navigator.clipboard.readText());
2090
+ const browser = holder.context.browser();
2091
+ if (!browser) throw new Error("Clipboard reads require an attached Chromium browser");
2092
+ // Native clipboard contents belong to the browser, not the context.
2093
+ // Observe from a disposable context: overriding an app permission and
2094
+ // restoring its queried state cannot restore native prompt behavior
2095
+ // or preserve overrides owned by another DevTools session.
2096
+ const observer = await browser.newContext();
2097
+ try {
2098
+ const observerOrigin = "https://spectest-clipboard.invalid";
2099
+ await observer.route("**/*", route => route.fulfill({
2100
+ status: 200, contentType: "text/html", body: "<!doctype html><title>Clipboard observation</title>",
2101
+ }));
2102
+ await observer.grantPermissions(["clipboard-read"], { origin: observerOrigin });
2103
+ const observerPage = await observer.newPage();
2104
+ await observerPage.goto(observerOrigin);
2105
+ const text = await observerPage.evaluate(() => navigator.clipboard.readText());
2106
+ await page.evaluate(expectedOrigin => {
2107
+ if (location.origin !== expectedOrigin) throw new Error("Page navigated during clipboard read");
2108
+ }, origin);
2109
+ return text;
2110
+ } finally { await observer.close(); }
2111
+ },
2112
+ { wrap: true },
2113
+ ) as unknown as Promise<Wrapped<string>>;
2114
+ },
2057
2115
 
2058
2116
  locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
2059
2117
  getByRole: (role, opts) =>
@@ -2236,6 +2294,7 @@ function buildBackend(
2236
2294
  if (browserCoverageActive()) {
2237
2295
  try { await harvestBrowserCoverage(holder.cdp); } catch { /* capture reports the error */ }
2238
2296
  }
2297
+ releaseBrowserCoverage(holder.cdp);
2239
2298
  viewClosed = true;
2240
2299
  buildOpts.onDestroy?.();
2241
2300
  try {
@@ -0,0 +1,35 @@
1
+ [
2
+ {"pattern":"**/*.ts","path":"helper.ts","matches":true},
3
+ {"pattern":"**/*.ts","path":"a/b/helper.ts","matches":true},
4
+ {"pattern":"*.ts","path":"a/helper.ts","matches":false},
5
+ {"pattern":"src/**","path":"src/a/b.ts","matches":true},
6
+ {"pattern":"src","path":"src/a.ts","matches":false},
7
+ {"pattern":"app/\\[id\\]/page.tsx","path":"app/[id]/page.tsx","matches":true},
8
+ {"pattern":"app/[[]id]/page.tsx","path":"app/[id]/page.tsx","matches":true},
9
+ {"pattern":"app/[id]/page.tsx","path":"app/[id]/page.tsx","matches":false},
10
+ {"pattern":"app/[id]/page.tsx","path":"app/i/page.tsx","matches":true},
11
+ {"pattern":"app/(shop)/\\[...slug\\]/page.tsx","path":"app/(shop)/[...slug]/page.tsx","matches":true},
12
+ {"pattern":"a?b.ts","path":"a😀b.ts","matches":true},
13
+ {"pattern":"a?b.ts","path":"a/b.ts","matches":false},
14
+ {"pattern":"[!a].ts","path":"b.ts","matches":true},
15
+ {"pattern":"[!a].ts","path":"a.ts","matches":false},
16
+ {"pattern":"[!a].ts","path":"/.ts","matches":false},
17
+ {"pattern":"[a-c].ts","path":"b.ts","matches":true},
18
+ {"pattern":"[a-c].ts","path":"d.ts","matches":false},
19
+ {"pattern":"[😀-🙏].ts","path":"😂.ts","matches":true},
20
+ {"pattern":"**/*.md","path":"a/line\nbreak.md","matches":true},
21
+ {"pattern":"file.ts","path":"file.ts\n","matches":false},
22
+ {"pattern":"!odd#name:foo+.ts","path":"!odd#name:foo+.ts","matches":true},
23
+ {"pattern":" leading space.ts","path":" leading space.ts","matches":true},
24
+ {"pattern":"file\\*.ts","path":"file*.ts","matches":true},
25
+ {"pattern":"*.{ts,js}","path":"a.ts","matches":false},
26
+ {"pattern":"a/[z-a]","invalid":true},
27
+ {"pattern":"a/[.-0]","invalid":true},
28
+ {"pattern":"a/[abc","invalid":true},
29
+ {"pattern":"a/[]","invalid":true},
30
+ {"pattern":"a/\\","invalid":true},
31
+ {"pattern":"foo**bar.ts","invalid":true},
32
+ {"pattern":"../secret","invalid":true},
33
+ {"pattern":"/absolute","invalid":true},
34
+ {"pattern":"a//b","invalid":true}
35
+ ]