@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.
- 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__/geo-consistency.test.ts +277 -0
- package/src/__tests__/geo-probe.test.ts +415 -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 -162
- package/src/__tests__/integration.e2e.test.ts +24 -0
- 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/geo-consistency.ts +343 -0
- package/src/geo-probe.ts +603 -0
- package/src/index.ts +43 -1
- package/src/launch.ts +277 -17
- package/src/linux-server.ts +157 -0
- package/src/page.ts +420 -9
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +595 -78
|
@@ -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
|
|
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
|
-
|
|
135
|
+
pipe: FakePipe;
|
|
135
136
|
pushEvent(method: string, params: unknown): void;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
function makeRouter(): FakeRouter {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
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
|
-
|
|
147
|
+
pipe,
|
|
180
148
|
pushEvent(method: string, params: unknown): void {
|
|
181
|
-
|
|
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
|
|
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({
|
|
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
|
});
|