@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
|
@@ -3,10 +3,12 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
-
test("SDK refuses a
|
|
7
|
-
const dir = await mkdtemp(join(tmpdir(), "spectest-no-
|
|
6
|
+
test("SDK refuses a Shell-only runtime instead of silently changing browser behavior", async () => {
|
|
7
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-no-chromium-"));
|
|
8
8
|
try {
|
|
9
|
-
|
|
9
|
+
for (const name of ["chromium-headless-shell", "chrome-headless-shell"]) {
|
|
10
|
+
await writeFile(join(dir, name), "#!/bin/sh\necho UNEXPECTED_SHELL_LAUNCH >&2\nexit 1\n", { mode: 0o755 });
|
|
11
|
+
}
|
|
10
12
|
const browserModule = new URL("./browser.ts", import.meta.url).href;
|
|
11
13
|
const child = Bun.spawn([process.execPath, "-e", `
|
|
12
14
|
import { openBrowser } from ${JSON.stringify(browserModule)};
|
|
@@ -15,7 +17,7 @@ test("SDK refuses a Chromium-only runtime instead of silently falling back", asy
|
|
|
15
17
|
`], { env: { ...process.env, PATH: dir }, stdout: "pipe", stderr: "pipe" });
|
|
16
18
|
const stderr = await new Response(child.stderr).text();
|
|
17
19
|
expect(await child.exited).toBe(1);
|
|
18
|
-
expect(stderr).toContain("
|
|
19
|
-
expect(stderr).not.toContain("
|
|
20
|
+
expect(stderr).toContain("Chromium is required");
|
|
21
|
+
expect(stderr).not.toContain("UNEXPECTED_SHELL_LAUNCH");
|
|
20
22
|
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
21
23
|
});
|
|
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { acquirePersistentBrowser } from "./browser.js";
|
|
3
3
|
import { finalizeBrowserRecordings } from "./harness/deadline.js";
|
|
4
4
|
|
|
5
|
-
const available = !!(Bun.which("chromium-
|
|
5
|
+
const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
|
|
6
6
|
|
|
7
7
|
test.skipIf(!available)("stuck replay drains are bounded warnings and late replies cannot change the recording or clear a later buffer", async () => {
|
|
8
8
|
const chunks: unknown[] = [];
|
|
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { readRaw } from "./inspect.js";
|
|
3
3
|
import { openBrowser } from "./browser.js";
|
|
4
4
|
|
|
5
|
-
const available = !!(Bun.which("chromium-
|
|
5
|
+
const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
|
|
6
6
|
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64");
|
|
7
7
|
|
|
8
8
|
for (const late of [false, true]) {
|
|
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { openBrowser } from "./browser.js";
|
|
3
3
|
import { startRecording, stopRecording } from "./recorder.js";
|
|
4
4
|
|
|
5
|
-
const available = !!(Bun.which("chromium-
|
|
5
|
+
const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome"));
|
|
6
6
|
|
|
7
7
|
test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
|
|
8
8
|
// The document commits immediately but load waits for an image. This
|
|
@@ -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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
+
]
|