@mochi.js/core 0.2.2 → 0.3.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
+ });
@@ -251,6 +251,8 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
251
251
  fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-2" });
252
252
  fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-2" });
253
253
  fake.autoRespond((m) => m === "Page.enable", {});
254
+ // Task 0262: Session sends Emulation.setTimezoneOverride per page.
255
+ fake.autoRespond((m) => m === "Emulation.setTimezoneOverride", {});
254
256
  // Task 0255: Session now sends Network.setUserAgentOverride per page.
255
257
  fake.autoRespond((m) => m === "Network.setUserAgentOverride", {});
256
258
  fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
@@ -57,4 +57,28 @@ describeOrSkip("@mochi.js/core E2E (MOCHI_E2E=1)", () => {
57
57
  },
58
58
  TEST_TIMEOUT_MS,
59
59
  );
60
+
61
+ it(
62
+ "page.evaluate awaits page-side Promises (awaitPromise:true) — task 0263",
63
+ async () => {
64
+ // Closes the regression that left 0261 + 0262 live tests skipped: an
65
+ // `async () => …` page function used to round-trip its returned
66
+ // Promise as `undefined`. The `awaitPromise:true` flag on
67
+ // Runtime.callFunctionOn makes Chromium wait for the Promise and
68
+ // serialize the resolved value instead.
69
+ const session = await mochi.launch({ profile: "test", seed: "0263", headless: true });
70
+ try {
71
+ const page = await session.newPage();
72
+ await page.goto("data:text/html,<title>0263</title>");
73
+ const v = await page.evaluate(async () => {
74
+ await new Promise((r) => setTimeout(r, 10));
75
+ return 42;
76
+ });
77
+ expect(v).toBe(42);
78
+ } finally {
79
+ await session.close();
80
+ }
81
+ },
82
+ TEST_TIMEOUT_MS,
83
+ );
60
84
  });