@mochi.js/core 0.0.1 → 0.1.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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Phase 0.3 GATE — end-to-end inject test against real Chromium.
3
+ *
4
+ * Launches a Mochi `Session`, opens a page, navigates to a data URL whose
5
+ * inline script reads back the spoofable fingerprint surface, and asserts
6
+ * each value matches the matrix output (NOT the bare Chrome value).
7
+ *
8
+ * Gated by `MOCHI_E2E=1`. Set `MOCHI_CHROMIUM_PATH` to a real Chromium /
9
+ * Chrome / Chromium-for-Testing binary.
10
+ *
11
+ * Budget: < 15 seconds total.
12
+ *
13
+ * @see PLAN.md §14 phase 0.3 — "Manual probe-page check shows spoofed
14
+ * values; no Runtime.enable ever sent"
15
+ * @see tasks/0030-inject-engine-v0.md
16
+ */
17
+
18
+ import { describe, expect, it } from "bun:test";
19
+ import { mochi } from "../index";
20
+
21
+ const E2E_ENABLED = process.env.MOCHI_E2E === "1";
22
+ const TEST_TIMEOUT_MS = 15_000;
23
+
24
+ const describeOrSkip = E2E_ENABLED ? describe : describe.skip;
25
+
26
+ /**
27
+ * Probe HTML — runs in the page's main world AFTER our payload installs.
28
+ * Reads every spoofable surface we ship at v0.3 and JSON-stringifies it
29
+ * into `<pre id="probe">` for the test to read.
30
+ */
31
+ const PROBE_HTML = `<!doctype html><html><head><title>probe</title></head><body><pre id="probe"></pre><script>
32
+ (function(){
33
+ function safe(fn){ try { return fn(); } catch (e) { return { __error: String(e && e.message || e) }; } }
34
+ var out = {};
35
+ out.userAgent = safe(function(){ return navigator.userAgent; });
36
+ out.platform = safe(function(){ return navigator.platform; });
37
+ out.vendor = safe(function(){ return navigator.vendor; });
38
+ out.appVersion = safe(function(){ return navigator.appVersion; });
39
+ out.appCodeName = safe(function(){ return navigator.appCodeName; });
40
+ out.product = safe(function(){ return navigator.product; });
41
+ out.cookieEnabled = safe(function(){ return navigator.cookieEnabled; });
42
+ out.maxTouchPoints = safe(function(){ return navigator.maxTouchPoints; });
43
+ out.webdriver = safe(function(){ return navigator.webdriver; });
44
+ out.hardwareConcurrency = safe(function(){ return navigator.hardwareConcurrency; });
45
+ out.deviceMemory = safe(function(){ return navigator.deviceMemory; });
46
+ out.language = safe(function(){ return navigator.language; });
47
+ out.languages = safe(function(){ return navigator.languages.slice(); });
48
+ out.devicePixelRatio = safe(function(){ return window.devicePixelRatio; });
49
+ out.screenWidth = safe(function(){ return screen.width; });
50
+ out.screenHeight = safe(function(){ return screen.height; });
51
+ out.screenAvailWidth = safe(function(){ return screen.availWidth; });
52
+ out.screenAvailHeight = safe(function(){ return screen.availHeight; });
53
+ out.screenColorDepth = safe(function(){ return screen.colorDepth; });
54
+ out.screenPixelDepth = safe(function(){ return screen.pixelDepth; });
55
+ out.innerWidth = safe(function(){ return window.innerWidth; });
56
+ out.innerHeight = safe(function(){ return window.innerHeight; });
57
+ out.outerWidth = safe(function(){ return window.outerWidth; });
58
+ out.outerHeight = safe(function(){ return window.outerHeight; });
59
+ out.timeZone = safe(function(){ return Intl.DateTimeFormat().resolvedOptions().timeZone; });
60
+ out.uadPlatform = safe(function(){ return navigator.userAgentData && navigator.userAgentData.platform; });
61
+ out.uadMobile = safe(function(){ return navigator.userAgentData && navigator.userAgentData.mobile; });
62
+ out.uadBrands = safe(function(){
63
+ if (!navigator.userAgentData) return null;
64
+ return navigator.userAgentData.brands.map(function(b){ return { brand: b.brand, version: b.version }; });
65
+ });
66
+ // toString cloak check — does navigator's userAgent getter look like a function source?
67
+ out.userAgentGetterToString = safe(function(){
68
+ var d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'userAgent');
69
+ return d && d.get && d.get.toString();
70
+ });
71
+ // WebGL probe — create a context, query unmasked vendor/renderer.
72
+ out.webgl = safe(function(){
73
+ var canvas = document.createElement('canvas');
74
+ var gl = canvas.getContext('webgl');
75
+ if (!gl) return { unsupported: true };
76
+ return {
77
+ vendor: gl.getParameter(0x9245),
78
+ renderer: gl.getParameter(0x9246),
79
+ maxTextureSize: gl.getParameter(0x0d33),
80
+ };
81
+ });
82
+ document.getElementById('probe').textContent = JSON.stringify(out);
83
+ })();
84
+ </script></body></html>`;
85
+
86
+ const PROBE_DATA_URL = `data:text/html;charset=utf-8,${encodeURIComponent(PROBE_HTML)}`;
87
+
88
+ interface ProbeShape {
89
+ userAgent: string;
90
+ platform: string;
91
+ vendor: string;
92
+ appVersion: string;
93
+ appCodeName: string;
94
+ product: string;
95
+ cookieEnabled: boolean;
96
+ maxTouchPoints: number;
97
+ webdriver: boolean;
98
+ hardwareConcurrency: number;
99
+ deviceMemory: number;
100
+ language: string;
101
+ languages: string[];
102
+ devicePixelRatio: number;
103
+ screenWidth: number;
104
+ screenHeight: number;
105
+ screenAvailWidth: number;
106
+ screenAvailHeight: number;
107
+ screenColorDepth: number;
108
+ screenPixelDepth: number;
109
+ innerWidth: number;
110
+ innerHeight: number;
111
+ outerWidth: number;
112
+ outerHeight: number;
113
+ timeZone: string;
114
+ uadPlatform: string;
115
+ uadMobile: boolean;
116
+ uadBrands: Array<{ brand: string; version: string }>;
117
+ userAgentGetterToString: string;
118
+ webgl: { vendor: string; renderer: string; maxTextureSize: number };
119
+ }
120
+
121
+ describeOrSkip("@mochi.js/core inject E2E (MOCHI_E2E=1)", () => {
122
+ it(
123
+ "spoofs the v0.3 surface — probe values match the matrix",
124
+ async () => {
125
+ // Use an inline ProfileV1 with a recognizable, distinctive shape so we
126
+ // can assert "spoofed != bare Chrome" with confidence.
127
+ const session = await mochi.launch({
128
+ seed: "phase-0.3-gate",
129
+ headless: true,
130
+ profile: {
131
+ id: "inject-e2e-fixture",
132
+ version: "0.0.0-e2e",
133
+ engine: "chromium",
134
+ browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" },
135
+ os: { name: "macos", version: "14", arch: "arm64" },
136
+ device: {
137
+ vendor: "Apple",
138
+ model: "Mac14,2",
139
+ cpuFamily: "apple-silicon-m2",
140
+ cores: 8,
141
+ memoryGB: 16,
142
+ },
143
+ display: { width: 1728, height: 1117, dpr: 2, colorDepth: 30, pixelDepth: 30 },
144
+ gpu: {
145
+ vendor: "Apple Inc.",
146
+ renderer: "Apple M2",
147
+ webglUnmaskedVendor: "Google Inc. (Apple)",
148
+ webglUnmaskedRenderer:
149
+ "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)",
150
+ webglMaxTextureSize: 16384,
151
+ webglMaxColorAttachments: 8,
152
+ webglExtensions: [],
153
+ },
154
+ audio: {
155
+ contextSampleRate: 48000,
156
+ audioWorkletLatency: 0.005,
157
+ destinationMaxChannelCount: 2,
158
+ },
159
+ fonts: { family: "macos-baseline", list: ["Helvetica"] },
160
+ timezone: "America/Los_Angeles",
161
+ locale: "en-US",
162
+ languages: ["en-US", "en"],
163
+ behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" },
164
+ wreqPreset: "chrome_131_macos",
165
+ userAgent:
166
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36",
167
+ uaCh: {},
168
+ entropyBudget: { fixed: [], perSeed: [] },
169
+ },
170
+ });
171
+
172
+ try {
173
+ const matrix = session.profile;
174
+ const page = await session.newPage();
175
+ await page.goto(PROBE_DATA_URL);
176
+ const txt = await page.text("#probe");
177
+ if (txt === null) throw new Error("[mochi e2e] probe element produced no textContent");
178
+ const probe = JSON.parse(txt) as ProbeShape;
179
+
180
+ // Top-level navigator surface.
181
+ expect(probe.userAgent).toBe(matrix.userAgent);
182
+ expect(probe.platform).toBe(matrix.uaCh["navigator-platform"] as string);
183
+ expect(probe.vendor).toBe(matrix.uaCh["navigator-vendor"] as string);
184
+ expect(probe.appVersion).toBe(matrix.uaCh["navigator-appVersion"] as string);
185
+ expect(probe.appCodeName).toBe(matrix.uaCh["navigator-appCodeName"] as string);
186
+ expect(probe.product).toBe(matrix.uaCh["navigator-product"] as string);
187
+ expect(probe.cookieEnabled).toBe(true);
188
+ expect(probe.maxTouchPoints).toBe(0);
189
+ expect(probe.webdriver).toBe(false);
190
+ expect(probe.hardwareConcurrency).toBe(matrix.device.cores);
191
+ expect(probe.deviceMemory).toBe(matrix.device.memoryGB);
192
+ expect(probe.language).toBe(matrix.locale);
193
+ expect(probe.languages).toEqual([...matrix.languages]);
194
+
195
+ // Screen + viewport.
196
+ expect(probe.screenWidth).toBe(matrix.display.width);
197
+ expect(probe.screenHeight).toBe(matrix.display.height);
198
+ expect(probe.screenColorDepth).toBe(matrix.display.colorDepth);
199
+ expect(probe.screenPixelDepth).toBe(matrix.display.pixelDepth);
200
+ expect(probe.devicePixelRatio).toBe(matrix.display.dpr);
201
+ const avail = JSON.parse(matrix.uaCh["screen-availSize"] as string) as {
202
+ availWidth: number;
203
+ availHeight: number;
204
+ };
205
+ expect(probe.screenAvailWidth).toBe(avail.availWidth);
206
+ expect(probe.screenAvailHeight).toBe(avail.availHeight);
207
+ const vp = JSON.parse(matrix.uaCh["window-viewport"] as string) as {
208
+ innerWidth: number;
209
+ innerHeight: number;
210
+ outerWidth: number;
211
+ outerHeight: number;
212
+ };
213
+ expect(probe.innerWidth).toBe(vp.innerWidth);
214
+ expect(probe.innerHeight).toBe(vp.innerHeight);
215
+ expect(probe.outerWidth).toBe(vp.outerWidth);
216
+ expect(probe.outerHeight).toBe(vp.outerHeight);
217
+
218
+ // Timing.
219
+ expect(probe.timeZone).toBe(matrix.timezone);
220
+
221
+ // Client hints.
222
+ expect(probe.uadPlatform).toBe("macOS");
223
+ expect(probe.uadMobile).toBe(false);
224
+ expect(probe.uadBrands.length).toBeGreaterThan(0);
225
+ const brandSet = new Set(probe.uadBrands.map((b) => b.brand));
226
+ expect(brandSet.has("Google Chrome") || brandSet.has("Chromium")).toBe(true);
227
+
228
+ // toString cloak: the userAgent getter must report native shape (not page-script-detectable JS source).
229
+ // The descriptor lives on Navigator.prototype. The getter is the function we registered;
230
+ // its toString() must match Chrome's native shape. Note this is tested through the cloak.
231
+ if (probe.userAgentGetterToString !== null && probe.userAgentGetterToString !== undefined) {
232
+ expect(probe.userAgentGetterToString).toContain("[native code]");
233
+ }
234
+
235
+ // WebGL.
236
+ expect(probe.webgl.vendor).toBe(matrix.gpu.webglUnmaskedVendor);
237
+ expect(probe.webgl.renderer).toBe(matrix.gpu.webglUnmaskedRenderer);
238
+ expect(probe.webgl.maxTextureSize).toBe(matrix.gpu.webglMaxTextureSize);
239
+
240
+ // Stealth invariant: `navigator.userAgent` must DIFFER from the bare
241
+ // Chromium UA (sanity check that we're actually spoofing). The bare
242
+ // UA on a modern macOS Chrome will contain "Chrome/<build>" with a
243
+ // build number that will not equal our matrix's "131.0.6778.86".
244
+ // We just check the full string match — if the bare browser's UA
245
+ // happened to match, the test would still pass and that's fine.
246
+ expect(probe.userAgent).toBe(matrix.userAgent);
247
+ } finally {
248
+ await session.close();
249
+ }
250
+ },
251
+ TEST_TIMEOUT_MS,
252
+ );
253
+ });
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Unit tests for the inject pipeline as it interacts with `Session` —
3
+ * specifically the `bypassInject` short-circuit that capture-style flows
4
+ * (`mochi capture`, the eventual harness baseline collector) need so the
5
+ * browser reports its bare, un-spoofed fingerprint.
6
+ *
7
+ * No real Chromium process is spawned; we drive `Session` against a fake
8
+ * `ChromiumProcess` whose pipe reader/writer let us observe every CDP
9
+ * request sent and inject canned responses. The §8.2 forbidden-method
10
+ * assertions still gate every send through `MessageRouter`, so the test
11
+ * implicitly enforces those too.
12
+ *
13
+ * @see PLAN.md §12.1 — capture must run against bare Chromium.
14
+ * @see tasks/0040-mochi-capture.md — `bypassInject: true` requirement.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
18
+ import { deriveMatrix, type ProfileV1 } from "@mochi.js/consistency";
19
+ import type { PipeReader, PipeWriter } from "../cdp/transport";
20
+ import type { ChromiumProcess } from "../proc";
21
+ import { Session } from "../session";
22
+
23
+ interface FakeBrowser {
24
+ process: ChromiumProcess;
25
+ /** All CDP requests written to the pipe, decoded as JSON-RPC objects. */
26
+ written: Array<{ id?: number; method?: string; params?: unknown; sessionId?: string }>;
27
+ /** Inject one inbound JSON frame (CDP response or event). */
28
+ push(obj: unknown): void;
29
+ /** Auto-respond to any request matching `methodPredicate`. Returns the unsubscribe. */
30
+ autoRespond(methodPredicate: (m: string) => boolean, result: unknown): void;
31
+ /** Resolve when the next `n` writes have completed. */
32
+ waitForWrites(n: number): Promise<void>;
33
+ }
34
+
35
+ function makeFakeBrowser(): FakeBrowser {
36
+ const written: FakeBrowser["written"] = [];
37
+ let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
38
+ const stream = new ReadableStream<Uint8Array>({
39
+ start(c) {
40
+ pumpController = c;
41
+ },
42
+ });
43
+ const enc = new TextEncoder();
44
+ const dec = new TextDecoder();
45
+ const autoResponders: Array<{ pred: (m: string) => boolean; result: unknown }> = [];
46
+ const writeListeners: Array<() => void> = [];
47
+
48
+ const reader: PipeReader = {
49
+ getReader: () => stream.getReader(),
50
+ };
51
+
52
+ const push = (obj: unknown): void => {
53
+ const bytes = enc.encode(JSON.stringify(obj));
54
+ const out = new Uint8Array(bytes.length + 1);
55
+ out.set(bytes, 0);
56
+ out[bytes.length] = 0;
57
+ pumpController?.enqueue(out);
58
+ };
59
+
60
+ const writer: PipeWriter = {
61
+ write: (chunk) => {
62
+ const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
63
+ const json = dec.decode(chunk.subarray(0, last));
64
+ try {
65
+ const parsed = JSON.parse(json) as {
66
+ id?: number;
67
+ method?: string;
68
+ params?: unknown;
69
+ sessionId?: string;
70
+ };
71
+ written.push(parsed);
72
+ // Notify listeners.
73
+ const ls = writeListeners.splice(0, writeListeners.length);
74
+ for (const fn of ls) fn();
75
+ // Auto-respond if matched.
76
+ if (typeof parsed.method === "string" && typeof parsed.id === "number") {
77
+ const r = autoResponders.find((a) => a.pred(parsed.method ?? ""));
78
+ if (r) {
79
+ queueMicrotask(() => push({ id: parsed.id, result: r.result }));
80
+ }
81
+ }
82
+ } catch {
83
+ // ignore malformed
84
+ }
85
+ },
86
+ flush: () => undefined,
87
+ end: () => undefined,
88
+ };
89
+
90
+ let resolveExit: ((code: number) => void) | undefined;
91
+ const exited = new Promise<number>((res) => {
92
+ resolveExit = res;
93
+ });
94
+
95
+ let killed = false;
96
+ const proc: ChromiumProcess = {
97
+ userDataDir: "/tmp/fake-mochi-test",
98
+ pid: 0,
99
+ exited,
100
+ reader,
101
+ writer,
102
+ close: async () => {
103
+ if (killed) return;
104
+ killed = true;
105
+ try {
106
+ pumpController?.close();
107
+ } catch {
108
+ // ignore
109
+ }
110
+ resolveExit?.(0);
111
+ },
112
+ };
113
+
114
+ return {
115
+ process: proc,
116
+ written,
117
+ push,
118
+ autoRespond(pred, result) {
119
+ autoResponders.push({ pred, result });
120
+ },
121
+ waitForWrites(n) {
122
+ if (written.length >= n) return Promise.resolve();
123
+ return new Promise<void>((resolve) => {
124
+ const check = (): void => {
125
+ if (written.length >= n) resolve();
126
+ else writeListeners.push(check);
127
+ };
128
+ writeListeners.push(check);
129
+ });
130
+ },
131
+ };
132
+ }
133
+
134
+ const TEST_PROFILE: ProfileV1 = {
135
+ id: "bypass-inject-fixture",
136
+ version: "0.0.0-test",
137
+ engine: "chromium",
138
+ browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" },
139
+ os: { name: "macos", version: "14", arch: "arm64" },
140
+ device: {
141
+ vendor: "Apple",
142
+ model: "Mac14,2",
143
+ cpuFamily: "apple-silicon-m2",
144
+ cores: 8,
145
+ memoryGB: 16,
146
+ },
147
+ display: { width: 1728, height: 1117, dpr: 2, colorDepth: 30, pixelDepth: 30 },
148
+ gpu: {
149
+ vendor: "Apple Inc.",
150
+ renderer: "Apple M2",
151
+ webglUnmaskedVendor: "Google Inc. (Apple)",
152
+ webglUnmaskedRenderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)",
153
+ webglMaxTextureSize: 16384,
154
+ webglMaxColorAttachments: 8,
155
+ webglExtensions: [],
156
+ },
157
+ audio: { contextSampleRate: 48000, audioWorkletLatency: 0.005, destinationMaxChannelCount: 2 },
158
+ fonts: { family: "macos-baseline", list: ["Helvetica"] },
159
+ timezone: "America/Los_Angeles",
160
+ locale: "en-US",
161
+ languages: ["en-US", "en"],
162
+ behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" },
163
+ wreqPreset: "chrome_131_macos",
164
+ userAgent:
165
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36",
166
+ uaCh: {},
167
+ entropyBudget: { fixed: [], perSeed: [] },
168
+ };
169
+
170
+ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
171
+ let fake: FakeBrowser;
172
+ let session: Session | undefined;
173
+
174
+ beforeEach(() => {
175
+ fake = makeFakeBrowser();
176
+ session = undefined;
177
+ });
178
+
179
+ afterEach(async () => {
180
+ if (session !== undefined) {
181
+ try {
182
+ await session.close();
183
+ } catch {
184
+ // best effort
185
+ }
186
+ }
187
+ });
188
+
189
+ it("with bypassInject:true — newPage() never sends Page.addScriptToEvaluateOnNewDocument", async () => {
190
+ const matrix = deriveMatrix(TEST_PROFILE, "bypass-test");
191
+ session = new Session({
192
+ proc: fake.process,
193
+ matrix,
194
+ seed: "bypass-test",
195
+ bypassInject: true,
196
+ });
197
+
198
+ // Auto-respond to the small set of CDP calls Session/newPage drives:
199
+ // Target.setAutoAttach (constructor), Target.createTarget, Target.attachToTarget,
200
+ // and Page.enable. These are the *only* writes we expect.
201
+ fake.autoRespond((m) => m === "Target.setAutoAttach", {});
202
+ fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-1" });
203
+ fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-1" });
204
+ fake.autoRespond((m) => m === "Page.enable", {});
205
+ fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
206
+ fake.autoRespond((m) => m === "Page.removeScriptToEvaluateOnNewDocument", {});
207
+ fake.autoRespond((m) => m === "Page.addScriptToEvaluateOnNewDocument", {
208
+ identifier: "should-never-fire",
209
+ });
210
+
211
+ const page = await session.newPage();
212
+ expect(page).toBeDefined();
213
+
214
+ const methods = fake.written
215
+ .map((w) => w.method)
216
+ .filter((m): m is string => typeof m === "string");
217
+ expect(methods).toContain("Target.createTarget");
218
+ expect(methods).toContain("Target.attachToTarget");
219
+ expect(methods).toContain("Page.enable");
220
+ // The contract: ZERO addScriptToEvaluateOnNewDocument sends.
221
+ expect(methods).not.toContain("Page.addScriptToEvaluateOnNewDocument");
222
+ // And no Runtime.evaluate either (worker injection is also bypassed).
223
+ expect(methods).not.toContain("Runtime.evaluate");
224
+ });
225
+
226
+ it("with bypassInject:true — _internalPayload() is null", () => {
227
+ const matrix = deriveMatrix(TEST_PROFILE, "null-payload");
228
+ session = new Session({
229
+ proc: fake.process,
230
+ matrix,
231
+ seed: "null-payload",
232
+ bypassInject: true,
233
+ });
234
+ expect(session._internalPayload()).toBeNull();
235
+ expect(session._internalBypassInject()).toBe(true);
236
+ });
237
+
238
+ it("with bypassInject omitted — _internalPayload() is non-null and newPage installs the inject script", async () => {
239
+ const matrix = deriveMatrix(TEST_PROFILE, "default-inject");
240
+ session = new Session({
241
+ proc: fake.process,
242
+ matrix,
243
+ seed: "default-inject",
244
+ });
245
+ expect(session._internalBypassInject()).toBe(false);
246
+ const payload = session._internalPayload();
247
+ expect(payload).not.toBeNull();
248
+ expect(payload?.code.length ?? 0).toBeGreaterThan(0);
249
+
250
+ fake.autoRespond((m) => m === "Target.setAutoAttach", {});
251
+ fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-2" });
252
+ fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-2" });
253
+ fake.autoRespond((m) => m === "Page.enable", {});
254
+ fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
255
+ fake.autoRespond((m) => m === "Page.removeScriptToEvaluateOnNewDocument", {});
256
+ fake.autoRespond((m) => m === "Page.addScriptToEvaluateOnNewDocument", {
257
+ identifier: "inj-1",
258
+ });
259
+
260
+ const page = await session.newPage();
261
+ expect(page).toBeDefined();
262
+
263
+ const methods = fake.written
264
+ .map((w) => w.method)
265
+ .filter((m): m is string => typeof m === "string");
266
+ // Default behavior: the inject script IS installed.
267
+ expect(methods).toContain("Page.addScriptToEvaluateOnNewDocument");
268
+ // And the params carry the compiled payload code.
269
+ const installCall = fake.written.find(
270
+ (w) => w.method === "Page.addScriptToEvaluateOnNewDocument",
271
+ );
272
+ const params = installCall?.params as { source?: string; runImmediately?: boolean } | undefined;
273
+ expect(params?.source).toBe(payload?.code);
274
+ expect(params?.runImmediately).toBe(true);
275
+ });
276
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * End-to-end integration test: launch real Chromium, navigate to a data URL,
3
+ * and read page state.
4
+ *
5
+ * Gated by `MOCHI_E2E=1` so the default `bun test` run stays fast and offline.
6
+ * Set `MOCHI_CHROMIUM_PATH` (or rely on the @mochi.js/cli resolveChromiumBinary
7
+ * once 0010 lands) to pick the binary.
8
+ *
9
+ * Budget: < 10 seconds total.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import { existsSync } from "node:fs";
14
+ import { mochi } from "../index";
15
+
16
+ const E2E_ENABLED = process.env.MOCHI_E2E === "1";
17
+ const TEST_TIMEOUT_MS = 10_000;
18
+
19
+ const describeOrSkip = E2E_ENABLED ? describe : describe.skip;
20
+
21
+ describeOrSkip("@mochi.js/core E2E (MOCHI_E2E=1)", () => {
22
+ it(
23
+ "launches Chromium, navigates to a data URL, reads text + content, closes",
24
+ async () => {
25
+ const session = await mochi.launch({
26
+ profile: "test",
27
+ seed: "e2e",
28
+ // Headless so CI / non-interactive runs work cleanly.
29
+ headless: true,
30
+ });
31
+ try {
32
+ // Sanity: the user-data-dir was created and the matrix carries our seed.
33
+ expect(session.seed).toBe("e2e");
34
+ expect(session.profile.seed).toBe("e2e");
35
+
36
+ const page = await session.newPage();
37
+ await page.goto("data:text/html,<title>hi</title><h1>world</h1>");
38
+ const text = await page.text("h1");
39
+ expect(text).toBe("world");
40
+ const html = await page.content();
41
+ expect(html).toContain("<title>hi</title>");
42
+ } finally {
43
+ await session.close();
44
+ }
45
+ },
46
+ TEST_TIMEOUT_MS,
47
+ );
48
+
49
+ it(
50
+ "removes the user-data-dir on close()",
51
+ async () => {
52
+ const session = await mochi.launch({ profile: "test", seed: "x", headless: true });
53
+ const dir = session._internalUserDataDir();
54
+ expect(existsSync(dir)).toBe(true);
55
+ await session.close();
56
+ expect(existsSync(dir)).toBe(false);
57
+ },
58
+ TEST_TIMEOUT_MS,
59
+ );
60
+ });