@mochi.js/core 0.1.2 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mochi.js/core",
3
- "version": "0.1.2",
3
+ "version": "0.2.2",
4
4
  "description": "Bun-native browser automation framework — relational fingerprint locking, zero-jitter injection, behavioral playback. The primary entry point.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,10 +49,10 @@
49
49
  "build": "echo 'no build step yet — Bun consumes src/ directly'"
50
50
  },
51
51
  "dependencies": {
52
- "@mochi.js/behavioral": "^0.1.1",
53
- "@mochi.js/challenges": "^0.2.0",
54
- "@mochi.js/consistency": "^0.1.0",
55
- "@mochi.js/inject": "^0.1.1",
52
+ "@mochi.js/behavioral": "^0.1.2",
53
+ "@mochi.js/challenges": "^0.2.1",
54
+ "@mochi.js/consistency": "^0.1.1",
55
+ "@mochi.js/inject": "^0.2.0",
56
56
  "@mochi.js/net": "^0.1.1"
57
57
  },
58
58
  "publishConfig": {
@@ -251,6 +251,8 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
251
251
  fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-2" });
252
252
  fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-2" });
253
253
  fake.autoRespond((m) => m === "Page.enable", {});
254
+ // Task 0255: Session now sends Network.setUserAgentOverride per page.
255
+ fake.autoRespond((m) => m === "Network.setUserAgentOverride", {});
254
256
  fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
255
257
  fake.autoRespond((m) => m === "Page.removeScriptToEvaluateOnNewDocument", {});
256
258
  fake.autoRespond((m) => m === "Page.addScriptToEvaluateOnNewDocument", {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Unit tests for the closed-shadow piercing walker
3
+ * (`packages/core/src/page/piercing.ts`). Drives a hand-crafted
4
+ * `PierceDomNode` tree that mirrors the CDP `DOM.getDocument({ depth:-1,
5
+ * pierce:true })` shape — including a closed-shadow-rooted iframe, which is
6
+ * the whole point of task 0253.
7
+ *
8
+ * The findPiercingMatches output is verified by `backendNodeId`, the only
9
+ * field the host-side `Page.querySelectorPiercing` cares about for the
10
+ * `DOM.resolveNode` round-trip.
11
+ */
12
+
13
+ import { describe, expect, it } from "bun:test";
14
+ import type { PierceDomNode } from "../cdp/types";
15
+ import { findPiercingMatches } from "../page/piercing";
16
+ import { parseSelector } from "../page/selector";
17
+
18
+ /** Element node helper. */
19
+ function elem(
20
+ backendNodeId: number,
21
+ tag: string,
22
+ attrs: Record<string, string>,
23
+ children: PierceDomNode[] = [],
24
+ ): PierceDomNode {
25
+ const flat: string[] = [];
26
+ for (const [k, v] of Object.entries(attrs)) flat.push(k, v);
27
+ return {
28
+ nodeId: backendNodeId,
29
+ backendNodeId,
30
+ nodeType: 1,
31
+ nodeName: tag.toUpperCase(),
32
+ localName: tag.toLowerCase(),
33
+ attributes: flat,
34
+ children,
35
+ };
36
+ }
37
+
38
+ /** Document node helper. */
39
+ function doc(children: PierceDomNode[]): PierceDomNode {
40
+ return {
41
+ nodeId: 0,
42
+ backendNodeId: 0,
43
+ nodeType: 9,
44
+ nodeName: "#document",
45
+ children,
46
+ };
47
+ }
48
+
49
+ /** Shadow-root node helper. */
50
+ function shadow(
51
+ backendNodeId: number,
52
+ type: "open" | "closed",
53
+ children: PierceDomNode[],
54
+ ): PierceDomNode {
55
+ return {
56
+ nodeId: backendNodeId,
57
+ backendNodeId,
58
+ nodeType: 11, // DOCUMENT_FRAGMENT_NODE
59
+ nodeName: "#document-fragment",
60
+ shadowRootType: type,
61
+ children,
62
+ };
63
+ }
64
+
65
+ describe("findPiercingMatches — light DOM only", () => {
66
+ it("finds a top-level iframe by tag", () => {
67
+ const tree = doc([elem(10, "iframe", { src: "https://example.com" })]);
68
+ const matches = findPiercingMatches(tree, parseSelector("iframe"));
69
+ expect(matches.map((m) => m.backendNodeId)).toEqual([10]);
70
+ });
71
+
72
+ it("returns matches in depth-first pre-order", () => {
73
+ const tree = doc([
74
+ elem(10, "div", { class: "a" }, [elem(11, "div", { class: "b" })]),
75
+ elem(12, "div", { class: "c" }),
76
+ ]);
77
+ const matches = findPiercingMatches(tree, parseSelector("div"));
78
+ expect(matches.map((m) => m.backendNodeId)).toEqual([10, 11, 12]);
79
+ });
80
+
81
+ it("respects the limit parameter", () => {
82
+ const tree = doc([elem(10, "div", {}), elem(11, "div", {}), elem(12, "div", {})]);
83
+ const matches = findPiercingMatches(tree, parseSelector("div"), 2);
84
+ expect(matches.map((m) => m.backendNodeId)).toEqual([10, 11]);
85
+ });
86
+ });
87
+
88
+ describe("findPiercingMatches — open shadow roots", () => {
89
+ it("finds an element inside an open shadow root", () => {
90
+ const target = elem(20, "iframe", {
91
+ src: "https://challenges.cloudflare.com/turnstile/x",
92
+ });
93
+ const host = elem(15, "x-host", {}, []);
94
+ host.shadowRoots = [shadow(16, "open", [target])];
95
+ const tree = doc([host]);
96
+ const matches = findPiercingMatches(
97
+ tree,
98
+ parseSelector('iframe[src*="challenges.cloudflare.com/turnstile"]'),
99
+ );
100
+ expect(matches.map((m) => m.backendNodeId)).toEqual([20]);
101
+ });
102
+ });
103
+
104
+ describe("findPiercingMatches — closed shadow roots (the point of 0253)", () => {
105
+ it("finds an element inside a CLOSED shadow root", () => {
106
+ const target = elem(30, "iframe", {
107
+ src: "https://challenges.cloudflare.com/turnstile/closed",
108
+ });
109
+ const host = elem(25, "cf-host", {}, []);
110
+ host.shadowRoots = [shadow(26, "closed", [target])];
111
+ const tree = doc([host]);
112
+
113
+ const matches = findPiercingMatches(
114
+ tree,
115
+ parseSelector('iframe[src*="challenges.cloudflare.com/turnstile"]'),
116
+ );
117
+ expect(matches.map((m) => m.backendNodeId)).toEqual([30]);
118
+ });
119
+
120
+ it("walks nested closed shadows", () => {
121
+ // outer-host > [closed shadow] > inner-host > [closed shadow] > iframe
122
+ // Each host's shadow root contains the next host (NOT also as a light-DOM
123
+ // child — that would double-walk the inner subtree).
124
+ const target = elem(40, "iframe", { src: "x" });
125
+ const innerHost = elem(35, "y-host", {});
126
+ innerHost.shadowRoots = [shadow(36, "closed", [target])];
127
+ const outerHost = elem(31, "x-host", {});
128
+ outerHost.shadowRoots = [shadow(32, "closed", [innerHost])];
129
+ const tree = doc([outerHost]);
130
+
131
+ const matches = findPiercingMatches(tree, parseSelector("iframe"));
132
+ expect(matches.map((m) => m.backendNodeId)).toEqual([40]);
133
+ });
134
+
135
+ it("matches descendant combinator across a closed shadow boundary", () => {
136
+ // <div class="root"><x-host>#shadow-closed{<iframe/>}</x-host></div>
137
+ const iframe = elem(50, "iframe", { src: "y" });
138
+ const host = elem(46, "x-host", {});
139
+ host.shadowRoots = [shadow(47, "closed", [iframe])];
140
+ const root = elem(45, "div", { class: "root" }, [host]);
141
+ const tree = doc([root]);
142
+
143
+ const matches = findPiercingMatches(tree, parseSelector(".root iframe"));
144
+ expect(matches.map((m) => m.backendNodeId)).toEqual([50]);
145
+ });
146
+ });
147
+
148
+ describe("findPiercingMatches — iframe contentDocument (same-origin)", () => {
149
+ it("walks into iframe contentDocument trees", () => {
150
+ const buried = elem(60, "button", { id: "go" });
151
+ const subdoc: PierceDomNode = {
152
+ nodeId: 0,
153
+ backendNodeId: 55,
154
+ nodeType: 9,
155
+ nodeName: "#document",
156
+ children: [buried],
157
+ };
158
+ const iframe = elem(54, "iframe", { src: "/sub.html" });
159
+ iframe.contentDocument = subdoc;
160
+ const tree = doc([iframe]);
161
+ const matches = findPiercingMatches(tree, parseSelector("#go"));
162
+ expect(matches.map((m) => m.backendNodeId)).toEqual([60]);
163
+ });
164
+ });
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Unit tests for `buildChromiumArgs` — the pure arg-vector builder shared
3
+ * between `spawnChromium` and the launcher's flag-plumbing tests. We do NOT
4
+ * spawn a real Chromium here; the goal is to lock the flag set against
5
+ * regressions, particularly the matrix-derived `--lang=<locale>` flag that
6
+ * closes the I-5 leak between Chromium's network-layer `Accept-Language`
7
+ * header and the JS-layer `navigator.language(s)` spoof (task 0251).
8
+ *
9
+ * The flag is sourced from `MatrixV1.locale` (the canonical primary BCP-47
10
+ * string) and MUST come from the matrix, never from the host OS.
11
+ *
12
+ * @see packages/core/src/proc.ts
13
+ * @see PLAN.md §8.6 (DEFAULT_CHROMIUM_FLAGS), §2 I-5
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
17
+ import {
18
+ buildChromiumArgs,
19
+ DEFAULT_CHROMIUM_FLAGS,
20
+ diagnoseEarlyExitTail,
21
+ HERMETIC_ONLY_CHROMIUM_FLAGS,
22
+ type SpawnConfig,
23
+ } from "../proc";
24
+
25
+ const FAKE_BINARY = "/usr/bin/chromium-stub";
26
+ const FAKE_UDD = "/tmp/mochi-test-udd";
27
+
28
+ function baseCfg(overrides: Partial<SpawnConfig> = {}): SpawnConfig {
29
+ return { binary: FAKE_BINARY, headless: false, ...overrides };
30
+ }
31
+
32
+ describe("buildChromiumArgs / baseline", () => {
33
+ it("includes every DEFAULT_CHROMIUM_FLAGS entry verbatim", () => {
34
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, process.env.MOCHI_EXTRA_ARGS);
35
+ for (const flag of DEFAULT_CHROMIUM_FLAGS) {
36
+ expect(args).toContain(flag);
37
+ }
38
+ });
39
+
40
+ it("puts --user-data-dir first so user-supplied args cannot override it", () => {
41
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, process.env.MOCHI_EXTRA_ARGS);
42
+ expect(args[0]).toBe(`--user-data-dir=${FAKE_UDD}`);
43
+ });
44
+
45
+ it("does NOT include --headless=new when headless is false", () => {
46
+ const args = buildChromiumArgs(
47
+ baseCfg({ headless: false }),
48
+ FAKE_UDD,
49
+ process.env.MOCHI_EXTRA_ARGS,
50
+ );
51
+ expect(args).not.toContain("--headless=new");
52
+ });
53
+
54
+ it("includes --headless=new when headless is true", () => {
55
+ const args = buildChromiumArgs(
56
+ baseCfg({ headless: true }),
57
+ FAKE_UDD,
58
+ process.env.MOCHI_EXTRA_ARGS,
59
+ );
60
+ expect(args).toContain("--headless=new");
61
+ });
62
+
63
+ it("appends --proxy-server when proxy is set", () => {
64
+ const args = buildChromiumArgs(
65
+ baseCfg({ proxy: "http://proxy.example:8080" }),
66
+ FAKE_UDD,
67
+ process.env.MOCHI_EXTRA_ARGS,
68
+ );
69
+ expect(args).toContain("--proxy-server=http://proxy.example:8080");
70
+ });
71
+
72
+ it("does NOT include --proxy-server when proxy is empty / undefined", () => {
73
+ const args = buildChromiumArgs(baseCfg({ proxy: "" }), FAKE_UDD, process.env.MOCHI_EXTRA_ARGS);
74
+ expect(args.some((a) => a.startsWith("--proxy-server"))).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe("buildChromiumArgs / --lang (task 0251 — matrix.locale → Accept-Language)", () => {
79
+ it("appends --lang=<value> when locale is set", () => {
80
+ const args = buildChromiumArgs(
81
+ baseCfg({ locale: "en-US" }),
82
+ FAKE_UDD,
83
+ process.env.MOCHI_EXTRA_ARGS,
84
+ );
85
+ expect(args).toContain("--lang=en-US");
86
+ });
87
+
88
+ it("preserves the BCP-47 hyphen / region casing exactly as supplied", () => {
89
+ // Chromium accepts the BCP-47 form verbatim; we MUST NOT lowercase the
90
+ // region tag (e.g. "en-US" vs "en-us") because Chromium's `Accept-Language`
91
+ // derivation respects the exact casing of the value.
92
+ const args = buildChromiumArgs(
93
+ baseCfg({ locale: "pt-BR" }),
94
+ FAKE_UDD,
95
+ process.env.MOCHI_EXTRA_ARGS,
96
+ );
97
+ expect(args).toContain("--lang=pt-BR");
98
+ expect(args).not.toContain("--lang=pt-br");
99
+ });
100
+
101
+ it("does NOT include --lang when locale is undefined", () => {
102
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, process.env.MOCHI_EXTRA_ARGS);
103
+ expect(args.some((a) => a.startsWith("--lang"))).toBe(false);
104
+ });
105
+
106
+ it("does NOT include --lang when locale is the empty string", () => {
107
+ const args = buildChromiumArgs(baseCfg({ locale: "" }), FAKE_UDD, process.env.MOCHI_EXTRA_ARGS);
108
+ expect(args.some((a) => a.startsWith("--lang"))).toBe(false);
109
+ });
110
+
111
+ it("does NOT silently fall back to host locale (we are not udc)", () => {
112
+ // udc's `__init__.py:359-369` falls back to `locale.getdefaultlocale()`;
113
+ // mochi explicitly does NOT — locale must come from the matrix or it is
114
+ // omitted (so a missing matrix.locale shows up as a profile-data bug
115
+ // rather than masquerading as host-locale leakage).
116
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, process.env.MOCHI_EXTRA_ARGS);
117
+ expect(args.some((a) => a.startsWith("--lang="))).toBe(false);
118
+ });
119
+
120
+ it("places --lang BEFORE extraArgs so a user override wins last-occurrence", () => {
121
+ const args = buildChromiumArgs(
122
+ baseCfg({ locale: "en-US", extraArgs: ["--lang=fr-FR"] }),
123
+ FAKE_UDD,
124
+ undefined,
125
+ );
126
+ const matrixIdx = args.indexOf("--lang=en-US");
127
+ const overrideIdx = args.indexOf("--lang=fr-FR");
128
+ expect(matrixIdx).toBeGreaterThanOrEqual(0);
129
+ expect(overrideIdx).toBeGreaterThan(matrixIdx);
130
+ });
131
+
132
+ it("emits --lang under --headless=new (flag is honored in modern headless)", () => {
133
+ // Chromium's `--lang` drives `ICU::Locale::Default` and the I/O thread's
134
+ // request-context Accept-Language; both run regardless of headless mode.
135
+ // We assert co-presence — the spawn path emits both flags together.
136
+ const args = buildChromiumArgs(
137
+ baseCfg({ locale: "en-US", headless: true }),
138
+ FAKE_UDD,
139
+ process.env.MOCHI_EXTRA_ARGS,
140
+ );
141
+ expect(args).toContain("--headless=new");
142
+ expect(args).toContain("--lang=en-US");
143
+ });
144
+ });
145
+
146
+ describe("buildChromiumArgs / MOCHI_EXTRA_ARGS env var", () => {
147
+ let prev: string | undefined;
148
+ beforeEach(() => {
149
+ prev = process.env.MOCHI_EXTRA_ARGS;
150
+ });
151
+ afterEach(() => {
152
+ if (prev === undefined) delete process.env.MOCHI_EXTRA_ARGS;
153
+ else process.env.MOCHI_EXTRA_ARGS = prev;
154
+ });
155
+
156
+ it("appends whitespace-separated env args after locale + extraArgs", () => {
157
+ process.env.MOCHI_EXTRA_ARGS = "--no-sandbox --disable-gpu";
158
+ const args = buildChromiumArgs(
159
+ baseCfg({ locale: "en-US" }),
160
+ FAKE_UDD,
161
+ process.env.MOCHI_EXTRA_ARGS,
162
+ );
163
+ expect(args).toContain("--no-sandbox");
164
+ expect(args).toContain("--disable-gpu");
165
+ // env extras come last, after the matrix-derived --lang.
166
+ const langIdx = args.indexOf("--lang=en-US");
167
+ const noSandboxIdx = args.indexOf("--no-sandbox");
168
+ expect(noSandboxIdx).toBeGreaterThan(langIdx);
169
+ });
170
+
171
+ it("ignores MOCHI_EXTRA_ARGS when set to empty / whitespace-only", () => {
172
+ process.env.MOCHI_EXTRA_ARGS = " ";
173
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, process.env.MOCHI_EXTRA_ARGS);
174
+ expect(args.some((a) => a === "")).toBe(false);
175
+ });
176
+ });
177
+
178
+ // =============================================================================
179
+ // Task 0252 (window-size + start-maximized scrub) — keeps its own describe
180
+ // block. Uses the same FAKE_UDD const as the locale tests above; the helper
181
+ // `baseCfg` from line 22 satisfies all configs needed below (no second helper).
182
+ // =============================================================================
183
+
184
+ describe("buildChromiumArgs — task 0252 (window-size + start-maximized scrub)", () => {
185
+ it("emits --window-size=<W>,<H> when windowSize is well-formed", () => {
186
+ const args = buildChromiumArgs(
187
+ baseCfg({ windowSize: { width: 1728, height: 1117 } }),
188
+ FAKE_UDD,
189
+ undefined,
190
+ );
191
+ expect(args).toContain("--window-size=1728,1117");
192
+ });
193
+
194
+ it("omits --window-size when windowSize is undefined (matrix-canonical, no fallback)", () => {
195
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, undefined);
196
+ expect(args.some((a) => a.startsWith("--window-size="))).toBe(false);
197
+ });
198
+
199
+ it("omits --window-size when dimensions are non-integer / non-positive / NaN", () => {
200
+ for (const ws of [
201
+ { width: 0, height: 1117 },
202
+ { width: 1728, height: 0 },
203
+ { width: -1, height: 1117 },
204
+ { width: 1728.5, height: 1117 },
205
+ { width: Number.NaN, height: 1117 },
206
+ { width: Number.POSITIVE_INFINITY, height: 1117 },
207
+ ] as const) {
208
+ const args = buildChromiumArgs(baseCfg({ windowSize: ws }), FAKE_UDD, undefined);
209
+ expect(args.some((a) => a.startsWith("--window-size="))).toBe(false);
210
+ }
211
+ });
212
+
213
+ it("strips --start-maximized from extraArgs (task 0252 #3 — UDC adds it; mochi must not)", () => {
214
+ const args = buildChromiumArgs(
215
+ baseCfg({ extraArgs: ["--start-maximized", "--no-sandbox"] }),
216
+ FAKE_UDD,
217
+ undefined,
218
+ );
219
+ expect(args).not.toContain("--start-maximized");
220
+ expect(args).toContain("--no-sandbox");
221
+ });
222
+
223
+ it("strips --start-maximized=<value> form from extraArgs", () => {
224
+ const args = buildChromiumArgs(
225
+ baseCfg({ extraArgs: ["--start-maximized=1", "--lang=en-US"] }),
226
+ FAKE_UDD,
227
+ undefined,
228
+ );
229
+ expect(args.some((a) => a.startsWith("--start-maximized"))).toBe(false);
230
+ expect(args).toContain("--lang=en-US");
231
+ });
232
+
233
+ it("strips --start-maximized from MOCHI_EXTRA_ARGS env split", () => {
234
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, "--start-maximized --no-sandbox --foo=bar");
235
+ expect(args).not.toContain("--start-maximized");
236
+ expect(args).toContain("--no-sandbox");
237
+ expect(args).toContain("--foo=bar");
238
+ });
239
+
240
+ it("appends --headless=new when headless is true", () => {
241
+ const args = buildChromiumArgs(baseCfg({ headless: true }), FAKE_UDD, undefined);
242
+ expect(args).toContain("--headless=new");
243
+ });
244
+
245
+ it("places --user-data-dir as the first arg", () => {
246
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, undefined);
247
+ expect(args[0]).toBe(`--user-data-dir=${FAKE_UDD}`);
248
+ });
249
+
250
+ it("emits --proxy-server when proxy is set", () => {
251
+ const args = buildChromiumArgs(
252
+ baseCfg({ proxy: "http://127.0.0.1:8080" }),
253
+ FAKE_UDD,
254
+ undefined,
255
+ );
256
+ expect(args).toContain("--proxy-server=http://127.0.0.1:8080");
257
+ });
258
+
259
+ it("does NOT include --start-maximized in the default flag set", () => {
260
+ // Defensive: if anyone ever adds it to DEFAULT_CHROMIUM_FLAGS, this fails.
261
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, undefined);
262
+ expect(args.some((a) => a.startsWith("--start-maximized"))).toBe(false);
263
+ });
264
+ });
265
+
266
+ // =============================================================================
267
+ // Task 0256 (default Chromium flags audit + hermetic-mode knob)
268
+ //
269
+ // Verifies the production / hermetic split. Production drops passive
270
+ // command-line bot-tells (`--disable-component-update`,
271
+ // `--disable-default-apps`, `--disable-background-networking`,
272
+ // `--disable-sync`) per patchright `chromiumSwitchesPatch.ts:20-34`;
273
+ // hermetic re-applies them for harness / CI / capture flows.
274
+ // =============================================================================
275
+
276
+ describe("buildChromiumArgs — task 0256 (hermetic-mode knob)", () => {
277
+ it("does NOT emit any HERMETIC_ONLY_CHROMIUM_FLAGS entry when hermetic is unset", () => {
278
+ const args = buildChromiumArgs(baseCfg(), FAKE_UDD, undefined);
279
+ for (const flag of HERMETIC_ONLY_CHROMIUM_FLAGS) {
280
+ expect(args).not.toContain(flag);
281
+ }
282
+ });
283
+
284
+ it("does NOT emit any HERMETIC_ONLY_CHROMIUM_FLAGS entry when hermetic is false", () => {
285
+ const args = buildChromiumArgs(baseCfg({ hermetic: false }), FAKE_UDD, undefined);
286
+ for (const flag of HERMETIC_ONLY_CHROMIUM_FLAGS) {
287
+ expect(args).not.toContain(flag);
288
+ }
289
+ });
290
+
291
+ it("emits every HERMETIC_ONLY_CHROMIUM_FLAGS entry when hermetic is true", () => {
292
+ const args = buildChromiumArgs(baseCfg({ hermetic: true }), FAKE_UDD, undefined);
293
+ for (const flag of HERMETIC_ONLY_CHROMIUM_FLAGS) {
294
+ expect(args).toContain(flag);
295
+ }
296
+ });
297
+
298
+ it("preserves DEFAULT_CHROMIUM_FLAGS in hermetic mode (additive, not replacement)", () => {
299
+ const args = buildChromiumArgs(baseCfg({ hermetic: true }), FAKE_UDD, undefined);
300
+ for (const flag of DEFAULT_CHROMIUM_FLAGS) {
301
+ expect(args).toContain(flag);
302
+ }
303
+ });
304
+
305
+ it("DEFAULT_CHROMIUM_FLAGS does not contain any patchright-trim cmdline tell", () => {
306
+ // Belt + braces: the production default must not regress on the audit.
307
+ expect(DEFAULT_CHROMIUM_FLAGS).not.toContain("--disable-component-update");
308
+ expect(DEFAULT_CHROMIUM_FLAGS).not.toContain("--disable-default-apps");
309
+ expect(DEFAULT_CHROMIUM_FLAGS).not.toContain("--disable-background-networking");
310
+ expect(DEFAULT_CHROMIUM_FLAGS).not.toContain("--disable-sync");
311
+ });
312
+
313
+ it("DEFAULT_CHROMIUM_FLAGS does not contain --no-sandbox or AutomationControlled", () => {
314
+ // Per PLAN.md §8.6 — both are explicitly out of defaults.
315
+ expect(DEFAULT_CHROMIUM_FLAGS).not.toContain("--no-sandbox");
316
+ expect(DEFAULT_CHROMIUM_FLAGS).not.toContain("--disable-blink-features=AutomationControlled");
317
+ expect(HERMETIC_ONLY_CHROMIUM_FLAGS).not.toContain("--no-sandbox");
318
+ expect(HERMETIC_ONLY_CHROMIUM_FLAGS).not.toContain(
319
+ "--disable-blink-features=AutomationControlled",
320
+ );
321
+ });
322
+
323
+ it("hermetic flags are appended AFTER defaults but before --headless / extras", () => {
324
+ const args = buildChromiumArgs(
325
+ baseCfg({ hermetic: true, headless: true, extraArgs: ["--foo"] }),
326
+ FAKE_UDD,
327
+ undefined,
328
+ );
329
+ const lastDefaultIdx = args.indexOf("--enable-features=NetworkService,NetworkServiceInProcess");
330
+ const firstHermeticIdx = args.indexOf("--disable-default-apps");
331
+ const headlessIdx = args.indexOf("--headless=new");
332
+ const extrasIdx = args.indexOf("--foo");
333
+ expect(firstHermeticIdx).toBeGreaterThan(lastDefaultIdx);
334
+ expect(headlessIdx).toBeGreaterThan(firstHermeticIdx);
335
+ expect(extrasIdx).toBeGreaterThan(headlessIdx);
336
+ });
337
+ });
338
+
339
+ /**
340
+ * Diagnostic-tail classifier — task 0259. Locks the two patterns we currently
341
+ * surface (root-sandbox refusal and missing shared libs) against regressions
342
+ * without spawning a real Chromium.
343
+ */
344
+ describe("diagnoseEarlyExitTail", () => {
345
+ it("returns the empty string when no known pattern matches", () => {
346
+ expect(diagnoseEarlyExitTail("some unrelated stderr noise")).toBe("");
347
+ expect(diagnoseEarlyExitTail("")).toBe("");
348
+ });
349
+
350
+ it("emits the root-sandbox hint for the canonical Chromium message", () => {
351
+ const tail =
352
+ "[1234:1234:0508/120000.000:ERROR:zygote_host_impl_linux.cc(90)] " +
353
+ "Running as root without --no-sandbox is not supported. See " +
354
+ "https://crbug.com/638180.";
355
+ const hint = diagnoseEarlyExitTail(tail);
356
+ expect(hint).toContain("Chromium refuses to start as root");
357
+ expect(hint).toContain("Run as a non-root user");
358
+ expect(hint).toContain("chrome-sandbox");
359
+ expect(hint).toContain("--no-sandbox");
360
+ });
361
+
362
+ it("emits the missing-lib hint with the offending .so name", () => {
363
+ const tail =
364
+ "chrome: error while loading shared libraries: libnss3.so: " +
365
+ "cannot open shared object file: No such file or directory";
366
+ const hint = diagnoseEarlyExitTail(tail);
367
+ expect(hint).toContain("libnss3.so");
368
+ expect(hint).toContain("Chromium failed to load a system library");
369
+ expect(hint).toContain("mochi browsers install");
370
+ expect(hint).toContain("https://mochijs.com/docs/getting-started/install");
371
+ });
372
+
373
+ it("prefers the root-sandbox hint when both patterns are present (root surfaces first)", () => {
374
+ // Defensive: a misconfigured rootful container could plausibly hit both;
375
+ // surface the root one because that's the load-bearing fix.
376
+ const tail =
377
+ "Running as root without --no-sandbox is not supported.\n" +
378
+ "error while loading shared libraries: libnss3.so: cannot open ...";
379
+ const hint = diagnoseEarlyExitTail(tail);
380
+ expect(hint).toContain("Chromium refuses to start as root");
381
+ expect(hint).not.toContain("libnss3.so");
382
+ });
383
+ });