@mochi.js/core 0.3.0 → 0.8.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 +5 -6
- package/src/__tests__/cookies-jar.test.ts +360 -0
- package/src/__tests__/default-profile.test.ts +179 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +244 -0
- package/src/__tests__/geo-consistency.test.ts +0 -1
- package/src/__tests__/geo-probe.test.ts +13 -13
- package/src/__tests__/init-injector.e2e.test.ts +143 -0
- package/src/__tests__/init-injector.test.ts +248 -0
- package/src/__tests__/inject.test.ts +80 -165
- package/src/__tests__/page-dx-cluster.test.ts +291 -0
- package/src/__tests__/piercing.test.ts +1 -1
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proc.test.ts +3 -3
- package/src/__tests__/proxy-auth.test.ts +22 -56
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/__tests__/window-size.e2e.test.ts +0 -1
- package/src/cdp/init-injector.ts +644 -0
- package/src/cdp/types.ts +0 -1
- package/src/default-profile.ts +110 -0
- package/src/geo-consistency.ts +0 -1
- package/src/geo-probe.ts +37 -32
- package/src/index.ts +33 -1
- package/src/launch.ts +225 -50
- package/src/linux-server.ts +157 -0
- package/src/page/element-handle.ts +0 -1
- package/src/page/piercing.ts +0 -1
- package/src/page/selector.ts +0 -1
- package/src/page.ts +429 -10
- package/src/proc.ts +52 -10
- package/src/proxy-auth.ts +25 -108
- package/src/session.ts +846 -182
- package/src/version.ts +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Page DX cluster:
|
|
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
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
15
|
+
import { MessageRouter } from "../cdp/router";
|
|
16
|
+
import type { PipeReader, PipeWriter } from "../cdp/transport";
|
|
17
|
+
import { ALL_BROWSER_PERMISSIONS, Page } from "../page";
|
|
18
|
+
|
|
19
|
+
interface FakeBrowser {
|
|
20
|
+
reader: PipeReader;
|
|
21
|
+
writer: PipeWriter;
|
|
22
|
+
written: Array<{ id?: number; method?: string; params?: unknown; sessionId?: string }>;
|
|
23
|
+
push(obj: unknown): void;
|
|
24
|
+
autoRespond(methodPredicate: (m: string) => boolean, result: unknown): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeFakeBrowser(): FakeBrowser {
|
|
29
|
+
const written: FakeBrowser["written"] = [];
|
|
30
|
+
let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
31
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
32
|
+
start(c) {
|
|
33
|
+
pumpController = c;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const enc = new TextEncoder();
|
|
37
|
+
const dec = new TextDecoder();
|
|
38
|
+
const autoResponders: Array<{ pred: (m: string) => boolean; result: unknown }> = [];
|
|
39
|
+
|
|
40
|
+
const reader: PipeReader = { getReader: () => stream.getReader() };
|
|
41
|
+
|
|
42
|
+
const push = (obj: unknown): void => {
|
|
43
|
+
const bytes = enc.encode(JSON.stringify(obj));
|
|
44
|
+
const out = new Uint8Array(bytes.length + 1);
|
|
45
|
+
out.set(bytes, 0);
|
|
46
|
+
out[bytes.length] = 0;
|
|
47
|
+
pumpController?.enqueue(out);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const writer: PipeWriter = {
|
|
51
|
+
write: (chunk) => {
|
|
52
|
+
const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
|
|
53
|
+
const json = dec.decode(chunk.subarray(0, last));
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(json) as {
|
|
56
|
+
id?: number;
|
|
57
|
+
method?: string;
|
|
58
|
+
params?: unknown;
|
|
59
|
+
sessionId?: string;
|
|
60
|
+
};
|
|
61
|
+
written.push(parsed);
|
|
62
|
+
if (typeof parsed.method === "string" && typeof parsed.id === "number") {
|
|
63
|
+
const r = autoResponders.find((a) => a.pred(parsed.method ?? ""));
|
|
64
|
+
if (r) {
|
|
65
|
+
queueMicrotask(() => push({ id: parsed.id, result: r.result }));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// ignore
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
flush: () => undefined,
|
|
73
|
+
end: () => undefined,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
reader,
|
|
78
|
+
writer,
|
|
79
|
+
written,
|
|
80
|
+
push,
|
|
81
|
+
autoRespond(pred, result) {
|
|
82
|
+
autoResponders.push({ pred, result });
|
|
83
|
+
},
|
|
84
|
+
close() {
|
|
85
|
+
try {
|
|
86
|
+
pumpController?.close();
|
|
87
|
+
} catch {
|
|
88
|
+
// ignore
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Auto-respond to the DOM-resolution chain `resolveOrigin` triggers. Two CDP
|
|
96
|
+
* calls fire:
|
|
97
|
+
* 1. `DOM.getDocument` → returns a synthetic root with a `backendNodeId`.
|
|
98
|
+
* 2. `DOM.resolveNode({ backendNodeId })` → returns `{ object: { objectId } }`.
|
|
99
|
+
* 3. `Runtime.callFunctionOn` → returns `{ result: { value: <origin> } }`.
|
|
100
|
+
*
|
|
101
|
+
* Used for the default-origin path; tests passing an explicit `origin` skip
|
|
102
|
+
* this fixture.
|
|
103
|
+
*/
|
|
104
|
+
function wireOriginResolver(fake: FakeBrowser, origin: string): void {
|
|
105
|
+
fake.autoRespond((m) => m === "DOM.getDocument", {
|
|
106
|
+
root: { nodeId: 1, backendNodeId: 100 },
|
|
107
|
+
});
|
|
108
|
+
fake.autoRespond((m) => m === "DOM.resolveNode", {
|
|
109
|
+
object: { objectId: "doc-obj-1" },
|
|
110
|
+
});
|
|
111
|
+
fake.autoRespond((m) => m === "Runtime.callFunctionOn", {
|
|
112
|
+
result: { value: origin },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("Page.localStorage / Page.sessionStorage", () => {
|
|
117
|
+
let fake: FakeBrowser;
|
|
118
|
+
let router: MessageRouter;
|
|
119
|
+
let page: Page;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
fake = makeFakeBrowser();
|
|
123
|
+
router = new MessageRouter(fake.reader, fake.writer);
|
|
124
|
+
router.start();
|
|
125
|
+
page = new Page({
|
|
126
|
+
router,
|
|
127
|
+
targetId: "page-target",
|
|
128
|
+
sessionId: "page-session",
|
|
129
|
+
initialUrl: "https://example.com/",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(async () => {
|
|
134
|
+
await router.close();
|
|
135
|
+
fake.close();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("localStorage.get() sends DOMStorage.getDOMStorageItems with isLocalStorage:true", async () => {
|
|
139
|
+
fake.autoRespond((m) => m === "DOMStorage.getDOMStorageItems", {
|
|
140
|
+
entries: [
|
|
141
|
+
["foo", "bar"],
|
|
142
|
+
["baz", "qux"],
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const items = await page.localStorage.get({ origin: "https://example.com" });
|
|
147
|
+
expect(items).toEqual({ foo: "bar", baz: "qux" });
|
|
148
|
+
|
|
149
|
+
const call = fake.written.find((w) => w.method === "DOMStorage.getDOMStorageItems");
|
|
150
|
+
expect(call).toBeDefined();
|
|
151
|
+
expect(call?.params).toEqual({
|
|
152
|
+
storageId: { securityOrigin: "https://example.com", isLocalStorage: true },
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("localStorage.get() defaults origin to current page origin", async () => {
|
|
157
|
+
wireOriginResolver(fake, "https://defaulted.test");
|
|
158
|
+
fake.autoRespond((m) => m === "DOMStorage.getDOMStorageItems", { entries: [] });
|
|
159
|
+
await page.localStorage.get();
|
|
160
|
+
const call = fake.written.find((w) => w.method === "DOMStorage.getDOMStorageItems");
|
|
161
|
+
expect(call?.params).toEqual({
|
|
162
|
+
storageId: { securityOrigin: "https://defaulted.test", isLocalStorage: true },
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("localStorage.set() fans out one DOMStorage.setDOMStorageItem per key", async () => {
|
|
167
|
+
fake.autoRespond((m) => m === "DOMStorage.setDOMStorageItem", {});
|
|
168
|
+
await page.localStorage.set({ foo: "bar", baz: "qux" }, { origin: "https://example.com" });
|
|
169
|
+
const calls = fake.written.filter((w) => w.method === "DOMStorage.setDOMStorageItem");
|
|
170
|
+
expect(calls.length).toBe(2);
|
|
171
|
+
expect(calls[0]?.params).toEqual({
|
|
172
|
+
storageId: { securityOrigin: "https://example.com", isLocalStorage: true },
|
|
173
|
+
key: "foo",
|
|
174
|
+
value: "bar",
|
|
175
|
+
});
|
|
176
|
+
expect(calls[1]?.params).toEqual({
|
|
177
|
+
storageId: { securityOrigin: "https://example.com", isLocalStorage: true },
|
|
178
|
+
key: "baz",
|
|
179
|
+
value: "qux",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("sessionStorage.get() flips isLocalStorage to false", async () => {
|
|
184
|
+
fake.autoRespond((m) => m === "DOMStorage.getDOMStorageItems", {
|
|
185
|
+
entries: [["k", "v"]],
|
|
186
|
+
});
|
|
187
|
+
const items = await page.sessionStorage.get({ origin: "https://example.com" });
|
|
188
|
+
expect(items).toEqual({ k: "v" });
|
|
189
|
+
const call = fake.written.find((w) => w.method === "DOMStorage.getDOMStorageItems");
|
|
190
|
+
expect(call?.params).toEqual({
|
|
191
|
+
storageId: { securityOrigin: "https://example.com", isLocalStorage: false },
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("sessionStorage.set() also flips isLocalStorage to false", async () => {
|
|
196
|
+
fake.autoRespond((m) => m === "DOMStorage.setDOMStorageItem", {});
|
|
197
|
+
await page.sessionStorage.set({ a: "1" }, { origin: "https://example.com" });
|
|
198
|
+
const call = fake.written.find((w) => w.method === "DOMStorage.setDOMStorageItem");
|
|
199
|
+
expect(call?.params).toEqual({
|
|
200
|
+
storageId: { securityOrigin: "https://example.com", isLocalStorage: false },
|
|
201
|
+
key: "a",
|
|
202
|
+
value: "1",
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("localStorage.get() throws when origin defaults to opaque about:blank", async () => {
|
|
207
|
+
// resolveOrigin returns "" or "null" for opaque origins. Wire that.
|
|
208
|
+
fake.autoRespond((m) => m === "DOM.getDocument", {
|
|
209
|
+
root: { nodeId: 1, backendNodeId: 100 },
|
|
210
|
+
});
|
|
211
|
+
fake.autoRespond((m) => m === "DOM.resolveNode", {
|
|
212
|
+
object: { objectId: "doc-obj-1" },
|
|
213
|
+
});
|
|
214
|
+
fake.autoRespond((m) => m === "Runtime.callFunctionOn", { result: { value: "null" } });
|
|
215
|
+
let threw = false;
|
|
216
|
+
try {
|
|
217
|
+
await page.localStorage.get();
|
|
218
|
+
} catch (err) {
|
|
219
|
+
threw = true;
|
|
220
|
+
expect(String(err)).toContain("opaque");
|
|
221
|
+
}
|
|
222
|
+
expect(threw).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("Page.grantAllPermissions", () => {
|
|
227
|
+
let fake: FakeBrowser;
|
|
228
|
+
let router: MessageRouter;
|
|
229
|
+
let page: Page;
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
fake = makeFakeBrowser();
|
|
233
|
+
router = new MessageRouter(fake.reader, fake.writer);
|
|
234
|
+
router.start();
|
|
235
|
+
page = new Page({
|
|
236
|
+
router,
|
|
237
|
+
targetId: "page-target",
|
|
238
|
+
sessionId: "page-session",
|
|
239
|
+
initialUrl: "https://example.com/",
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
afterEach(async () => {
|
|
244
|
+
await router.close();
|
|
245
|
+
fake.close();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("grantAllPermissions({ origin }) sends Browser.grantPermissions with the full list", async () => {
|
|
249
|
+
fake.autoRespond((m) => m === "Browser.grantPermissions", {});
|
|
250
|
+
await page.grantAllPermissions({ origin: "https://example.com" });
|
|
251
|
+
const call = fake.written.find((w) => w.method === "Browser.grantPermissions");
|
|
252
|
+
expect(call).toBeDefined();
|
|
253
|
+
expect(call?.params).toEqual({
|
|
254
|
+
permissions: [...ALL_BROWSER_PERMISSIONS],
|
|
255
|
+
origin: "https://example.com",
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("grantAllPermissions({ origin }) routes to ROOT browser target (no sessionId)", async () => {
|
|
260
|
+
fake.autoRespond((m) => m === "Browser.grantPermissions", {});
|
|
261
|
+
await page.grantAllPermissions({ origin: "https://example.com" });
|
|
262
|
+
const call = fake.written.find((w) => w.method === "Browser.grantPermissions");
|
|
263
|
+
// The router omits sessionId when unset → routes to root target. The
|
|
264
|
+
// fake captures a missing/undefined sessionId.
|
|
265
|
+
expect(call?.sessionId).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("grantAllPermissions() defaults origin to the page's main-frame origin", async () => {
|
|
269
|
+
wireOriginResolver(fake, "https://granted.test");
|
|
270
|
+
fake.autoRespond((m) => m === "Browser.grantPermissions", {});
|
|
271
|
+
await page.grantAllPermissions();
|
|
272
|
+
const call = fake.written.find((w) => w.method === "Browser.grantPermissions");
|
|
273
|
+
expect(call?.params).toEqual({
|
|
274
|
+
permissions: [...ALL_BROWSER_PERMISSIONS],
|
|
275
|
+
origin: "https://granted.test",
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("ALL_BROWSER_PERMISSIONS contains canonical descriptors", () => {
|
|
280
|
+
// Sanity check: a couple of well-known permissions must be present so
|
|
281
|
+
// the conformance test catches a typo if someone hand-edits the list.
|
|
282
|
+
expect(ALL_BROWSER_PERMISSIONS).toContain("geolocation");
|
|
283
|
+
expect(ALL_BROWSER_PERMISSIONS).toContain("notifications");
|
|
284
|
+
expect(ALL_BROWSER_PERMISSIONS).toContain("audioCapture");
|
|
285
|
+
expect(ALL_BROWSER_PERMISSIONS).toContain("videoCapture");
|
|
286
|
+
expect(ALL_BROWSER_PERMISSIONS).toContain("clipboardReadWrite");
|
|
287
|
+
expect(ALL_BROWSER_PERMISSIONS).toContain("midiSysex");
|
|
288
|
+
// No duplicates.
|
|
289
|
+
expect(new Set(ALL_BROWSER_PERMISSIONS).size).toBe(ALL_BROWSER_PERMISSIONS.length);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* (`packages/core/src/page/piercing.ts`). Drives a hand-crafted
|
|
4
4
|
* `PierceDomNode` tree that mirrors the CDP `DOM.getDocument({ depth:-1,
|
|
5
5
|
* pierce:true })` shape — including a closed-shadow-rooted iframe, which is
|
|
6
|
-
* the whole point of
|
|
6
|
+
* the whole point of
|
|
7
7
|
*
|
|
8
8
|
* The findPiercingMatches output is verified by `backendNodeId`, the only
|
|
9
9
|
* field the host-side `Page.querySelectorPiercing` cares about for the
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Linux-server environment auto-detection that drives
|
|
3
|
+
* `LaunchOptions.headlessMode` defaulting.
|
|
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", () => {
|
|
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", () => {
|
|
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", () => {
|
|
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
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* spawn a real Chromium here; the goal is to lock the flag set against
|
|
5
5
|
* regressions, particularly the matrix-derived `--lang=<locale>` flag that
|
|
6
6
|
* closes the I-5 leak between Chromium's network-layer `Accept-Language`
|
|
7
|
-
* header and the JS-layer `navigator.language(s)` spoof
|
|
7
|
+
* header and the JS-layer `navigator.language(s)` spoof.
|
|
8
8
|
*
|
|
9
9
|
* The flag is sourced from `MatrixV1.locale` (the canonical primary BCP-47
|
|
10
10
|
* string) and MUST come from the matrix, never from the host OS.
|
|
@@ -75,7 +75,7 @@ describe("buildChromiumArgs / baseline", () => {
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
describe("buildChromiumArgs / --lang
|
|
78
|
+
describe("buildChromiumArgs / --lang (— matrix.locale → Accept-Language)", () => {
|
|
79
79
|
it("appends --lang=<value> when locale is set", () => {
|
|
80
80
|
const args = buildChromiumArgs(
|
|
81
81
|
baseCfg({ locale: "en-US" }),
|
|
@@ -337,7 +337,7 @@ describe("buildChromiumArgs — task 0256 (hermetic-mode knob)", () => {
|
|
|
337
337
|
});
|
|
338
338
|
|
|
339
339
|
/**
|
|
340
|
-
* Diagnostic-tail classifier —
|
|
340
|
+
* Diagnostic-tail classifier — Locks the two patterns we currently
|
|
341
341
|
* surface (root-sandbox refusal and missing shared libs) against regressions
|
|
342
342
|
* without spawning a real Chromium.
|
|
343
343
|
*/
|