@mochi.js/core 0.3.0 → 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 CHANGED
@@ -10,28 +10,32 @@ bun add @mochi.js/core
10
10
  import { mochi } from "@mochi.js/core";
11
11
 
12
12
  const session = await mochi.launch({
13
- profile: "mac-m2-chrome-stable",
13
+ profile: "linux-chrome-stable",
14
14
  seed: "user-12345",
15
15
  });
16
16
 
17
17
  const page = await session.newPage();
18
18
  await page.goto("https://example.com");
19
- await page.humanClick("#submit");
19
+ await page.humanClick("a");
20
20
  await session.close();
21
21
  ```
22
22
 
23
23
  ## Status
24
24
 
25
- **v0.0.1 claim release.** The surface above is the contract; the implementation lands incrementally per the project roadmap. Calling `mochi.launch()` at v0.0.1 throws `NotImplementedError` with a pointer to the repo.
25
+ `v0.4.x` (v0.2 wave-4 surfaces). `mochi.launch()` is fully wired: pipe-mode CDP transport, relational `(profile, seed)` Matrix, JIT-friendly inject delivered via `Fetch.fulfillRequest` body splice (with `Page.addScriptToEvaluateOnNewDocument({ runImmediately: true, worldName: "" })` as the `about:blank` / `data:` fallback), behavioral synth, and JA4-coherent `session.fetch` via Rust+wreq.
26
26
 
27
- The full surface lands in phases 0.1 → 1.0. Watch the repo for v0.1 (CDP transport) and v1.0 (the production release).
27
+ The full [v0.1.4v0.2] surface lands as additive minor bumps. See [`CHANGELOG.md`](https://github.com/0xchasercat/mochi/blob/main/CHANGELOG.md).
28
28
 
29
29
  ## What this package gives you
30
30
 
31
- - `mochi.launch(opts)` — spawn a Chromium-for-Testing instance with a relationally-locked fingerprint matrix derived from `(profile, seed)`.
31
+ - `mochi.launch(opts)` — spawn a Chromium-for-Testing instance with a relationally-locked fingerprint matrix derived from `(profile, seed)`. Options include `proxy`, `headless`, `binary`, `timeout`, `geoConsistency` (IP/TZ/locale exit reconciliation), and `challenges` (Turnstile auto-click).
32
32
  - `Session` and `Page` — the runtime objects you drive.
33
- - `page.humanClick / humanType / humanScroll` — biomechanically-shaped input synthesis.
34
- - `session.fetch` — out-of-band requests with profile-matching JA3/JA4 via the Rust+wreq backend.
33
+ - `page.humanClick / humanType / humanScroll` — biomechanically-shaped input synthesis (Bezier + Fitts + Gaussian jitter).
34
+ - `session.fetch` — out-of-band requests with profile-matching JA3/JA4/H2 via the Rust+wreq backend.
35
+ - `page.screenshot(opts?)` — PNG / JPEG / WebP via CDP `Page.captureScreenshot`. Options: `format`, `quality`, `fullPage`, `clip`, `omitBackground`, `encoding`. Element-bounded capture (`{ element: handle }`) is deferred — see <https://mochijs.com/docs/reference/limits>.
36
+ - `session.cookies.{save,load}(path, { pattern? })` — JSON cookie jar with version header + regex domain filter. Round-trips losslessly via `Storage.getCookies` / `Storage.setCookies`.
37
+ - `page.localStorage.{get,set}` and `page.sessionStorage.{get,set}` — direct `DOMStorage` CDP access, frame-scoped (defaults to current main-frame origin; pass `{ origin }` for cross-origin).
38
+ - `page.grantAllPermissions(opts?)` — wraps `Browser.grantPermissions` with the full `ALL_BROWSER_PERMISSIONS` descriptor list.
35
39
 
36
40
  All of this is the single import. No mixing Patchright + a fingerprint injector + a Turnstile clicker. mochi solves it once.
37
41
 
@@ -39,8 +43,8 @@ All of this is the single import. No mixing Patchright + a fingerprint injector
39
43
 
40
44
  - **Bun-only.** No Node fallback. Engines: `bun >= 1.1`.
41
45
  - **Stock Chromium.** No patched fork. Works against [Chromium-for-Testing](https://googlechromelabs.github.io/chrome-for-testing/), pinned and downloadable via `mochi browsers install`.
42
- - **Relational locking.** Every fingerprint surface (canvas, WebGL, audio, fonts, timing) derives from a single `(profile, seed)` pair. No Frankenstein fingerprints.
43
- - **Zero-jitter spoofing.** TurboFan-friendly proxies installed via `Page.addScriptToEvaluateOnNewDocument(runImmediately:true)` before any page script. No async round-trips when a WAF probes.
46
+ - **Relational locking.** Every fingerprint surface (canvas, WebGL, audio, fonts, timing) derives from a single `(profile, seed)` pair. No Frankenstein fingerprints. Audio + canvas digests are byte-exact via precomputed per-(profile, sample-rate) blobs (R-047 / R-048).
47
+ - **Zero-jitter spoofing.** TurboFan-friendly proxies installed before any page script. Init-script delivery via `Fetch.fulfillRequest` body splice closes the source-attribution leak that bare `addScriptToEvaluateOnNewDocument` would otherwise carry.
44
48
 
45
49
  ## License
46
50
 
@@ -50,4 +54,9 @@ MIT.
50
54
 
51
55
  - [Repo](https://github.com/0xchasercat/mochi)
52
56
  - [PLAN.md](https://github.com/0xchasercat/mochi/blob/main/PLAN.md) — the full design contract
53
- - [Limits](https://github.com/0xchasercat/mochi/blob/main/docs/limits.md) — what the JS-only ceiling honestly does and doesn't cover
57
+
58
+ ## Documentation
59
+
60
+ - Package reference: <https://mochijs.com/docs/api/core>
61
+ - Concept deep-dive: <https://mochijs.com/docs/concepts/stealth-philosophy>
62
+ - Cookbook: <https://mochijs.com/docs/guides/pick-a-scenario>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mochi.js/core",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
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.3",
52
+ "@mochi.js/behavioral": "^0.1.4",
53
53
  "@mochi.js/challenges": "^0.2.1",
54
- "@mochi.js/consistency": "^0.1.2",
55
- "@mochi.js/inject": "^0.2.1",
54
+ "@mochi.js/consistency": "^0.1.3",
55
+ "@mochi.js/inject": "^0.3.0",
56
56
  "@mochi.js/net": "^0.1.1"
57
57
  },
58
58
  "publishConfig": {
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Unit tests for the {@link Session.cookies} jar surface (task 0257):
3
+ *
4
+ * - `cookies.get()` → `Storage.getCookies` round-trip with optional
5
+ * url-host filter.
6
+ * - `cookies.set(cookies)` → `Storage.setCookies` round-trip.
7
+ * - `cookies.save(path)` → JSON file with the {@link CookieJarFile}
8
+ * header (`version`, `savedAt`, `mochiVersion`,
9
+ * `pattern`, `count`) and the filtered cookies
10
+ * array.
11
+ * - `cookies.load(path)` → reads the file, validates the version, and
12
+ * replays via `Storage.setCookies`.
13
+ *
14
+ * Round-trip property: `save → load → get` returns a set equal to what
15
+ * `get` originally returned (modulo the regex filter). We verify the wire
16
+ * shape too — the saved JSON is the contract test's reference shape.
17
+ *
18
+ * No real Chromium process is spawned; we drive `Session` against a fake
19
+ * `ChromiumProcess` whose pipe reader/writer let us observe every CDP
20
+ * request sent and inject canned responses.
21
+ *
22
+ * @see tasks/0257-dx-cluster-cookies-storage-permissions.md
23
+ * @see docs/audits/nodriver.md (LOW finding 2 — pickle → JSON port)
24
+ */
25
+
26
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
27
+ import { rmSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { deriveMatrix, type ProfileV1 } from "@mochi.js/consistency";
31
+ import type { PipeReader, PipeWriter } from "../cdp/transport";
32
+ import type { Cookie } from "../page";
33
+ import type { ChromiumProcess } from "../proc";
34
+ import { COOKIE_JAR_FORMAT_VERSION, type CookieJarFile, Session } from "../session";
35
+
36
+ interface FakeBrowser {
37
+ process: ChromiumProcess;
38
+ written: Array<{ id?: number; method?: string; params?: unknown; sessionId?: string }>;
39
+ push(obj: unknown): void;
40
+ autoRespond(methodPredicate: (m: string) => boolean, result: unknown): void;
41
+ }
42
+
43
+ function makeFakeBrowser(): FakeBrowser {
44
+ const written: FakeBrowser["written"] = [];
45
+ let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
46
+ const stream = new ReadableStream<Uint8Array>({
47
+ start(c) {
48
+ pumpController = c;
49
+ },
50
+ });
51
+ const enc = new TextEncoder();
52
+ const dec = new TextDecoder();
53
+ const autoResponders: Array<{ pred: (m: string) => boolean; result: unknown }> = [];
54
+
55
+ const reader: PipeReader = { getReader: () => stream.getReader() };
56
+
57
+ const push = (obj: unknown): void => {
58
+ const bytes = enc.encode(JSON.stringify(obj));
59
+ const out = new Uint8Array(bytes.length + 1);
60
+ out.set(bytes, 0);
61
+ out[bytes.length] = 0;
62
+ pumpController?.enqueue(out);
63
+ };
64
+
65
+ const writer: PipeWriter = {
66
+ write: (chunk) => {
67
+ const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
68
+ const json = dec.decode(chunk.subarray(0, last));
69
+ try {
70
+ const parsed = JSON.parse(json) as {
71
+ id?: number;
72
+ method?: string;
73
+ params?: unknown;
74
+ sessionId?: string;
75
+ };
76
+ written.push(parsed);
77
+ if (typeof parsed.method === "string" && typeof parsed.id === "number") {
78
+ const r = autoResponders.find((a) => a.pred(parsed.method ?? ""));
79
+ if (r) {
80
+ queueMicrotask(() => push({ id: parsed.id, result: r.result }));
81
+ }
82
+ }
83
+ } catch {
84
+ // ignore
85
+ }
86
+ },
87
+ flush: () => undefined,
88
+ end: () => undefined,
89
+ };
90
+
91
+ let resolveExit: ((code: number) => void) | undefined;
92
+ const exited = new Promise<number>((res) => {
93
+ resolveExit = res;
94
+ });
95
+ let killed = false;
96
+ const proc: ChromiumProcess = {
97
+ userDataDir: "/tmp/fake-mochi-cookies-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
+ };
122
+ }
123
+
124
+ const TEST_PROFILE: ProfileV1 = {
125
+ id: "cookies-jar-fixture",
126
+ version: "0.0.0-test",
127
+ engine: "chromium",
128
+ browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" },
129
+ os: { name: "macos", version: "14", arch: "arm64" },
130
+ device: {
131
+ vendor: "Apple",
132
+ model: "Mac14,2",
133
+ cpuFamily: "apple-silicon-m2",
134
+ cores: 8,
135
+ memoryGB: 16,
136
+ },
137
+ display: { width: 1728, height: 1117, dpr: 2, colorDepth: 30, pixelDepth: 30 },
138
+ gpu: {
139
+ vendor: "Apple Inc.",
140
+ renderer: "Apple M2",
141
+ webglUnmaskedVendor: "Apple Inc.",
142
+ webglUnmaskedRenderer: "Apple M2",
143
+ webglMaxTextureSize: 16384,
144
+ webglMaxColorAttachments: 8,
145
+ webglExtensions: [],
146
+ },
147
+ audio: { contextSampleRate: 48000, audioWorkletLatency: 0.005, destinationMaxChannelCount: 2 },
148
+ fonts: { family: "macos-baseline", list: ["Helvetica"] },
149
+ timezone: "America/Los_Angeles",
150
+ locale: "en-US",
151
+ languages: ["en-US", "en"],
152
+ behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" },
153
+ wreqPreset: "chrome_131_macos",
154
+ userAgent: "Mozilla/5.0 (cookies-test)",
155
+ uaCh: {},
156
+ entropyBudget: { fixed: [], perSeed: [] },
157
+ };
158
+
159
+ const SAMPLE_COOKIES: Cookie[] = [
160
+ {
161
+ name: "session_id",
162
+ value: "abc",
163
+ domain: ".example.com",
164
+ path: "/",
165
+ expires: 1_800_000_000,
166
+ size: 12,
167
+ httpOnly: true,
168
+ secure: true,
169
+ session: false,
170
+ sameSite: "Lax",
171
+ },
172
+ {
173
+ name: "consent",
174
+ value: "1",
175
+ domain: "tracker.io",
176
+ path: "/",
177
+ expires: 1_900_000_000,
178
+ size: 4,
179
+ httpOnly: false,
180
+ secure: true,
181
+ session: false,
182
+ },
183
+ {
184
+ name: "ab_test",
185
+ value: "B",
186
+ domain: ".example.com",
187
+ path: "/",
188
+ expires: -1,
189
+ size: 5,
190
+ httpOnly: false,
191
+ secure: false,
192
+ session: true,
193
+ },
194
+ ];
195
+
196
+ describe("Session.cookies (task 0257)", () => {
197
+ let fake: FakeBrowser;
198
+ let session: Session;
199
+ let tmpFile: string;
200
+
201
+ beforeEach(() => {
202
+ fake = makeFakeBrowser();
203
+ fake.autoRespond((m) => m === "Target.setAutoAttach", {});
204
+ const matrix = deriveMatrix(TEST_PROFILE, "cookies-test");
205
+ session = new Session({
206
+ proc: fake.process,
207
+ matrix,
208
+ seed: "cookies-test",
209
+ bypassInject: true,
210
+ });
211
+ tmpFile = join(
212
+ tmpdir(),
213
+ `mochi-cookies-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
214
+ );
215
+ });
216
+
217
+ afterEach(async () => {
218
+ try {
219
+ await session.close();
220
+ } catch {
221
+ // ignore
222
+ }
223
+ try {
224
+ rmSync(tmpFile, { force: true });
225
+ } catch {
226
+ // ignore
227
+ }
228
+ });
229
+
230
+ it("get() returns Storage.getCookies result verbatim when no filter", async () => {
231
+ fake.autoRespond((m) => m === "Storage.getCookies", { cookies: SAMPLE_COOKIES });
232
+ const got = await session.cookies.get();
233
+ expect(got).toEqual(SAMPLE_COOKIES);
234
+ const call = fake.written.find((w) => w.method === "Storage.getCookies");
235
+ expect(call).toBeDefined();
236
+ });
237
+
238
+ it("get({ url }) filters by hostname", async () => {
239
+ fake.autoRespond((m) => m === "Storage.getCookies", { cookies: SAMPLE_COOKIES });
240
+ const got = await session.cookies.get({ url: "https://example.com/path" });
241
+ // .example.com matches "example.com" (endsWith on either direction).
242
+ const names = got.map((c) => c.name).sort();
243
+ expect(names).toEqual(["ab_test", "session_id"]);
244
+ });
245
+
246
+ it("set(cookies) sends Storage.setCookies with the full array", async () => {
247
+ fake.autoRespond((m) => m === "Storage.setCookies", {});
248
+ await session.cookies.set(SAMPLE_COOKIES);
249
+ const call = fake.written.find((w) => w.method === "Storage.setCookies");
250
+ expect(call).toBeDefined();
251
+ expect(call?.params).toEqual({ cookies: SAMPLE_COOKIES });
252
+ });
253
+
254
+ it("save() writes JSON with the version header + filtered cookies", async () => {
255
+ fake.autoRespond((m) => m === "Storage.getCookies", { cookies: SAMPLE_COOKIES });
256
+ await session.cookies.save(tmpFile);
257
+ const text = await Bun.file(tmpFile).text();
258
+ const parsed = JSON.parse(text) as CookieJarFile;
259
+ expect(parsed.version).toBe(COOKIE_JAR_FORMAT_VERSION);
260
+ expect(typeof parsed.savedAt).toBe("string");
261
+ expect(parsed.savedAt.endsWith("Z")).toBe(true);
262
+ expect(typeof parsed.mochiVersion).toBe("string");
263
+ expect(parsed.pattern).toBe(".*");
264
+ expect(parsed.count).toBe(SAMPLE_COOKIES.length);
265
+ expect(parsed.cookies).toEqual(SAMPLE_COOKIES);
266
+ });
267
+
268
+ it("save({ pattern }) only writes matching domains", async () => {
269
+ fake.autoRespond((m) => m === "Storage.getCookies", { cookies: SAMPLE_COOKIES });
270
+ await session.cookies.save(tmpFile, { pattern: /example\.com$/ });
271
+ const parsed = JSON.parse(await Bun.file(tmpFile).text()) as CookieJarFile;
272
+ expect(parsed.pattern).toBe("example\\.com$");
273
+ expect(parsed.count).toBe(2);
274
+ expect(parsed.cookies.every((c) => c.domain.endsWith("example.com"))).toBe(true);
275
+ });
276
+
277
+ it("load() round-trips: file contents replay via Storage.setCookies", async () => {
278
+ // Stage: save once, then forget the in-memory state and load it back.
279
+ fake.autoRespond((m) => m === "Storage.getCookies", { cookies: SAMPLE_COOKIES });
280
+ fake.autoRespond((m) => m === "Storage.setCookies", {});
281
+ await session.cookies.save(tmpFile);
282
+
283
+ await session.cookies.load(tmpFile);
284
+ const setCall = fake.written.find((w) => w.method === "Storage.setCookies");
285
+ expect(setCall).toBeDefined();
286
+ expect(setCall?.params).toEqual({ cookies: SAMPLE_COOKIES });
287
+ });
288
+
289
+ it("load({ pattern }) skips cookies that don't match", async () => {
290
+ fake.autoRespond((m) => m === "Storage.getCookies", { cookies: SAMPLE_COOKIES });
291
+ fake.autoRespond((m) => m === "Storage.setCookies", {});
292
+ await session.cookies.save(tmpFile);
293
+
294
+ await session.cookies.load(tmpFile, { pattern: /tracker/ });
295
+ const setCall = fake.written.find((w) => w.method === "Storage.setCookies");
296
+ expect(setCall).toBeDefined();
297
+ const params = setCall?.params as { cookies: Cookie[] };
298
+ expect(params.cookies.length).toBe(1);
299
+ expect(params.cookies[0]?.domain).toBe("tracker.io");
300
+ });
301
+
302
+ it("load() throws when the file is missing", async () => {
303
+ let threw = false;
304
+ try {
305
+ await session.cookies.load(`${tmpFile}-missing`);
306
+ } catch (err) {
307
+ threw = true;
308
+ expect(String(err)).toContain("file not found");
309
+ }
310
+ expect(threw).toBe(true);
311
+ });
312
+
313
+ it("load() throws on version mismatch", async () => {
314
+ const bad = {
315
+ version: 999,
316
+ savedAt: "2026-05-09T00:00:00Z",
317
+ mochiVersion: "x",
318
+ pattern: ".*",
319
+ count: 0,
320
+ cookies: [],
321
+ };
322
+ await Bun.write(tmpFile, JSON.stringify(bad));
323
+ let threw = false;
324
+ try {
325
+ await session.cookies.load(tmpFile);
326
+ } catch (err) {
327
+ threw = true;
328
+ expect(String(err)).toContain("version");
329
+ }
330
+ expect(threw).toBe(true);
331
+ });
332
+
333
+ it("load() throws on malformed JSON", async () => {
334
+ await Bun.write(tmpFile, "not json {");
335
+ let threw = false;
336
+ try {
337
+ await session.cookies.load(tmpFile);
338
+ } catch (err) {
339
+ threw = true;
340
+ expect(String(err)).toContain("not valid JSON");
341
+ }
342
+ expect(threw).toBe(true);
343
+ });
344
+
345
+ it("save() then load() is idempotent on the saved set", async () => {
346
+ fake.autoRespond((m) => m === "Storage.getCookies", { cookies: SAMPLE_COOKIES });
347
+ fake.autoRespond((m) => m === "Storage.setCookies", {});
348
+ await session.cookies.save(tmpFile);
349
+
350
+ // Load once.
351
+ await session.cookies.load(tmpFile);
352
+ const calls1 = fake.written.filter((w) => w.method === "Storage.setCookies");
353
+ expect(calls1.length).toBe(1);
354
+
355
+ // Load again — same wire shape.
356
+ await session.cookies.load(tmpFile);
357
+ const calls2 = fake.written.filter((w) => w.method === "Storage.setCookies");
358
+ expect(calls2.length).toBe(2);
359
+ expect(calls2[0]?.params).toEqual(calls2[1]?.params);
360
+ });
361
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Unit tests for the host-OS profile auto-pick (task 0272).
3
+ *
4
+ * Three layers under test:
5
+ *
6
+ * 1. {@link resolveDefaultProfileForHost} — the pure mapping table.
7
+ * `(platform, arch)` in, `ProfileId | null` out. No I/O, no globals.
8
+ * 2. {@link defaultProfileForHost} — the live wrapper that reads
9
+ * `process.platform` / `process.arch`. Verified by stubbing the
10
+ * `process` properties and asserting the same table holds.
11
+ * 3. The launcher's failure-mode diagnostic (`unsupportedHostMessage`):
12
+ * lists the six explicit profile IDs verbatim and points at the
13
+ * choose-your-profile guide URL. Format is pinned by task 0272 — the
14
+ * docs + LLM-context blocks reference these strings.
15
+ *
16
+ * The "explicit `profile:` always wins" half of the success criteria is
17
+ * exercised against the launcher's `resolveProfileSource` resolver via the
18
+ * exported `defaultProfileForHost` introspection helper. We do NOT spawn
19
+ * Chromium here — the goal is to lock the decisions against regressions
20
+ * without taking the cost of a real launch. Mirrors the
21
+ * `proc-linux-server.test.ts` pattern (task 0258).
22
+ *
23
+ * @see packages/core/src/default-profile.ts
24
+ * @see tasks/0271-the-linux-os-thesis.md (strategic thesis + production evidence)
25
+ * @see tasks/0272-host-os-profile-auto-default.md (engineering brief)
26
+ */
27
+
28
+ import { afterEach, describe, expect, it } from "bun:test";
29
+ import {
30
+ defaultProfileForHost,
31
+ EXPLICIT_PROFILE_IDS,
32
+ resolveDefaultProfileForHost,
33
+ unsupportedHostMessage,
34
+ } from "../default-profile";
35
+
36
+ describe("resolveDefaultProfileForHost — pure mapping table (task 0272)", () => {
37
+ it("linux/x64 → linux-chrome-stable", () => {
38
+ expect(resolveDefaultProfileForHost("linux", "x64")).toBe("linux-chrome-stable");
39
+ });
40
+
41
+ it("darwin/arm64 → mac-m4-chrome-stable", () => {
42
+ expect(resolveDefaultProfileForHost("darwin", "arm64")).toBe("mac-m4-chrome-stable");
43
+ });
44
+
45
+ it("darwin/x64 → mac-chrome-stable", () => {
46
+ expect(resolveDefaultProfileForHost("darwin", "x64")).toBe("mac-chrome-stable");
47
+ });
48
+
49
+ it("win32/x64 → windows-chrome-stable", () => {
50
+ expect(resolveDefaultProfileForHost("win32", "x64")).toBe("windows-chrome-stable");
51
+ });
52
+
53
+ it("linux/arm64 → null (no Linux arm64 capture today)", () => {
54
+ expect(resolveDefaultProfileForHost("linux", "arm64")).toBeNull();
55
+ });
56
+
57
+ it("freebsd/x64 → null (unsupported platform)", () => {
58
+ // `freebsd` is a valid `NodeJS.Platform`. Confirms the resolver fails
59
+ // closed rather than silently routing to a Linux profile.
60
+ expect(resolveDefaultProfileForHost("freebsd", "x64")).toBeNull();
61
+ });
62
+
63
+ it("win32/arm64 → null (no Windows arm64 capture today)", () => {
64
+ expect(resolveDefaultProfileForHost("win32", "arm64")).toBeNull();
65
+ });
66
+
67
+ it("openbsd/x64 → null (unsupported platform — fail closed)", () => {
68
+ expect(resolveDefaultProfileForHost("openbsd", "x64")).toBeNull();
69
+ });
70
+
71
+ it("returns one of the six real-device profile IDs on every supported host", () => {
72
+ // Cross-check: every value the resolver can return MUST be in the
73
+ // EXPLICIT_PROFILE_IDS list — that's the list the unsupported-host
74
+ // diagnostic surfaces, and a resolver that picks an id outside that
75
+ // list would create a documentation drift on the failure path.
76
+ const supported: Array<[NodeJS.Platform, string]> = [
77
+ ["linux", "x64"],
78
+ ["darwin", "arm64"],
79
+ ["darwin", "x64"],
80
+ ["win32", "x64"],
81
+ ];
82
+ for (const [platform, arch] of supported) {
83
+ const id = resolveDefaultProfileForHost(platform, arch);
84
+ expect(id).not.toBeNull();
85
+ expect(EXPLICIT_PROFILE_IDS).toContain(id as (typeof EXPLICIT_PROFILE_IDS)[number]);
86
+ }
87
+ });
88
+ });
89
+
90
+ describe("defaultProfileForHost — live wrapper (task 0272)", () => {
91
+ // Stub `process.platform` / `process.arch` so the live wrapper exercises
92
+ // the same table as the pure resolver. `Object.defineProperty` is the
93
+ // standard Bun-test pattern (matches `proc-linux-server.test.ts`).
94
+ const ORIG_PLATFORM = process.platform;
95
+ const ORIG_ARCH = process.arch;
96
+
97
+ function stub(platform: NodeJS.Platform, arch: string) {
98
+ Object.defineProperty(process, "platform", { value: platform, configurable: true });
99
+ Object.defineProperty(process, "arch", { value: arch, configurable: true });
100
+ }
101
+
102
+ afterEach(() => {
103
+ Object.defineProperty(process, "platform", { value: ORIG_PLATFORM, configurable: true });
104
+ Object.defineProperty(process, "arch", { value: ORIG_ARCH, configurable: true });
105
+ });
106
+
107
+ it("reads (process.platform, process.arch) and returns the mapped id", () => {
108
+ stub("linux", "x64");
109
+ expect(defaultProfileForHost()).toBe("linux-chrome-stable");
110
+
111
+ stub("darwin", "arm64");
112
+ expect(defaultProfileForHost()).toBe("mac-m4-chrome-stable");
113
+
114
+ stub("darwin", "x64");
115
+ expect(defaultProfileForHost()).toBe("mac-chrome-stable");
116
+
117
+ stub("win32", "x64");
118
+ expect(defaultProfileForHost()).toBe("windows-chrome-stable");
119
+ });
120
+
121
+ it("returns null on unsupported hosts (linux arm64, freebsd)", () => {
122
+ stub("linux", "arm64");
123
+ expect(defaultProfileForHost()).toBeNull();
124
+
125
+ stub("freebsd", "x64");
126
+ expect(defaultProfileForHost()).toBeNull();
127
+ });
128
+ });
129
+
130
+ describe("unsupportedHostMessage — failure-mode diagnostic (task 0272)", () => {
131
+ it("names the host platform + arch verbatim", () => {
132
+ const msg = unsupportedHostMessage("freebsd", "x64");
133
+ expect(msg).toContain("platform=freebsd");
134
+ expect(msg).toContain("arch=x64");
135
+ });
136
+
137
+ it("lists every explicit profile id (six total)", () => {
138
+ const msg = unsupportedHostMessage("linux", "arm64");
139
+ for (const id of EXPLICIT_PROFILE_IDS) {
140
+ expect(msg).toContain(id);
141
+ }
142
+ expect(EXPLICIT_PROFILE_IDS.length).toBe(6);
143
+ });
144
+
145
+ it("points at the choose-your-profile guide URL", () => {
146
+ const msg = unsupportedHostMessage("openbsd", "x64");
147
+ expect(msg).toContain("https://mochijs.com/docs/guides/choose-your-profile");
148
+ });
149
+
150
+ it("uses the [mochi] launch: prefix so users grep the right log", () => {
151
+ const msg = unsupportedHostMessage("linux", "arm64");
152
+ expect(msg.startsWith("[mochi] launch:")).toBe(true);
153
+ });
154
+ });
155
+
156
+ describe("introspection contract — defaultProfileForHost is pure (task 0272)", () => {
157
+ it("returns the same value on repeated calls (no caching, no mutation)", () => {
158
+ // The launcher consults this helper at every launch; pinning purity
159
+ // here means a downstream `console.log(mochi.defaultProfileForHost())`
160
+ // is always safe and does not influence subsequent launches.
161
+ const a = defaultProfileForHost();
162
+ const b = defaultProfileForHost();
163
+ expect(a).toBe(b);
164
+ });
165
+
166
+ it("EXPLICIT_PROFILE_IDS lists exactly the six real-device profiles", () => {
167
+ // The unsupported-host diagnostic surfaces this list verbatim; the
168
+ // README LLM-context block + the task 0272 brief reference these IDs
169
+ // by name. Pin the membership here so a docs/code drift surfaces.
170
+ expect(new Set(EXPLICIT_PROFILE_IDS)).toEqual(
171
+ new Set([
172
+ "mac-m4-chrome-stable",
173
+ "mac-chrome-stable",
174
+ "mac-chrome-beta",
175
+ "windows-chrome-stable",
176
+ "linux-chrome-stable",
177
+ "mac-brave-stable",
178
+ ]),
179
+ );
180
+ });
181
+ });