@mochi.js/core 0.3.0 → 0.6.0

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.
@@ -5,132 +5,24 @@
5
5
  * browser reports its bare, un-spoofed fingerprint.
6
6
  *
7
7
  * No real Chromium process is spawned; we drive `Session` against a fake
8
- * `ChromiumProcess` whose pipe reader/writer let us observe every CDP
9
- * request sent and inject canned responses. The §8.2 forbidden-method
10
- * assertions still gate every send through `MessageRouter`, so the test
11
- * implicitly enforces those too.
8
+ * `ChromiumProcess` via the shared `tests/helpers/cdp-fixture.ts` helper.
9
+ * The §8.2 forbidden-method assertions still gate every send through
10
+ * `MessageRouter`, so the test implicitly enforces those too.
12
11
  *
13
12
  * @see PLAN.md §12.1 — capture must run against bare Chromium.
14
13
  * @see tasks/0040-mochi-capture.md — `bypassInject: true` requirement.
14
+ * @see tests/helpers/cdp-fixture.ts — shared helper consolidating fake-pipe boilerplate.
15
15
  */
16
16
 
17
17
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
18
18
  import { deriveMatrix, type ProfileV1 } from "@mochi.js/consistency";
19
- import type { PipeReader, PipeWriter } from "../cdp/transport";
20
- import type { ChromiumProcess } from "../proc";
19
+ import {
20
+ type FakePipe,
21
+ fakeChromiumProcess,
22
+ makeFakePipe,
23
+ } from "../../../../tests/helpers/cdp-fixture";
21
24
  import { Session } from "../session";
22
25
 
23
- interface FakeBrowser {
24
- process: ChromiumProcess;
25
- /** All CDP requests written to the pipe, decoded as JSON-RPC objects. */
26
- written: Array<{ id?: number; method?: string; params?: unknown; sessionId?: string }>;
27
- /** Inject one inbound JSON frame (CDP response or event). */
28
- push(obj: unknown): void;
29
- /** Auto-respond to any request matching `methodPredicate`. Returns the unsubscribe. */
30
- autoRespond(methodPredicate: (m: string) => boolean, result: unknown): void;
31
- /** Resolve when the next `n` writes have completed. */
32
- waitForWrites(n: number): Promise<void>;
33
- }
34
-
35
- function makeFakeBrowser(): FakeBrowser {
36
- const written: FakeBrowser["written"] = [];
37
- let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
38
- const stream = new ReadableStream<Uint8Array>({
39
- start(c) {
40
- pumpController = c;
41
- },
42
- });
43
- const enc = new TextEncoder();
44
- const dec = new TextDecoder();
45
- const autoResponders: Array<{ pred: (m: string) => boolean; result: unknown }> = [];
46
- const writeListeners: Array<() => void> = [];
47
-
48
- const reader: PipeReader = {
49
- getReader: () => stream.getReader(),
50
- };
51
-
52
- const push = (obj: unknown): void => {
53
- const bytes = enc.encode(JSON.stringify(obj));
54
- const out = new Uint8Array(bytes.length + 1);
55
- out.set(bytes, 0);
56
- out[bytes.length] = 0;
57
- pumpController?.enqueue(out);
58
- };
59
-
60
- const writer: PipeWriter = {
61
- write: (chunk) => {
62
- const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
63
- const json = dec.decode(chunk.subarray(0, last));
64
- try {
65
- const parsed = JSON.parse(json) as {
66
- id?: number;
67
- method?: string;
68
- params?: unknown;
69
- sessionId?: string;
70
- };
71
- written.push(parsed);
72
- // Notify listeners.
73
- const ls = writeListeners.splice(0, writeListeners.length);
74
- for (const fn of ls) fn();
75
- // Auto-respond if matched.
76
- if (typeof parsed.method === "string" && typeof parsed.id === "number") {
77
- const r = autoResponders.find((a) => a.pred(parsed.method ?? ""));
78
- if (r) {
79
- queueMicrotask(() => push({ id: parsed.id, result: r.result }));
80
- }
81
- }
82
- } catch {
83
- // ignore malformed
84
- }
85
- },
86
- flush: () => undefined,
87
- end: () => undefined,
88
- };
89
-
90
- let resolveExit: ((code: number) => void) | undefined;
91
- const exited = new Promise<number>((res) => {
92
- resolveExit = res;
93
- });
94
-
95
- let killed = false;
96
- const proc: ChromiumProcess = {
97
- userDataDir: "/tmp/fake-mochi-test",
98
- pid: 0,
99
- exited,
100
- reader,
101
- writer,
102
- close: async () => {
103
- if (killed) return;
104
- killed = true;
105
- try {
106
- pumpController?.close();
107
- } catch {
108
- // ignore
109
- }
110
- resolveExit?.(0);
111
- },
112
- };
113
-
114
- return {
115
- process: proc,
116
- written,
117
- push,
118
- autoRespond(pred, result) {
119
- autoResponders.push({ pred, result });
120
- },
121
- waitForWrites(n) {
122
- if (written.length >= n) return Promise.resolve();
123
- return new Promise<void>((resolve) => {
124
- const check = (): void => {
125
- if (written.length >= n) resolve();
126
- else writeListeners.push(check);
127
- };
128
- writeListeners.push(check);
129
- });
130
- },
131
- };
132
- }
133
-
134
26
  const TEST_PROFILE: ProfileV1 = {
135
27
  id: "bypass-inject-fixture",
136
28
  version: "0.0.0-test",
@@ -168,11 +60,18 @@ const TEST_PROFILE: ProfileV1 = {
168
60
  };
169
61
 
170
62
  describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
171
- let fake: FakeBrowser;
63
+ let pipe: FakePipe;
172
64
  let session: Session | undefined;
173
65
 
174
66
  beforeEach(() => {
175
- fake = makeFakeBrowser();
67
+ pipe = makeFakePipe({
68
+ responders: {
69
+ // Tests below assert on identifier shape — keep these stable.
70
+ "Target.createTarget": () => ({ targetId: "page-target-1" }),
71
+ "Target.attachToTarget": () => ({ sessionId: "session-1" }),
72
+ "Page.addScriptToEvaluateOnNewDocument": () => ({ identifier: "should-never-fire" }),
73
+ },
74
+ });
176
75
  session = undefined;
177
76
  });
178
77
 
@@ -186,47 +85,42 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
186
85
  }
