@mochi.js/core 0.2.2 → 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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Unit tests for the geo-probe — exercises the full registry against a
3
+ * mocked `ProbeFetch` (we never hit ipinfo.io in unit tests, per the
4
+ * brief). Covers:
5
+ * - all 7 adapters parse their canonical happy-path JSON.
6
+ * - schema-mismatch JSON returns `null` (not throw).
7
+ * - {@link probeExitGeo} falls through on per-endpoint timeout / non-2xx
8
+ * / parser-null and respects the 4-attempt cap.
9
+ * - all-fail returns `null`.
10
+ *
11
+ * The probe's `fetch` injection seam is an internal — production wires it
12
+ * to `@mochi.js/net`'s `fetch`, which carries the matrix's wreq preset.
13
+ *
14
+ * @see tasks/0262-ip-tz-locale-exit-consistency.md
15
+ * @see packages/core/src/geo-probe.ts
16
+ */
17
+
18
+ import { describe, expect, it } from "bun:test";
19
+ import { ADAPTERS, type ProbeFetch, probeExitGeo } from "../geo-probe";
20
+
21
+ const MATRIX_STUB = { wreqPreset: "chrome_131_macos" };
22
+
23
+ /** Build a `ProbeFetch` that returns canned JSON for each URL. */
24
+ function fakeFetch(
25
+ map: Record<string, { status?: number; body: unknown; delayMs?: number }>,
26
+ ): ProbeFetch {
27
+ return async (url, _init) => {
28
+ const entry = map[url];
29
+ if (entry === undefined) {
30
+ // Default: connection refused / non-2xx 599.
31
+ return new Response("", { status: 599 });
32
+ }
33
+ if (entry.delayMs !== undefined && entry.delayMs > 0) {
34
+ await new Promise((r) => setTimeout(r, entry.delayMs));
35
+ }
36
+ const status = entry.status ?? 200;
37
+ return new Response(JSON.stringify(entry.body), {
38
+ status,
39
+ headers: { "content-type": "application/json" },
40
+ });
41
+ };
42
+ }
43
+
44
+ /** Identity shuffle so adapter order is stable per test. */
45
+ const noShuffle = <T>(xs: readonly T[]): readonly T[] => xs;
46
+
47
+ describe("ADAPTERS — happy-path schema parsing", () => {
48
+ function parseFor(url: string): (json: unknown) => unknown {
49
+ const adapter = ADAPTERS.find((a) => a.url === url);
50
+ if (adapter === undefined) throw new Error(`no adapter for ${url}`);
51
+ return adapter.parse;
52
+ }
53
+
54
+ it("ip.decodo.com/json", () => {
55
+ const parsed = parseFor("https://ip.decodo.com/json")({
56
+ proxy: { ip: "1.1.1.1" },
57
+ country: { code: "us" },
58
+ city: {
59
+ name: "San Francisco",
60
+ state: "California",
61
+ time_zone: "America/Los_Angeles",
62
+ zip_code: "94103",
63
+ latitude: 37.77,
64
+ longitude: -122.41,
65
+ },
66
+ });
67
+ expect(parsed).toEqual({
68
+ ip: "1.1.1.1",
69
+ country: "US",
70
+ city: "San Francisco",
71
+ region: "California",
72
+ timezone: "America/Los_Angeles",
73
+ postalCode: "94103",
74
+ lat: 37.77,
75
+ lng: -122.41,
76
+ source: "decodo",
77
+ });
78
+ });
79
+
80
+ it("ipinfo.io/json (parses loc)", () => {
81
+ const parsed = parseFor("https://ipinfo.io/json")({
82
+ ip: "2.2.2.2",
83
+ country: "DE",
84
+ city: "Berlin",
85
+ region: "Berlin",
86
+ timezone: "Europe/Berlin",
87
+ postal: "10115",
88
+ loc: "52.52,13.40",
89
+ });
90
+ expect(parsed).toEqual({
91
+ ip: "2.2.2.2",
92
+ country: "DE",
93
+ city: "Berlin",
94
+ region: "Berlin",
95
+ timezone: "Europe/Berlin",
96
+ postalCode: "10115",
97
+ lat: 52.52,
98
+ lng: 13.4,
99
+ source: "ipinfo",
100
+ });
101
+ });
102
+
103
+ it("ipwho.is/", () => {
104
+ const parsed = parseFor("https://ipwho.is/")({
105
+ ip: "3.3.3.3",
106
+ country_code: "TH",
107
+ city: "Bangkok",
108
+ region: "Bangkok",
109
+ timezone: { id: "Asia/Bangkok" },
110
+ postal: "10100",
111
+ latitude: 13.75,
112
+ longitude: 100.5,
113
+ });
114
+ expect(parsed).toEqual({
115
+ ip: "3.3.3.3",
116
+ country: "TH",
117
+ city: "Bangkok",
118
+ region: "Bangkok",
119
+ timezone: "Asia/Bangkok",
120
+ postalCode: "10100",
121
+ lat: 13.75,
122
+ lng: 100.5,
123
+ source: "ipwhois",
124
+ });
125
+ });
126
+
127
+ it("ipwho.is — success:false returns null", () => {
128
+ const parsed = parseFor("https://ipwho.is/")({ success: false, message: "blocked" });
129
+ expect(parsed).toBeNull();
130
+ });
131
+
132
+ it("api.ip.sb/geoip", () => {
133
+ const parsed = parseFor("https://api.ip.sb/geoip")({
134
+ ip: "4.4.4.4",
135
+ country_code: "JP",
136
+ country: "Japan",
137
+ city: "Tokyo",
138
+ region: "Tokyo",
139
+ timezone: "Asia/Tokyo",
140
+ latitude: 35.68,
141
+ longitude: 139.69,
142
+ });
143
+ expect(parsed).toEqual({
144
+ ip: "4.4.4.4",
145
+ country: "JP",
146
+ city: "Tokyo",
147
+ region: "Tokyo",
148
+ timezone: "Asia/Tokyo",
149
+ lat: 35.68,
150
+ lng: 139.69,
151
+ source: "ipsb",
152
+ });
153
+ });
154
+
155
+ it("ifconfig.co/json", () => {
156
+ const parsed = parseFor("https://ifconfig.co/json")({
157
+ ip: "5.5.5.5",
158
+ country_iso: "GB",
159
+ country: "United Kingdom",
160
+ city: "London",
161
+ region_name: "England",
162
+ time_zone: "Europe/London",
163
+ zip_code: "SW1A",
164
+ latitude: 51.5,
165
+ longitude: -0.12,
166
+ });
167
+ expect(parsed).toEqual({
168
+ ip: "5.5.5.5",
169
+ country: "GB",
170
+ city: "London",
171
+ region: "England",
172
+ timezone: "Europe/London",
173
+ postalCode: "SW1A",
174
+ lat: 51.5,
175
+ lng: -0.12,
176
+ source: "ifconfig",
177
+ });
178
+ });
179
+
180
+ it("api.iplocation.net — always null (country-only schema, no tz)", () => {
181
+ const parsed = parseFor("https://api.iplocation.net/")({
182
+ ip: "6.6.6.6",
183
+ country_code2: "US",
184
+ });
185
+ expect(parsed).toBeNull();
186
+ });
187
+
188
+ it("ipapi.co/json — error:true returns null (rate-limited)", () => {
189
+ const parsed = parseFor("https://ipapi.co/json/")({ error: true, reason: "RateLimited" });
190
+ expect(parsed).toBeNull();
191
+ });
192
+
193
+ it("ipapi.co/json — happy path", () => {
194
+ const parsed = parseFor("https://ipapi.co/json/")({
195
+ ip: "7.7.7.7",
196
+ country_code: "FR",
197
+ country: "France",
198
+ city: "Paris",
199
+ region: "Île-de-France",
200
+ timezone: "Europe/Paris",
201
+ postal: "75001",
202
+ latitude: 48.85,
203
+ longitude: 2.35,
204
+ });
205
+ expect(parsed).toEqual({
206
+ ip: "7.7.7.7",
207
+ country: "FR",
208
+ city: "Paris",
209
+ region: "Île-de-France",
210
+ timezone: "Europe/Paris",
211
+ postalCode: "75001",
212
+ lat: 48.85,
213
+ lng: 2.35,
214
+ source: "ipapi",
215
+ });
216
+ });
217
+ });
218
+
219
+ describe("probeExitGeo — strategy", () => {
220
+ it("first endpoint OK → returns immediately, doesn't probe further", async () => {
221
+ let calls = 0;
222
+ const fetchSpy: ProbeFetch = async (url) => {
223
+ calls += 1;
224
+ if (url === "https://ip.decodo.com/json") {
225
+ return new Response(
226
+ JSON.stringify({
227
+ proxy: { ip: "1.1.1.1" },
228
+ country: { code: "US" },
229
+ city: { time_zone: "America/Los_Angeles" },
230
+ }),
231
+ { status: 200 },
232
+ );
233
+ }
234
+ return new Response("", { status: 599 });
235
+ };
236
+ const geo = await probeExitGeo({
237
+ matrix: MATRIX_STUB,
238
+ fetch: fetchSpy,
239
+ shuffle: noShuffle,
240
+ maxAttempts: 4,
241
+ perEndpointTimeoutMs: 100,
242
+ });
243
+ expect(geo).not.toBeNull();
244
+ expect(geo?.country).toBe("US");
245
+ expect(geo?.source).toBe("decodo");
246
+ expect(calls).toBe(1);
247
+ });
248
+
249
+ it("non-2xx → falls through to next adapter", async () => {
250
+ const fetchSpy = fakeFetch({
251
+ "https://ip.decodo.com/json": { status: 500, body: {} },
252
+ "https://ipinfo.io/json": {
253
+ body: {
254
+ ip: "2.2.2.2",
255
+ country: "GB",
256
+ timezone: "Europe/London",
257
+ },
258
+ },
259
+ });
260
+ const geo = await probeExitGeo({
261
+ matrix: MATRIX_STUB,
262
+ fetch: fetchSpy,
263
+ shuffle: noShuffle,
264
+ maxAttempts: 4,
265
+ perEndpointTimeoutMs: 100,
266
+ });
267
+ expect(geo?.source).toBe("ipinfo");
268
+ expect(geo?.country).toBe("GB");
269
+ });
270
+
271
+ it("schema mismatch (parser returns null) → falls through", async () => {
272
+ const fetchSpy = fakeFetch({
273
+ "https://ip.decodo.com/json": {
274
+ // Missing country.code → adapter returns null.
275
+ body: { proxy: { ip: "1.1.1.1" } },
276
+ },
277
+ "https://ipinfo.io/json": {
278
+ body: { ip: "9.9.9.9", country: "TH", timezone: "Asia/Bangkok" },
279
+ },
280
+ });
281
+ const geo = await probeExitGeo({
282
+ matrix: MATRIX_STUB,
283
+ fetch: fetchSpy,
284
+ shuffle: noShuffle,
285
+ maxAttempts: 4,
286
+ perEndpointTimeoutMs: 100,
287
+ });
288
+ expect(geo?.country).toBe("TH");
289
+ });
290
+
291
+ it("per-endpoint timeout fires → falls through", async () => {
292
+ const fetchSpy = fakeFetch({
293
+ "https://ip.decodo.com/json": { delayMs: 200, body: {} }, // overshoots 50ms cap
294
+ "https://ipinfo.io/json": {
295
+ body: { ip: "1.1.1.1", country: "US", timezone: "America/Los_Angeles" },
296
+ },
297
+ });
298
+ const geo = await probeExitGeo({
299
+ matrix: MATRIX_STUB,
300
+ fetch: fetchSpy,
301
+ shuffle: noShuffle,
302
+ maxAttempts: 4,
303
+ perEndpointTimeoutMs: 50,
304
+ });
305
+ expect(geo?.source).toBe("ipinfo");
306
+ });
307
+
308
+ it("all attempts fail (non-2xx + parser-null) → returns null", async () => {
309
+ // Empty map → every URL returns 599.
310
+ const fetchSpy = fakeFetch({});
311
+ const geo = await probeExitGeo({
312
+ matrix: MATRIX_STUB,
313
+ fetch: fetchSpy,
314
+ shuffle: noShuffle,
315
+ maxAttempts: 4,
316
+ perEndpointTimeoutMs: 50,
317
+ });
318
+ expect(geo).toBeNull();
319
+ });
320
+
321
+ it("respects 4-attempt cap (doesn't burn through all 7)", async () => {
322
+ let calls = 0;
323
+ const fetchSpy: ProbeFetch = async () => {
324
+ calls += 1;
325
+ return new Response("", { status: 599 });
326
+ };
327
+ const geo = await probeExitGeo({
328
+ matrix: MATRIX_STUB,
329
+ fetch: fetchSpy,
330
+ shuffle: noShuffle,
331
+ maxAttempts: 4,
332
+ perEndpointTimeoutMs: 50,
333
+ });
334
+ expect(geo).toBeNull();
335
+ expect(calls).toBe(4);
336
+ });
337
+
338
+ it("forwards proxy + matrix.wreqPreset to the fetch impl", async () => {
339
+ let captured: { url?: string; preset?: string; proxy?: string } = {};
340
+ const fetchSpy: ProbeFetch = async (url, init) => {
341
+ captured = { url, preset: init.preset, proxy: init.proxy };
342
+ return new Response(
343
+ JSON.stringify({
344
+ proxy: { ip: "1" },
345
+ country: { code: "US" },
346
+ city: { time_zone: "America/Los_Angeles" },
347
+ }),
348
+ { status: 200 },
349
+ );
350
+ };
351
+ await probeExitGeo({
352
+ matrix: { wreqPreset: "chrome_131_linux" },
353
+ proxy: "http://user:pass@proxy.example:8080",
354
+ fetch: fetchSpy,
355
+ shuffle: noShuffle,
356
+ maxAttempts: 1,
357
+ perEndpointTimeoutMs: 100,
358
+ });
359
+ expect(captured.preset).toBe("chrome_131_linux");
360
+ expect(captured.proxy).toBe("http://user:pass@proxy.example:8080");
361
+ });
362
+
363
+ it("synchronous throw from fetch (e.g. dlopen failure) → null, NEVER propagates", async () => {
364
+ const fetchSpy: ProbeFetch = () => {
365
+ // Simulate the cdylib-missing case: throws synchronously off the
366
+ // top of the body, before Promise.resolve.
367
+ throw new Error("dlopen: libmochi-net.dylib not found");
368
+ };
369
+ const geo = await probeExitGeo({
370
+ matrix: MATRIX_STUB,
371
+ fetch: fetchSpy,
372
+ shuffle: noShuffle,
373
+ maxAttempts: 4,
374
+ perEndpointTimeoutMs: 50,
375
+ });
376
+ expect(geo).toBeNull();
377
+ });
378
+
379
+ it("rejected fetch promise → falls through, never throws out", async () => {
380
+ const fetchSpy: ProbeFetch = () => Promise.reject(new Error("connection refused"));
381
+ const geo = await probeExitGeo({
382
+ matrix: MATRIX_STUB,
383
+ fetch: fetchSpy,
384
+ shuffle: noShuffle,
385
+ maxAttempts: 4,
386
+ perEndpointTimeoutMs: 50,
387
+ });
388
+ expect(geo).toBeNull();
389
+ });
390
+
391
+ it("malformed JSON body → falls through (parser doesn't throw)", async () => {
392
+ const fetchSpy: ProbeFetch = async (url) => {
393
+ if (url === "https://ip.decodo.com/json") {
394
+ // Real `Response` whose .json() will reject.
395
+ return new Response("not-json{{{", { status: 200 });
396
+ }
397
+ return new Response(
398
+ JSON.stringify({
399
+ ip: "2",
400
+ country: "GB",
401
+ timezone: "Europe/London",
402
+ }),
403
+ { status: 200 },
404
+ );
405
+ };
406
+ const geo = await probeExitGeo({
407
+ matrix: MATRIX_STUB,
408
+ fetch: fetchSpy,
409
+ shuffle: noShuffle,
410
+ maxAttempts: 4,
411
+ perEndpointTimeoutMs: 100,
412
+ });
413
+ expect(geo?.country).toBe("GB");
414
+ });
415
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Live conformance for task 0266 — `Fetch.fulfillRequest` body splice.
3
+ *
4
+ * Boots a Bun.serve fixture that hands the browser an HTML document whose
5
+ * inline `<script>` would normally race the inject. Asserts:
6
+ *
7
+ * - `__mochi_inject_marker === true` after navigation (our payload ran).
8
+ * - The original document's `window.__before === true` (page script ran too).
9
+ * - **Critical**: `__mochi_inject_marker` was set BEFORE the document's
10
+ * first inline script — the timing property the splice is supposed to
11
+ * guarantee. The fixture's first `<script>` records `__after_marker`
12
+ * reflecting whether the marker was already truthy at that moment.
13
+ * - No `<script class="__mochi_init_script__">` survives in the DOM after
14
+ * load — the self-removal worked.
15
+ *
16
+ * Gated by `MOCHI_E2E=1`. Set `MOCHI_CHROMIUM_PATH` if needed.
17
+ *
18
+ * @see PLAN.md §8.4
19
+ * @see tasks/0266-fetch-fulfill-init-script.md
20
+ */
21
+
22
+ import { describe, expect, it } from "bun:test";
23
+ import { mochi } from "../index";
24
+
25
+ const E2E_ENABLED = process.env.MOCHI_E2E === "1";
26
+ const TEST_TIMEOUT_MS = 20_000;
27
+
28
+ const describeOrSkip = E2E_ENABLED ? describe : describe.skip;
29
+
30
+ const PROBE_HTML = `<!doctype html>
31
+ <html><head><meta charset="utf-8"><title>0266</title>
32
+ <script>
33
+ // First page-script. Records whether our inject marker was already true
34
+ // by the time this line runs. If the splice landed correctly this will be
35
+ // true; if the splice raced behind us, it will be undefined/false.
36
+ window.__before = true;
37
+ window.__after_marker = window.__mochi_inject_marker === true;
38
+ </script>
39
+ </head>
40
+ <body>
41
+ <pre id="probe"></pre>
42
+ <script>
43
+ // After-DOM script: dump the captured state for the test to read.
44
+ document.getElementById('probe').textContent = JSON.stringify({
45
+ before: window.__before === true,
46
+ injected: window.__mochi_inject_marker === true,
47
+ orderedFirst: window.__after_marker === true,
48
+ surviving: document.querySelectorAll('script.__mochi_init_script__').length,
49
+ });
50
+ </script>
51
+ </body></html>`;
52
+
53
+ describeOrSkip("@mochi.js/core init-injector E2E (MOCHI_E2E=1, task 0266)", () => {
54
+ it(
55
+ "splices payload before page's first <script> and self-removes",
56
+ async () => {
57
+ // Spin up a one-shot HTTP server so we exercise the real Fetch
58
+ // domain (data: URLs do NOT trigger Fetch.requestPaused).
59
+ const server = Bun.serve({
60
+ port: 0,
61
+ fetch() {
62
+ return new Response(PROBE_HTML, {
63
+ headers: {
64
+ "Content-Type": "text/html; charset=utf-8",
65
+ // Restrictive CSP so the rewriter is exercised.
66
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'",
67
+ },
68
+ });
69
+ },
70
+ });
71
+
72
+ const url = `http://127.0.0.1:${server.port}/`;
73
+ const session = await mochi.launch({
74
+ seed: "phase-0266-gate",
75
+ headless: true,
76
+ profile: {
77
+ id: "init-injector-e2e",
78
+ version: "0.0.0-e2e",
79
+ engine: "chromium",
80
+ browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" },
81
+ os: { name: "macos", version: "14", arch: "arm64" },
82
+ device: {
83
+ vendor: "Apple",
84
+ model: "Mac14,2",
85
+ cpuFamily: "apple-silicon-m2",
86
+ cores: 8,
87
+ memoryGB: 16,
88
+ },
89
+ display: { width: 1728, height: 1117, dpr: 2, colorDepth: 30, pixelDepth: 30 },
90
+ gpu: {
91
+ vendor: "Apple Inc.",
92
+ renderer: "Apple M2",
93
+ webglUnmaskedVendor: "Google Inc. (Apple)",
94
+ webglUnmaskedRenderer:
95
+ "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)",
96
+ webglMaxTextureSize: 16384,
97
+ webglMaxColorAttachments: 8,
98
+ webglExtensions: [],
99
+ },
100
+ audio: {
101
+ contextSampleRate: 48000,
102
+ audioWorkletLatency: 0.005,
103
+ destinationMaxChannelCount: 2,
104
+ },
105
+ fonts: { family: "macos-baseline", list: ["Helvetica"] },
106
+ timezone: "America/Los_Angeles",
107
+ locale: "en-US",
108
+ languages: ["en-US", "en"],
109
+ behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" },
110
+ wreqPreset: "chrome_131_macos",
111
+ userAgent:
112
+ "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",
113
+ uaCh: {},
114
+ entropyBudget: { fixed: [], perSeed: [] },
115
+ },
116
+ });
117
+
118
+ try {
119
+ const page = await session.newPage();
120
+ await page.goto(url);
121
+ const txt = await page.text("#probe");
122
+ if (txt === null) throw new Error("[mochi e2e] probe element produced no textContent");
123
+ const probe = JSON.parse(txt) as {
124
+ before: boolean;
125
+ injected: boolean;
126
+ orderedFirst: boolean;
127
+ surviving: number;
128
+ };
129
+
130
+ // Both the page script and our inject ran.
131
+ expect(probe.before).toBe(true);
132
+ expect(probe.injected).toBe(true);
133
+ // CRITICAL: our marker was set before the page's first <script> ran.
134
+ expect(probe.orderedFirst).toBe(true);
135
+ // Self-removal worked — no surviving init-script tags.
136
+ expect(probe.surviving).toBe(0);
137
+ } finally {
138
+ await session.close();
139
+ server.stop();
140
+ }
141
+ },
142
+ TEST_TIMEOUT_MS,
143
+ );
144
+ });