@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 +19 -10
- package/package.json +4 -4
- package/src/__tests__/cookies-jar.test.ts +361 -0
- package/src/__tests__/default-profile.test.ts +181 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +245 -0
- package/src/__tests__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -164
- package/src/__tests__/page-dx-cluster.test.ts +292 -0
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proxy-auth.test.ts +22 -55
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/cdp/init-injector.ts +644 -0
- package/src/default-profile.ts +112 -0
- package/src/index.ts +33 -1
- package/src/launch.ts +199 -10
- package/src/linux-server.ts +157 -0
- package/src/page.ts +410 -8
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +367 -68
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: "
|
|
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("
|
|
19
|
+
await page.humanClick("a");
|
|
20
20
|
await session.close();
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Status
|
|
24
24
|
|
|
25
|
-
|
|
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
|
|
27
|
+
The full [v0.1.4 → v0.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
|
|
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
|
-
|
|
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
|
+
"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.
|
|
52
|
+
"@mochi.js/behavioral": "^0.1.4",
|
|
53
53
|
"@mochi.js/challenges": "^0.2.1",
|
|
54
|
-
"@mochi.js/consistency": "^0.1.
|
|
55
|
-
"@mochi.js/inject": "^0.
|
|
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
|
+
});
|