187
86
  });
188
87
 
189
- it("with bypassInject:true — newPage() never sends Page.addScriptToEvaluateOnNewDocument", async () => {
88
+ it("with bypassInject:true — newPage() never sends Page.addScriptToEvaluateOnNewDocument and no Fetch.enable for inject", async () => {
190
89
  const matrix = deriveMatrix(TEST_PROFILE, "bypass-test");
191
90
  session = new Session({
192
- proc: fake.process,
91
+ proc: fakeChromiumProcess(pipe, { userDataDir: "/tmp/fake-mochi-test" }),
193
92
  matrix,
194
93
  seed: "bypass-test",
195
94
  bypassInject: true,
196
95
  });
197
96
 
198
- // Auto-respond to the small set of CDP calls Session/newPage drives:
199
- // Target.setAutoAttach (constructor), Target.createTarget, Target.attachToTarget,
200
- // and Page.enable. These are the *only* writes we expect.
201
- fake.autoRespond((m) => m === "Target.setAutoAttach", {});
202
- fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-1" });
203
- fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-1" });
204
- fake.autoRespond((m) => m === "Page.enable", {});
205
- fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
206
- fake.autoRespond((m) => m === "Page.removeScriptToEvaluateOnNewDocument", {});
207
- fake.autoRespond((m) => m === "Page.addScriptToEvaluateOnNewDocument", {
208
- identifier: "should-never-fire",
209
- });
210
-
211
97
  const page = await session.newPage();
212
98
  expect(page).toBeDefined();
99
+ // Allow the constructor's deferred init-injector promise to settle (it's
100
+ // a no-op in this case but the rejection-handler microtasks still queue).
101
+ await new Promise((r) => setTimeout(r, 5));
213
102
 
214
- const methods = fake.written
215
- .map((w) => w.method)
103
+ const methods = pipe.written
104
+ .map((w) => w.parsed.method)
216
105
  .filter((m): m is string => typeof m === "string");
217
106
  expect(methods).toContain("Target.createTarget");
218
107
  expect(methods).toContain("Target.attachToTarget");
219
108
  expect(methods).toContain("Page.enable");
220
- // The contract: ZERO addScriptToEvaluateOnNewDocument sends.
109
+ // Task 0266 contract: no Page.addScriptToEvaluateOnNewDocument under
110
+ // bypassInject — the session-level injector is also short-circuited.
221
111
  expect(methods).not.toContain("Page.addScriptToEvaluateOnNewDocument");
222
- // And no Runtime.evaluate either (worker injection is also bypassed).
112
+ // No Runtime.evaluate worker injection is also bypassed.
223
113
  expect(methods).not.toContain("Runtime.evaluate");
114
+ // No proxy creds, no payload to deliver — the unified injector
115
+ // short-circuits and does NOT send Fetch.enable. Capture flow keeps a
116
+ // zero-extra-protocol-surface posture.
117
+ expect(methods).not.toContain("Fetch.enable");
224
118
  });
225
119
 
226
120
  it("with bypassInject:true — _internalPayload() is null", () => {
227
121
  const matrix = deriveMatrix(TEST_PROFILE, "null-payload");
228
122
  session = new Session({
229
- proc: fake.process,
123
+ proc: fakeChromiumProcess(pipe, { userDataDir: "/tmp/fake-mochi-test" }),
230
124
  matrix,
231
125
  seed: "null-payload",
232
126
  bypassInject: true,
@@ -235,10 +129,20 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
235
129
  expect(session._internalBypassInject()).toBe(true);
236
130
  });
237
131
 
238
- it("with bypassInject omitted — _internalPayload() is non-null and newPage installs the inject script", async () => {
132
+ it("with bypassInject omitted — Session installs the unified Fetch-domain injector instead of Page.addScriptToEvaluateOnNewDocument (task 0266)", async () => {
133
+ // Override the script identifier for this test — it asserts that the
134
+ // dual-mechanism `addScriptToEvaluateOnNewDocument` call (commit 2 of
135
+ // 0266) carries the wrapped matrix payload.
136
+ const localPipe = makeFakePipe({
137
+ responders: {
138
+ "Target.createTarget": () => ({ targetId: "page-target-2" }),
139
+ "Target.attachToTarget": () => ({ sessionId: "session-2" }),
140
+ "Page.addScriptToEvaluateOnNewDocument": () => ({ identifier: "inj-1" }),
141
+ },
142
+ });
239
143
  const matrix = deriveMatrix(TEST_PROFILE, "default-inject");
240
144
  session = new Session({
241
- proc: fake.process,
145
+ proc: fakeChromiumProcess(localPipe, { userDataDir: "/tmp/fake-mochi-test" }),
242
146
  matrix,
243
147
  seed: "default-inject",
244
148
  });
@@ -247,34 +151,46 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
247
151
  expect(payload).not.toBeNull();
248
152
  expect(payload?.code.length ?? 0).toBeGreaterThan(0);
249
153
 
250
- fake.autoRespond((m) => m === "Target.setAutoAttach", {});
251
- fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-2" });
252
- fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-2" });
253
- fake.autoRespond((m) => m === "Page.enable", {});
254
- // Task 0262: Session sends Emulation.setTimezoneOverride per page.
255
- fake.autoRespond((m) => m === "Emulation.setTimezoneOverride", {});
256
- // Task 0255: Session now sends Network.setUserAgentOverride per page.
257
- fake.autoRespond((m) => m === "Network.setUserAgentOverride", {});
258
- fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
259
- fake.autoRespond((m) => m === "Page.removeScriptToEvaluateOnNewDocument", {});
260
- fake.autoRespond((m) => m === "Page.addScriptToEvaluateOnNewDocument", {
261
- identifier: "inj-1",
262
- });
263
-
264
154
  const page = await session.newPage();
265
155
  expect(page).toBeDefined();
156
+ // Yield once so the deferred installInitInjector promise settles.
157
+ await new Promise((r) => setTimeout(r, 10));
266
158
 
267
- const methods = fake.written
268
- .map((w) => w.method)
159
+ const methods = localPipe.written
160
+ .map((w) => w.parsed.method)
269
161
  .filter((m): m is string => typeof m === "string");
270
- // Default behavior: the inject script IS installed.
162
+ // Task 0266 dual-mechanism: Session uses BOTH Fetch.fulfillRequest body
163
+ // splice (HTTP/HTTPS Document responses — closes source-attribution
164
+ // leak) AND Page.addScriptToEvaluateOnNewDocument (per-page fallback for
165
+ // about:blank / data: / blob: where Fetch domain can't intercept). The
166
+ // wrapped payload's `__mochi_inject_marker` early-return prevents
167
+ // double-execution when both fire on the same realm.
271
168
  expect(methods).toContain("Page.addScriptToEvaluateOnNewDocument");
272
- // And the params carry the compiled payload code.
273
- const installCall = fake.written.find(
274
- (w) => w.method === "Page.addScriptToEvaluateOnNewDocument",
169
+ const addScriptCall = localPipe.written.find(
170
+ (w) => w.parsed.method === "Page.addScriptToEvaluateOnNewDocument",
275
171
  );
276
- const params = installCall?.params as { source?: string; runImmediately?: boolean } | undefined;
277
- expect(params?.source).toBe(payload?.code);
278
- expect(params?.runImmediately).toBe(true);
172
+ const addScriptParams = addScriptCall?.parsed.params as
173
+ | { source?: string; runImmediately?: boolean; worldName?: string }
174
+ | undefined;
175
+ expect(addScriptParams?.runImmediately).toBe(true);
176
+ expect(addScriptParams?.worldName).toBe(""); // PLAN.md §8.4 — main world
177
+ expect(addScriptParams?.source).toContain("__mochi_inject_marker"); // idempotency guard
178
+ // Fetch.enable is sent ONCE on session construction with the
179
+ // Document-first patterns. Auth is off because no proxyAuth was set.
180
+ expect(methods).toContain("Fetch.enable");
181
+ const enableCall = localPipe.written.find((w) => w.parsed.method === "Fetch.enable");
182
+ const enableParams = enableCall?.parsed.params as
183
+ | {
184
+ handleAuthRequests?: boolean;
185
+ patterns?: { urlPattern?: string; resourceType?: string }[];
186
+ }
187
+ | undefined;
188
+ expect(enableParams?.handleAuthRequests).toBe(false);
189
+ expect(enableParams?.patterns).toBeDefined();
190
+ expect(enableParams?.patterns?.[0]).toEqual({
191
+ urlPattern: "*",
192
+ resourceType: "Document",
193
+ });
194
+ expect(enableParams?.patterns?.[1]).toEqual({ urlPattern: "*" });
279
195
  });
280
196
  });
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Unit tests for the Page DX cluster (task 0257):
3
+ * - `Page.localStorage.{get,set}` → DOMStorage.getDOMStorageItems /
4
+ * DOMStorage.setDOMStorageItem.
5
+ * - `Page.sessionStorage.{get,set}` → same shape, `isLocalStorage: false`.
6
+ * - `Page.grantAllPermissions()` → Browser.grantPermissions with the
7
+ * full descriptor list.
8
+ *
9
+ * Driven against a hand-rolled fake CDP transport — same fixture pattern as
10
+ * the cookies-jar tests, kept inline here so each test file stands alone.
11
+ *
12
+ * @see tasks/0257-dx-cluster-cookies-storage-permissions.md
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
16
+ import { MessageRouter } from "../cdp/router";
17
+ import type { PipeReader, PipeWriter } from "../cdp/transport";
18
+ import { ALL_BROWSER_PERMISSIONS, Page } from "../page";
19
+
20
+ interface FakeBrowser {
21
+ reader: PipeReader;
22
+ writer: PipeWriter;
23
+ written: Array<{ id?: number; method?: string; params?: unknown; sessionId?: string }>;
24
+ push(obj: unknown): void;
25
+ autoRespond(methodPredicate: (m: string) => boolean, result: unknown): void;
26
+ close(): void;
27
+ }
28
+
29
+ function makeFakeBrowser(): FakeBrowser {
30
+ const written: FakeBrowser["written"] = [];
31
+ let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
32
+ const stream = new ReadableStream<Uint8Array>({
33
+ start(c) {
34
+ pumpController = c;
35
+ },
36
+ });
37
+ const enc = new TextEncoder();
38
+ const dec = new TextDecoder();
39
+ const autoResponders: Array<{ pred: (m: string) => boolean; result: unknown }> = [];
40
+
41
+ const reader: PipeReader = { getReader: () => stream.getReader() };
42
+
43
+ const push = (obj: unknown): void => {
44
+ const bytes = enc.encode(JSON.stringify(obj));
45
+ const out = new Uint8Array(bytes.length + 1);
46
+ out.set(bytes, 0);
47
+ out[bytes.length] = 0;
48
+ pumpController?.enqueue(out);
49
+ };
50
+
51
+ const writer: PipeWriter = {
52
+ write: (chunk) => {
53
+ const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
54
+ const json = dec.decode(chunk.subarray(0, last));
55
+ try {
56
+ const parsed = JSON.parse(json) as {
57
+ id?: number;
58
+ method?: string;
59
+ params?: unknown;
60
+ sessionId?: string;
61
+ };
62
+ written.push(parsed);
63
+ if (typeof parsed.method === "string" && typeof parsed.id === "number") {
64
+ const r = autoResponders.find((a) => a.pred(parsed.method ?? ""));
65
+ if (r) {
66
+ queueMicrotask(() => push({ id: parsed.id, result: r.result }));
67
+ }
68
+ }
69
+ } catch {
70
+ // ignore
71
+ }
72
+ },
73
+ flush: () => undefined,
74
+ end: () => undefined,
75
+ };
76
+
77
+ return {
78
+ reader,
79
+ writer,
80
+ written,
81
+ push,
82
+ autoRespond(pred, result) {
83
+ autoResponders.push({ pred, result });
84
+ },
85
+ close() {
86
+ try {
87
+ pumpController?.close();
88
+ } catch {
89
+ // ignore
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Auto-respond to the DOM-resolution chain `resolveOrigin` triggers. Two CDP
97
+ * calls fire:
98
+ * 1. `DOM.getDocument` → returns a synthetic root with a `backendNodeId`.
99
+ * 2. `DOM.resolveNode({ backendNodeId })` → returns `{ object: { objectId } }`.
100
+ * 3. `Runtime.callFunctionOn` → returns `{ result: { value: <origin> } }`.
101
+ *
102
+ * Used for the default-origin path; tests passing an explicit `origin` skip
103
+ * this fixture.
104
+ */
105
+ function wireOriginResolver(fake: FakeBrowser, origin: string): void {
106
+ fake.autoRespond((m) => m === "DOM.getDocument", {
107
+ root: { nodeId: 1, backendNodeId: 100 },
108
+ });
109
+ fake.autoRespond((m) => m === "DOM.resolveNode", {
110
+ object: { objectId: "doc-obj-1" },
111
+ });
112
+ fake.autoRespond((m) => m === "Runtime.callFunctionOn", {
113
+ result: { value: origin },
114
+ });
115
+ }
116
+
117
+ describe("Page.localStorage / Page.sessionStorage (task 0257)", () => {
118
+ let fake: FakeBrowser;
119
+ let router: MessageRouter;
120
+ let page: Page;
121
+
122
+ beforeEach(() => {
123
+ fake = makeFakeBrowser();
124
+ router = new MessageRouter(fake.reader, fake.writer);
125
+ router.start();
126
+ page = new Page({
127
+ router,
128
+ targetId: "page-target",
129
+ sessionId: "page-session",
130
+ initialUrl: "https://example.com/",
131
+ });
132
+ });
133
+
134
+ afterEach(async () => {
135
+ await router.close();
136
+ fake.close();
137
+ });
138
+
139
+ it("localStorage.get() sends DOMStorage.getDOMStorageItems with isLocalStorage:true", async () => {
140
+ fake.autoRespond((m) => m === "DOMStorage.getDOMStorageItems", {
141
+ entries: [
142
+ ["foo", "bar"],
143
+ ["baz", "qux"],
144
+ ],
145
+ });
146
+
147
+ const items = await page.localStorage.get({ origin: "https://example.com" });
148
+ expect(items).toEqual({ foo: "bar", baz: "qux" });
149
+
150
+ const call = fake.written.find((w) => w.method === "DOMStorage.getDOMStorageItems");
151
+ expect(call).toBeDefined();
152
+ expect(call?.params).toEqual({
153
+ storageId: { securityOrigin: "https://example.com", isLocalStorage: true },
154
+ });
155
+ });
156
+
157
+ it("localStorage.get() defaults origin to current page origin", async () => {
158
+ wireOriginResolver(fake, "https://defaulted.test");
159
+ fake.autoRespond((m) => m === "DOMStorage.getDOMStorageItems", { entries: [] });
160
+ await page.localStorage.get();
161
+ const call = fake.written.find((w) => w.method === "DOMStorage.getDOMStorageItems");
162
+ expect(call?.params).toEqual({
163
+ storageId: { securityOrigin: "https://defaulted.test", isLocalStorage: true },
164
+ });
165
+ });
166
+
167
+ it("localStorage.set() fans out one DOMStorage.setDOMStorageItem per key", async () => {
168
+ fake.autoRespond((m) => m === "DOMStorage.setDOMStorageItem", {});
169
+ await page.localStorage.set({ foo: "bar", baz: "qux" }, { origin: "https://example.com" });
170
+ const calls = fake.written.filter((w) => w.method === "DOMStorage.setDOMStorageItem");
171
+ expect(calls.length).toBe(2);
172
+ expect(calls[0]?.params).toEqual({
173
+ storageId: { securityOrigin: "https://example.com", isLocalStorage: true },
174
+ key: "foo",
175
+ value: "bar",
176
+ });
177
+ expect(calls[1]?.params).toEqual({
178
+ storageId: { securityOrigin: "https://example.com", isLocalStorage: true },
179
+ key: "baz",
180
+ value: "qux",
181
+ });
182
+ });
183
+
184
+ it("sessionStorage.get() flips isLocalStorage to false", async () => {
185
+ fake.autoRespond((m) => m === "DOMStorage.getDOMStorageItems", {
186
+ entries: [["k", "v"]],
187
+ });
188
+ const items = await page.sessionStorage.get({ origin: "https://example.com" });
189
+ expect(items).toEqual({ k: "v" });
190
+ const call = fake.written.find((w) => w.method === "DOMStorage.getDOMStorageItems");
191
+ expect(call?.params).toEqual({
192
+ storageId: { securityOrigin: "https://example.com", isLocalStorage: false },
193
+ });
194
+ });
195
+
196
+ it("sessionStorage.set() also flips isLocalStorage to false", async () => {
197
+ fake.autoRespond((m) => m === "DOMStorage.setDOMStorageItem", {});
198
+ await page.sessionStorage.set({ a: "1" }, { origin: "https://example.com" });
199
+ const call = fake.written.find((w) => w.method === "DOMStorage.setDOMStorageItem");
200
+ expect(call?.params).toEqual({
201
+ storageId: { securityOrigin: "https://example.com", isLocalStorage: false },
202
+ key: "a",
203
+ value: "1",
204
+ });
205
+ });
206
+
207
+ it("localStorage.get() throws when origin defaults to opaque about:blank", async () => {
208
+ // resolveOrigin returns "" or "null" for opaque origins. Wire that.
209
+ fake.autoRespond((m) => m === "DOM.getDocument", {
210
+ root: { nodeId: 1, backendNodeId: 100 },
211
+ });
212
+ fake.autoRespond((m) => m === "DOM.resolveNode", {
213
+ object: { objectId: "doc-obj-1" },
214
+ });
215
+ fake.autoRespond((m) => m === "Runtime.callFunctionOn", { result: { value: "null" } });
216
+ let threw = false;
217
+ try {
218
+ await page.localStorage.get();
219
+ } catch (err) {
220
+ threw = true;
221
+ expect(String(err)).toContain("opaque");
222
+ }
223
+ expect(threw).toBe(true);
224
+ });
225
+ });
226
+
227
+ describe("Page.grantAllPermissions (task 0257)", () => {
228
+ let fake: FakeBrowser;
229
+ let router: MessageRouter;
230
+ let page: Page;
231
+
232
+ beforeEach(() => {
233
+ fake = makeFakeBrowser();
234
+ router = new MessageRouter(fake.reader, fake.writer);
235
+ router.start();
236
+ page = new Page({
237
+ router,
238
+ targetId: "page-target",
239
+ sessionId: "page-session",
240
+ initialUrl: "https://example.com/",
241
+ });
242
+ });
243
+
244
+ afterEach(async () => {
245
+ await router.close();
246
+ fake.close();
247
+ });
248
+
249
+ it("grantAllPermissions({ origin }) sends Browser.grantPermissions with the full list", async () => {
250
+ fake.autoRespond((m) => m === "Browser.grantPermissions", {});
251
+ await page.grantAllPermissions({ origin: "https://example.com" });
252
+ const call = fake.written.find((w) => w.method === "Browser.grantPermissions");
253
+ expect(call).toBeDefined();
254
+ expect(call?.params).toEqual({
255
+ permissions: [...ALL_BROWSER_PERMISSIONS],
256
+ origin: "https://example.com",
257
+ });
258
+ });
259
+
260
+ it("grantAllPermissions({ origin }) routes to ROOT browser target (no sessionId)", async () => {
261
+ fake.autoRespond((m) => m === "Browser.grantPermissions", {});
262
+ await page.grantAllPermissions({ origin: "https://example.com" });
263
+ const call = fake.written.find((w) => w.method === "Browser.grantPermissions");
264
+ // The router omits sessionId when unset → routes to root target. The
265
+ // fake captures a missing/undefined sessionId.
266
+ expect(call?.sessionId).toBeUndefined();
267
+ });
268
+
269
+ it("grantAllPermissions() defaults origin to the page's main-frame origin", async () => {
270
+ wireOriginResolver(fake, "https://granted.test");
271
+ fake.autoRespond((m) => m === "Browser.grantPermissions", {});
272
+ await page.grantAllPermissions();
273
+ const call = fake.written.find((w) => w.method === "Browser.grantPermissions");
274
+ expect(call?.params).toEqual({
275
+ permissions: [...ALL_BROWSER_PERMISSIONS],
276
+ origin: "https://granted.test",
277
+ });
278
+ });
279
+
280
+ it("ALL_BROWSER_PERMISSIONS contains canonical descriptors", () => {
281
+ // Sanity check: a couple of well-known permissions must be present so
282
+ // the conformance test catches a typo if someone hand-edits the list.
283
+ expect(ALL_BROWSER_PERMISSIONS).toContain("geolocation");
284
+ expect(ALL_BROWSER_PERMISSIONS).toContain("notifications");
285
+ expect(ALL_BROWSER_PERMISSIONS).toContain("audioCapture");
286
+ expect(ALL_BROWSER_PERMISSIONS).toContain("videoCapture");
287
+ expect(ALL_BROWSER_PERMISSIONS).toContain("clipboardReadWrite");
288
+ expect(ALL_BROWSER_PERMISSIONS).toContain("midiSysex");
289
+ // No duplicates.
290
+ expect(new Set(ALL_BROWSER_PERMISSIONS).size).toBe(ALL_BROWSER_PERMISSIONS.length);
291
+ });
292
+ });