@mochi.js/core 0.2.2 → 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.
@@ -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
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Unit tests for the Linux-server environment auto-detection that drives
3
+ * `LaunchOptions.headlessMode` defaulting (task 0258).
4
+ *
5
+ * Two layers under test:
6
+ *
7
+ * 1. {@link detectLinuxServerEnv} — the pure classifier. Stub
8
+ * `(platform, env, uid, container probes)` and assert the
9
+ * `LinuxServerEnv` summary it returns.
10
+ * 2. {@link resolveHeadlessMode} — the resolution table that maps
11
+ * `(LaunchOptions, LinuxServerEnv)` → `"new" | "legacy" | "off"`. Order
12
+ * of precedence is load-bearing for the docs we ship; tests pin it.
13
+ *
14
+ * We DO NOT spawn Chromium here — the goal is to lock the decisions against
15
+ * regressions without taking the cost of a real launch. The flag-emit
16
+ * behaviour for the resolved mode is covered separately in
17
+ * `proc.test.ts` ("appends --headless=new when headless is true" et al.).
18
+ *
19
+ * @see packages/core/src/linux-server.ts
20
+ * @see packages/core/src/launch.ts (resolveHeadlessMode)
21
+ * @see tasks/0258 (Linux server env auto-detection)
22
+ */
23
+
24
+ import { describe, expect, it } from "bun:test";
25
+ import { resolveHeadlessMode } from "../launch";
26
+ import { detectLinuxServerEnv, type LinuxServerProbes } from "../linux-server";
27
+ import { buildChromiumArgs, type SpawnConfig } from "../proc";
28
+
29
+ const FAKE_BINARY = "/usr/bin/chromium-stub";
30
+ const FAKE_UDD = "/tmp/mochi-test-udd";
31
+
32
+ function probes(overrides: Partial<LinuxServerProbes> = {}): LinuxServerProbes {
33
+ return {
34
+ platform: "linux",
35
+ display: undefined,
36
+ waylandDisplay: undefined,
37
+ uid: 1000,
38
+ hasDockerEnvFile: false,
39
+ cgroup: undefined,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe("detectLinuxServerEnv — server-no-display classifier (task 0258)", () => {
45
+ it("flags Linux + no DISPLAY + no WAYLAND_DISPLAY as serverNoDisplay=true", () => {
46
+ const env = detectLinuxServerEnv(probes());
47
+ expect(env.serverNoDisplay).toBe(true);
48
+ });
49
+
50
+ it("clears serverNoDisplay when DISPLAY is set (X11 dev workstation)", () => {
51
+ const env = detectLinuxServerEnv(probes({ display: ":0" }));
52
+ expect(env.serverNoDisplay).toBe(false);
53
+ });
54
+
55
+ it("clears serverNoDisplay when WAYLAND_DISPLAY is set (Wayland session)", () => {
56
+ const env = detectLinuxServerEnv(probes({ waylandDisplay: "wayland-0" }));
57
+ expect(env.serverNoDisplay).toBe(false);
58
+ });
59
+
60
+ it("treats empty-string DISPLAY as 'no display' (matches Chromium)", () => {
61
+ // An empty DISPLAY value means no usable X server; Chromium itself rejects
62
+ // the connection. Mirror that — an empty string must not gate us out of
63
+ // headless defaulting.
64
+ const env = detectLinuxServerEnv(probes({ display: "" }));
65
+ expect(env.serverNoDisplay).toBe(true);
66
+ });
67
+
68
+ it("never flags serverNoDisplay on darwin / win32 (rule is Linux-only)", () => {
69
+ expect(detectLinuxServerEnv(probes({ platform: "darwin" })).serverNoDisplay).toBe(false);
70
+ expect(detectLinuxServerEnv(probes({ platform: "win32" })).serverNoDisplay).toBe(false);
71
+ });
72
+
73
+ it("flags root=true when uid === 0 on Linux (orthogonal axis)", () => {
74
+ const env = detectLinuxServerEnv(probes({ uid: 0 }));
75
+ expect(env.root).toBe(true);
76
+ expect(env.serverNoDisplay).toBe(true);
77
+ });
78
+
79
+ it("clears root when uid !== 0", () => {
80
+ expect(detectLinuxServerEnv(probes({ uid: 1000 })).root).toBe(false);
81
+ expect(detectLinuxServerEnv(probes({ uid: undefined })).root).toBe(false);
82
+ });
83
+
84
+ it("never flags root on non-Linux (uid 0 on macOS root shell is not the same axis)", () => {
85
+ // The auto-`--no-sandbox` fallback in proc.ts is Linux-specific; the
86
+ // classifier mirrors that.
87
+ expect(detectLinuxServerEnv(probes({ platform: "darwin", uid: 0 })).root).toBe(false);
88
+ });
89
+
90
+ it("flags container=true when /.dockerenv is present", () => {
91
+ const env = detectLinuxServerEnv(probes({ hasDockerEnvFile: true }));
92
+ expect(env.container).toBe(true);
93
+ });
94
+
95
+ it("flags container=true when /proc/1/cgroup mentions docker", () => {
96
+ const env = detectLinuxServerEnv(
97
+ probes({ cgroup: "12:devices:/docker/abc123\n11:freezer:/docker/abc123\n" }),
98
+ );
99
+ expect(env.container).toBe(true);
100
+ });
101
+
102
+ it("flags container=true when cgroup mentions containerd", () => {
103
+ const env = detectLinuxServerEnv(probes({ cgroup: "0::/system.slice/containerd.service\n" }));
104
+ expect(env.container).toBe(true);
105
+ });
106
+
107
+ it("flags container=true when cgroup mentions kubepods (Kubernetes)", () => {
108
+ const env = detectLinuxServerEnv(
109
+ probes({ cgroup: "0::/kubepods.slice/kubepods-pod123.slice/\n" }),
110
+ );
111
+ expect(env.container).toBe(true);
112
+ });
113
+
114
+ it("clears container when neither indicator hits", () => {
115
+ expect(
116
+ detectLinuxServerEnv(probes({ cgroup: "0::/user.slice/user-1000.slice\n" })).container,
117
+ ).toBe(false);
118
+ expect(detectLinuxServerEnv(probes()).container).toBe(false);
119
+ });
120
+
121
+ it("rationale string surfaces every probed axis for debug logging", () => {
122
+ const env = detectLinuxServerEnv(
123
+ probes({ display: ":0", uid: 0, hasDockerEnvFile: true, waylandDisplay: undefined }),
124
+ );
125
+ expect(env.rationale).toContain("platform=linux");
126
+ expect(env.rationale).toContain("display=:0");
127
+ expect(env.rationale).toContain("uid=0");
128
+ expect(env.rationale).toContain("container=true");
129
+ expect(env.rationale).toContain("serverNoDisplay=false");
130
+ });
131
+ });
132
+
133
+ describe("resolveHeadlessMode — precedence table (task 0258)", () => {
134
+ const SERVER_ENV = detectLinuxServerEnv(probes());
135
+ const DEV_ENV = detectLinuxServerEnv(probes({ display: ":0" }));
136
+
137
+ it("explicit headlessMode='new' wins on a dev workstation", () => {
138
+ expect(resolveHeadlessMode({ headlessMode: "new" }, DEV_ENV)).toBe("new");
139
+ });
140
+
141
+ it("explicit headlessMode='legacy' wins on a server", () => {
142
+ expect(resolveHeadlessMode({ headlessMode: "legacy" }, SERVER_ENV)).toBe("legacy");
143
+ });
144
+
145
+ it("explicit headlessMode='off' wins on a server (caller knows what they want)", () => {
146
+ expect(resolveHeadlessMode({ headlessMode: "off" }, SERVER_ENV)).toBe("off");
147
+ });
148
+
149
+ it("legacy headless: true maps to 'new' regardless of env", () => {
150
+ expect(resolveHeadlessMode({ headless: true }, DEV_ENV)).toBe("new");
151
+ expect(resolveHeadlessMode({ headless: true }, SERVER_ENV)).toBe("new");
152
+ });
153
+
154
+ it("legacy headless: false maps to 'off' regardless of env", () => {
155
+ // Even on a server, an explicit `headless: false` must be honored —
156
+ // we are not in the business of ignoring user input. The user will
157
+ // crash, but they asked for it.
158
+ expect(resolveHeadlessMode({ headless: false }, SERVER_ENV)).toBe("off");
159
+ expect(resolveHeadlessMode({ headless: false }, DEV_ENV)).toBe("off");
160
+ });
161
+
162
+ it("env default: server-no-display → 'new' (the task 0258 fix)", () => {
163
+ expect(resolveHeadlessMode({}, SERVER_ENV)).toBe("new");
164
+ });
165
+
166
+ it("env default: dev workstation with DISPLAY → 'off' (run headful)", () => {
167
+ expect(resolveHeadlessMode({}, DEV_ENV)).toBe("off");
168
+ });
169
+
170
+ it("env default: macOS / Windows → 'off' (no Linux-only fallback)", () => {
171
+ const macEnv = detectLinuxServerEnv(probes({ platform: "darwin" }));
172
+ const winEnv = detectLinuxServerEnv(probes({ platform: "win32" }));
173
+ expect(resolveHeadlessMode({}, macEnv)).toBe("off");
174
+ expect(resolveHeadlessMode({}, winEnv)).toBe("off");
175
+ });
176
+
177
+ it("headlessMode supersedes a contradicting legacy headless flag", () => {
178
+ expect(resolveHeadlessMode({ headlessMode: "off", headless: true }, SERVER_ENV)).toBe("off");
179
+ expect(resolveHeadlessMode({ headlessMode: "new", headless: false }, DEV_ENV)).toBe("new");
180
+ });
181
+
182
+ it("server + root still resolves to 'new' (orthogonal axes — task 0258 §detection)", () => {
183
+ // The root/no-sandbox auto-flag is owned by proc.ts, not the headless
184
+ // resolver. This test pins that resolveHeadlessMode does NOT bake root
185
+ // into its decision — the user could be running the new-headless flow
186
+ // as root inside a container without that influencing the mode.
187
+ const rootServer = detectLinuxServerEnv(probes({ uid: 0 }));
188
+ expect(resolveHeadlessMode({}, rootServer)).toBe("new");
189
+ });
190
+
191
+ it("container + DISPLAY (rare but valid) → 'off' (developer in a devcontainer)", () => {
192
+ const devcontainer = detectLinuxServerEnv(
193
+ probes({ display: ":0", hasDockerEnvFile: true, cgroup: "0::/docker/abc\n" }),
194
+ );
195
+ expect(devcontainer.container).toBe(true);
196
+ expect(devcontainer.serverNoDisplay).toBe(false);
197
+ expect(resolveHeadlessMode({}, devcontainer)).toBe("off");
198
+ });
199
+ });
200
+
201
+ describe("buildChromiumArgs — headlessMode dispatch (task 0258)", () => {
202
+ function baseCfg(overrides: Partial<SpawnConfig> = {}): SpawnConfig {
203
+ return { binary: FAKE_BINARY, headless: false, ...overrides };
204
+ }
205
+
206
+ it("headlessMode='new' emits --headless=new", () => {
207
+ const args = buildChromiumArgs(baseCfg({ headlessMode: "new" }), FAKE_UDD, undefined);
208
+ expect(args).toContain("--headless=new");
209
+ expect(args).not.toContain("--headless");
210
+ });
211
+
212
+ it("headlessMode='legacy' emits bare --headless", () => {
213
+ const args = buildChromiumArgs(baseCfg({ headlessMode: "legacy" }), FAKE_UDD, undefined);
214
+ expect(args).toContain("--headless");
215
+ expect(args).not.toContain("--headless=new");
216
+ });
217
+
218
+ it("headlessMode='off' emits no headless flag at all", () => {
219
+ const args = buildChromiumArgs(baseCfg({ headlessMode: "off" }), FAKE_UDD, undefined);
220
+ expect(args.some((a) => a === "--headless" || a.startsWith("--headless="))).toBe(false);
221
+ });
222
+
223
+ it("headlessMode='new' supersedes headless: false", () => {
224
+ // The launcher resolves the mode; by the time we reach buildChromiumArgs,
225
+ // the resolved mode is canonical.
226
+ const args = buildChromiumArgs(
227
+ baseCfg({ headless: false, headlessMode: "new" }),
228
+ FAKE_UDD,
229
+ undefined,
230
+ );
231
+ expect(args).toContain("--headless=new");
232
+ });
233
+
234
+ it("legacy: headless: true with no headlessMode falls back to --headless=new", () => {
235
+ const args = buildChromiumArgs(baseCfg({ headless: true }), FAKE_UDD, undefined);
236
+ expect(args).toContain("--headless=new");
237
+ });
238
+
239
+ it("legacy: headless: false with no headlessMode emits no headless flag", () => {
240
+ const args = buildChromiumArgs(baseCfg({ headless: false }), FAKE_UDD, undefined);
241
+ expect(args.some((a) => a === "--headless" || a.startsWith("--headless="))).toBe(false);
242
+ });
243
+ });
@@ -5,7 +5,8 @@
5
5
  * with-auth/no-auth × edge cases (missing port, IPv6 host, percent-encoded
6
6
  * creds, empty password).
7
7
  *
8
- * `installProxyAuth`: drives a fake CDP router. Verifies:
8
+ * `installProxyAuth`: drives a fake CDP router via the shared
9
+ * `tests/helpers/cdp-fixture.ts` helper. Verifies:
9
10
  * - `Fetch.enable` is sent with `handleAuthRequests: true, patterns: [{ urlPattern: "*" }]`.
10
11
  * - `Fetch.authRequired` events trigger `Fetch.continueWithAuth` carrying
11
12
  * the configured creds.
@@ -16,8 +17,8 @@
16
17
  */
17
18
 
18
19
  import { describe, expect, it } from "bun:test";
20
+ import { type FakePipe, makeFakePipe } from "../../../../tests/helpers/cdp-fixture";
19
21
  import { MessageRouter } from "../cdp/router";
20
- import type { PipeReader, PipeWriter } from "../cdp/transport";
21
22
  import { installProxyAuth, parseProxyUrl } from "../proxy-auth";
22
23
 
23
24
  describe("parseProxyUrl", () => {
@@ -131,69 +132,35 @@ describe("parseProxyUrl", () => {
131
132
 
132
133
  interface FakeRouter {
133
134
  router: MessageRouter;
134
- written: { method: string; params?: unknown }[];
135
+ pipe: FakePipe;
135
136
  pushEvent(method: string, params: unknown): void;
136
137
  }
137
138
 
138
139
  function makeRouter(): FakeRouter {
139
- const written: { method: string; params?: unknown }[] = [];
140
- let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
141
- const stream = new ReadableStream<Uint8Array>({
142
- start(c) {
143
- pumpController = c;
144
- },
145
- });
146
- const reader: PipeReader = {
147
- getReader: () => stream.getReader(),
148
- };
149
- const writer: PipeWriter = {
150
- write: (chunk: Uint8Array) => {
151
- const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
152
- const json = new TextDecoder().decode(chunk.subarray(0, last));
153
- try {
154
- const obj = JSON.parse(json) as { id?: number; method: string; params?: unknown };
155
- written.push({ method: obj.method, params: obj.params });
156
- // Auto-resolve the request immediately so the await in
157
- // `installProxyAuth` doesn't hang.
158
- if (typeof obj.id === "number") {
159
- const reply = JSON.stringify({ id: obj.id, result: {} });
160
- const enc = new TextEncoder().encode(reply);
161
- const out = new Uint8Array(enc.length + 1);
162
- out.set(enc, 0);
163
- out[enc.length] = 0;
164
- pumpController?.enqueue(out);
165
- }
166
- } catch {
167
- // ignore
168
- }
169
- return chunk.byteLength;
170
- },
171
- flush: () => undefined,
172
- end: () => undefined,
173
- };
174
- const router = new MessageRouter(reader, writer);
140
+ // Default responders auto-answer Fetch.enable / Fetch.disable / etc with
141
+ // `{}` that's everything `installProxyAuth` waits on.
142
+ const pipe = makeFakePipe();
143
+ const router = new MessageRouter(pipe.reader, pipe.writer);
175
144
  router.start();
176
- const enc = new TextEncoder();
177
145
  return {
178
146
  router,
179
- written,
147
+ pipe,
180
148
  pushEvent(method: string, params: unknown): void {
181
- const bytes = enc.encode(JSON.stringify({ method, params }));
182
- const out = new Uint8Array(bytes.length + 1);
183
- out.set(bytes, 0);
184
- out[bytes.length] = 0;
185
- pumpController?.enqueue(out);
149
+ pipe.inject({ method, params });
186
150
  },
187
151
  };
188
152
  }
189
153
 
190
154
  describe("installProxyAuth", () => {
191
- it("sends Fetch.enable with handleAuthRequests:true and empty patterns", async () => {
155
+ it("sends Fetch.enable with handleAuthRequests:true and Document-first patterns (task 0266)", async () => {
192
156
  const f = makeRouter();
193
157
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
194
- const enable = f.written.find((c) => c.method === "Fetch.enable");
158
+ const enable = f.pipe.written.find((c) => c.parsed.method === "Fetch.enable");
195
159
  expect(enable).toBeDefined();
196
- expect(enable?.params).toEqual({ handleAuthRequests: true, patterns: [{ urlPattern: "*" }] });
160
+ expect(enable?.parsed.params).toEqual({
161
+ handleAuthRequests: true,
162
+ patterns: [{ urlPattern: "*", resourceType: "Document" }, { urlPattern: "*" }],
163
+ });
197
164
  await handle.dispose();
198
165
  await f.router.close();
199
166
  });
@@ -204,9 +171,9 @@ describe("installProxyAuth", () => {
204
171
  f.pushEvent("Fetch.authRequired", { requestId: "req-42", authChallenge: { source: "Proxy" } });
205
172
  // Allow microtasks + the writer push to flush.
206
173
  await new Promise((r) => setTimeout(r, 10));
207
- const reply = f.written.find((c) => c.method === "Fetch.continueWithAuth");
174
+ const reply = f.pipe.written.find((c) => c.parsed.method === "Fetch.continueWithAuth");
208
175
  expect(reply).toBeDefined();
209
- expect(reply?.params).toEqual({
176
+ expect(reply?.parsed.params).toEqual({
210
177
  requestId: "req-42",
211
178
  authChallengeResponse: {
212
179
  response: "ProvideCredentials",
@@ -223,9 +190,9 @@ describe("installProxyAuth", () => {
223
190
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
224
191
  f.pushEvent("Fetch.requestPaused", { requestId: "rp-1" });
225
192
  await new Promise((r) => setTimeout(r, 10));
226
- const reply = f.written.find((c) => c.method === "Fetch.continueRequest");
193
+ const reply = f.pipe.written.find((c) => c.parsed.method === "Fetch.continueRequest");
227
194
  expect(reply).toBeDefined();
228
- expect(reply?.params).toEqual({ requestId: "rp-1" });
195
+ expect(reply?.parsed.params).toEqual({ requestId: "rp-1" });
229
196
  await handle.dispose();
230
197
  await f.router.close();
231
198
  });
@@ -235,7 +202,7 @@ describe("installProxyAuth", () => {
235
202
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
236
203
  await handle.dispose();
237
204
  await handle.dispose();
238
- const disables = f.written.filter((c) => c.method === "Fetch.disable");
205
+ const disables = f.pipe.written.filter((c) => c.parsed.method === "Fetch.disable");
239
206
  expect(disables.length).toBe(1);
240
207
  await f.router.close();
241
208
  });
@@ -246,7 +213,7 @@ describe("installProxyAuth", () => {
246
213
  await handle.dispose();
247
214
  f.pushEvent("Fetch.authRequired", { requestId: "late" });
248
215
  await new Promise((r) => setTimeout(r, 10));
249
- const replies = f.written.filter((c) => c.method === "Fetch.continueWithAuth");
216
+ const replies = f.pipe.written.filter((c) => c.parsed.method === "Fetch.continueWithAuth");
250
217
  expect(replies.length).toBe(0);
251
218
  await f.router.close();
252
219
  });