@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.
- package/README.md +19 -10
- package/package.json +4 -4
- package/src/__tests__/cookies-jar.test.ts +361 -0
- package/src/__tests__/default-profile.test.ts +181 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +245 -0
- package/src/__tests__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -164
- package/src/__tests__/page-dx-cluster.test.ts +292 -0
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proxy-auth.test.ts +22 -55
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/cdp/init-injector.ts +644 -0
- package/src/default-profile.ts +112 -0
- package/src/index.ts +33 -1
- package/src/launch.ts +199 -10
- package/src/linux-server.ts +157 -0
- package/src/page.ts +410 -8
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +367 -68
|
@@ -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`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
20
|
-
|
|
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
|
|
63
|
+
let pipe: FakePipe;
|
|
172
64
|
let session: Session | undefined;
|
|
173
65
|
|
|
174
66
|
beforeEach(() => {
|
|
175
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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 —
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
(w) => w.method === "Page.addScriptToEvaluateOnNewDocument",
|
|
169
|
+
const addScriptCall = localPipe.written.find(
|
|
170
|
+
(w) => w.parsed.method === "Page.addScriptToEvaluateOnNewDocument",
|
|
275
171
|
);
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
});
|