@mostly-good-metrics/javascript 0.7.0 → 0.8.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.
@@ -1,6 +1,12 @@
1
1
  import { MostlyGoodMetrics } from './client';
2
2
  import { InMemoryEventStorage } from './storage';
3
- import { INetworkClient, MGMEventsPayload, ResolvedConfiguration, SendResult } from './types';
3
+ import {
4
+ IExperimentStorage,
5
+ INetworkClient,
6
+ MGMEventsPayload,
7
+ ResolvedConfiguration,
8
+ SendResult,
9
+ } from './types';
4
10
 
5
11
  class MockNetworkClient implements INetworkClient {
6
12
  public sentPayloads: MGMEventsPayload[] = [];
@@ -145,6 +151,62 @@ describe('MostlyGoodMetrics', () => {
145
151
  expect(events[0].platform).toBeDefined();
146
152
  });
147
153
 
154
+ it('should include session_id in events', async () => {
155
+ MostlyGoodMetrics.track('test_event');
156
+
157
+ await new Promise((resolve) => setTimeout(resolve, 10));
158
+
159
+ const events = await storage.fetchEvents(1);
160
+ expect(events[0].session_id).toBeDefined();
161
+ // session_id should be a UUID
162
+ expect(events[0].session_id).toMatch(
163
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
164
+ );
165
+ });
166
+
167
+ it('should include app_version when configured', async () => {
168
+ MostlyGoodMetrics.reset();
169
+ MostlyGoodMetrics.configure({
170
+ apiKey: 'test-key',
171
+ storage,
172
+ networkClient,
173
+ trackAppLifecycleEvents: false,
174
+ appVersion: '1.2.3',
175
+ });
176
+
177
+ MostlyGoodMetrics.track('test_event');
178
+ await new Promise((resolve) => setTimeout(resolve, 10));
179
+
180
+ const events = await storage.fetchEvents(1);
181
+ expect(events[0].app_version).toBe('1.2.3');
182
+ });
183
+
184
+ it('should not include app_version when not configured', async () => {
185
+ MostlyGoodMetrics.track('test_event');
186
+
187
+ await new Promise((resolve) => setTimeout(resolve, 10));
188
+
189
+ const events = await storage.fetchEvents(1);
190
+ expect(events[0].app_version).toBeUndefined();
191
+ });
192
+
193
+ it('should include os_version in events', async () => {
194
+ MostlyGoodMetrics.reset();
195
+ MostlyGoodMetrics.configure({
196
+ apiKey: 'test-key',
197
+ storage,
198
+ networkClient,
199
+ trackAppLifecycleEvents: false,
200
+ osVersion: '14.0',
201
+ });
202
+
203
+ MostlyGoodMetrics.track('test_event');
204
+ await new Promise((resolve) => setTimeout(resolve, 10));
205
+
206
+ const events = await storage.fetchEvents(1);
207
+ expect(events[0].os_version).toBe('14.0');
208
+ });
209
+
148
210
  it('should include client_event_id as a UUID', async () => {
149
211
  MostlyGoodMetrics.track('test_event');
150
212
 
@@ -477,6 +539,34 @@ describe('MostlyGoodMetrics', () => {
477
539
  expect(networkClient.sentPayloads[0].events).toHaveLength(2);
478
540
  });
479
541
 
542
+ it('should include context with snake_case fields in payload', async () => {
543
+ MostlyGoodMetrics.reset();
544
+ MostlyGoodMetrics.configure({
545
+ apiKey: 'test-key',
546
+ storage,
547
+ networkClient,
548
+ trackAppLifecycleEvents: false,
549
+ appVersion: '2.0.0',
550
+ osVersion: '15.0',
551
+ });
552
+
553
+ MostlyGoodMetrics.identify('user_123');
554
+ MostlyGoodMetrics.track('test_event');
555
+
556
+ await new Promise((resolve) => setTimeout(resolve, 10));
557
+ await MostlyGoodMetrics.flush();
558
+
559
+ const payload = networkClient.sentPayloads[0];
560
+ expect(payload.context).toBeDefined();
561
+ expect(payload.context.session_id).toBeDefined();
562
+ expect(payload.context.session_id).toMatch(
563
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
564
+ );
565
+ expect(payload.context.app_version).toBe('2.0.0');
566
+ expect(payload.context.os_version).toBe('15.0');
567
+ expect(payload.context.user_id).toBe('user_123');
568
+ });
569
+
480
570
  it('should clear events after successful send', async () => {
481
571
  MostlyGoodMetrics.track('test_event');
482
572
 
@@ -1198,4 +1288,497 @@ describe('MostlyGoodMetrics', () => {
1198
1288
  expect(global.fetch).toHaveBeenCalledTimes(callCount);
1199
1289
  });
1200
1290
  });
1291
+
1292
+ describe('experiments contract (MGM-31)', () => {
1293
+ let originalFetch: typeof global.fetch;
1294
+
1295
+ const CACHE_KEY = 'mgm_experiment_variants';
1296
+ const EXPOSURES_KEY = 'mgm_experiment_exposures';
1297
+ const ANON_ID = 'anon-mgm31';
1298
+ const HOUR_MS = 60 * 60 * 1000;
1299
+
1300
+ const mockFetch = (assignedVariants: Record<string, string>): jest.Mock => {
1301
+ const mock = jest.fn().mockResolvedValue({
1302
+ ok: true,
1303
+ json: () => Promise.resolve({ assigned_variants: assignedVariants }),
1304
+ });
1305
+ global.fetch = mock as unknown as typeof global.fetch;
1306
+ return mock;
1307
+ };
1308
+
1309
+ // Returns a resolve function for a fetch that stays in flight until called.
1310
+ const mockDeferredFetch = (assignedVariants: Record<string, string>): (() => void) => {
1311
+ let resolveFn!: () => void;
1312
+ const promise = new Promise((resolve) => {
1313
+ resolveFn = () =>
1314
+ resolve({
1315
+ ok: true,
1316
+ json: () => Promise.resolve({ assigned_variants: assignedVariants }),
1317
+ });
1318
+ });
1319
+ global.fetch = jest.fn().mockReturnValue(promise) as unknown as typeof global.fetch;
1320
+ return resolveFn;
1321
+ };
1322
+
1323
+ const seedCache = (variants: Record<string, string>, fetchedAt: number, userId = ANON_ID) => {
1324
+ localStorage.setItem(CACHE_KEY, JSON.stringify({ userId, variants, fetchedAt }));
1325
+ };
1326
+
1327
+ const configureSDK = (overrides: Record<string, unknown> = {}) =>
1328
+ MostlyGoodMetrics.configure({
1329
+ apiKey: 'test-key',
1330
+ storage,
1331
+ networkClient,
1332
+ trackAppLifecycleEvents: false,
1333
+ anonymousId: ANON_ID,
1334
+ ...overrides,
1335
+ });
1336
+
1337
+ const exposureEvents = async (eventStorage: InMemoryEventStorage) => {
1338
+ const events = await eventStorage.fetchEvents(100);
1339
+ return events.filter((e) => e.name === '$experiment_exposure');
1340
+ };
1341
+
1342
+ const tick = (ms = 15) => new Promise((resolve) => setTimeout(resolve, ms));
1343
+
1344
+ beforeEach(() => {
1345
+ originalFetch = global.fetch;
1346
+ // Full clean slate: persisted user id, experiment cache, exposure flags
1347
+ localStorage.clear();
1348
+ });
1349
+
1350
+ afterEach(() => {
1351
+ global.fetch = originalFetch;
1352
+ MostlyGoodMetrics.reset();
1353
+ localStorage.clear();
1354
+ });
1355
+
1356
+ describe('getVariant fallback parameter', () => {
1357
+ it('should return fallback before experiments are loaded', () => {
1358
+ // Fetch never resolves
1359
+ global.fetch = jest
1360
+ .fn()
1361
+ .mockImplementation(() => new Promise(() => {})) as unknown as typeof global.fetch;
1362
+
1363
+ configureSDK();
1364
+
1365
+ expect(MostlyGoodMetrics.getVariant('button-color', 'control')).toBe('control');
1366
+ expect(MostlyGoodMetrics.getVariant('button-color')).toBeNull();
1367
+ });
1368
+
1369
+ it('should return fallback for unknown experiment after load', async () => {
1370
+ mockFetch({});
1371
+ configureSDK();
1372
+ await MostlyGoodMetrics.ready();
1373
+
1374
+ expect(MostlyGoodMetrics.getVariant('nonexistent', 'b')).toBe('b');
1375
+ expect(MostlyGoodMetrics.getVariant('nonexistent')).toBeNull();
1376
+ });
1377
+
1378
+ it('should return fallback when SDK is not configured', () => {
1379
+ expect(MostlyGoodMetrics.getVariant('button-color', 'control')).toBe('control');
1380
+ });
1381
+
1382
+ it('should ignore fallback when a variant is assigned', async () => {
1383
+ mockFetch({ 'button-color': 'a' });
1384
+ configureSDK();
1385
+ await MostlyGoodMetrics.ready();
1386
+
1387
+ expect(MostlyGoodMetrics.getVariant('button-color', 'control')).toBe('a');
1388
+ });
1389
+ });
1390
+
1391
+ describe('$experiment_exposure event', () => {
1392
+ it('should track exposure exactly once per (experiment, variant) with correct properties', async () => {
1393
+ mockFetch({ 'checkout-flow': 'treatment' });
1394
+ configureSDK();
1395
+ await MostlyGoodMetrics.ready();
1396
+
1397
+ MostlyGoodMetrics.getVariant('checkout-flow');
1398
+ MostlyGoodMetrics.getVariant('checkout-flow');
1399
+ MostlyGoodMetrics.getVariant('checkout-flow');
1400
+ await tick();
1401
+
1402
+ const exposures = await exposureEvents(storage);
1403
+ expect(exposures).toHaveLength(1);
1404
+ expect(exposures[0].properties?.$experiment_name).toBe('checkout-flow');
1405
+ expect(exposures[0].properties?.$variant).toBe('treatment');
1406
+ });
1407
+
1408
+ it('should not re-track exposure across simulated restarts (persisted dedup)', async () => {
1409
+ mockFetch({ 'sticky-exp': 'a' });
1410
+ configureSDK();
1411
+ await MostlyGoodMetrics.ready();
1412
+
1413
+ MostlyGoodMetrics.getVariant('sticky-exp');
1414
+ await tick();
1415
+ expect(await exposureEvents(storage)).toHaveLength(1);
1416
+ expect(localStorage.getItem(EXPOSURES_KEY)).toContain('sticky-exp');
1417
+
1418
+ // Simulated restart: new instance + new event storage, same localStorage
1419
+ MostlyGoodMetrics.reset();
1420
+ const storageAfterRestart = new InMemoryEventStorage(100);
1421
+ configureSDK({ storage: storageAfterRestart });
1422
+ await MostlyGoodMetrics.ready();
1423
+
1424
+ expect(MostlyGoodMetrics.getVariant('sticky-exp')).toBe('a');
1425
+ MostlyGoodMetrics.getVariant('sticky-exp');
1426
+ await tick();
1427
+
1428
+ expect(await exposureEvents(storageAfterRestart)).toHaveLength(0);
1429
+ });
1430
+
1431
+ it('should track a new exposure when the variant changes', async () => {
1432
+ mockFetch({ exp: 'a' });
1433
+ configureSDK();
1434
+ await MostlyGoodMetrics.ready();
1435
+
1436
+ MostlyGoodMetrics.getVariant('exp');
1437
+ await tick();
1438
+
1439
+ // Server reassigns to 'b' after identify
1440
+ mockFetch({ exp: 'b' });
1441
+ MostlyGoodMetrics.identify('mgm31-variant-change-user');
1442
+ await MostlyGoodMetrics.ready();
1443
+
1444
+ MostlyGoodMetrics.getVariant('exp');
1445
+ MostlyGoodMetrics.getVariant('exp');
1446
+ await tick();
1447
+
1448
+ const exposures = await exposureEvents(storage);
1449
+ expect(exposures).toHaveLength(2);
1450
+ expect(exposures[1].properties?.$variant).toBe('b');
1451
+ });
1452
+
1453
+ it('should still set the super property on getVariant hit', async () => {
1454
+ mockFetch({ 'button-color': 'a' });
1455
+ configureSDK();
1456
+ await MostlyGoodMetrics.ready();
1457
+
1458
+ MostlyGoodMetrics.getVariant('button-color');
1459
+ expect(MostlyGoodMetrics.getSuperProperties().$experiment_button_color).toBe('a');
1460
+ });
1461
+ });
1462
+
1463
+ describe('cache policy (no TTL, stale-while-revalidate)', () => {
1464
+ it('should serve cached variants after 25 hours (no expiry)', async () => {
1465
+ seedCache({ 'old-exp': 'cached' }, Date.now() - 25 * HOUR_MS);
1466
+
1467
+ // Refetch stays in flight - cached value must be served regardless
1468
+ const resolveFetch = mockDeferredFetch({ 'old-exp': 'fresh' });
1469
+
1470
+ configureSDK();
1471
+ await MostlyGoodMetrics.ready();
1472
+
1473
+ // Served synchronously from cache while refetch is in flight
1474
+ expect(MostlyGoodMetrics.getVariant('old-exp')).toBe('cached');
1475
+ // Stale cache (>1h) triggered a background revalidation
1476
+ expect(global.fetch).toHaveBeenCalledTimes(1);
1477
+
1478
+ // Still serving cached value while the request is pending
1479
+ expect(MostlyGoodMetrics.getVariant('old-exp')).toBe('cached');
1480
+
1481
+ resolveFetch();
1482
+ await tick();
1483
+
1484
+ // Atomically swapped to the fresh value once the response arrived
1485
+ expect(MostlyGoodMetrics.getVariant('old-exp')).toBe('fresh');
1486
+ });
1487
+
1488
+ it('should resolve ready() from cache without waiting for the background refetch', async () => {
1489
+ seedCache({ 'swr-exp': 'v1' }, Date.now() - 2 * HOUR_MS);
1490
+ mockDeferredFetch({ 'swr-exp': 'v2' }); // never resolved
1491
+
1492
+ configureSDK();
1493
+
1494
+ // ready() must resolve even though the refetch never completes
1495
+ await MostlyGoodMetrics.ready();
1496
+ expect(MostlyGoodMetrics.getVariant('swr-exp')).toBe('v1');
1497
+ });
1498
+
1499
+ it('should throttle background refetch to once per hour', async () => {
1500
+ seedCache({ 'fresh-exp': 'a' }, Date.now() - 30 * 60 * 1000); // 30 minutes old
1501
+ const fetchMock = mockFetch({ 'fresh-exp': 'b' });
1502
+
1503
+ configureSDK();
1504
+ await MostlyGoodMetrics.ready();
1505
+ await tick();
1506
+
1507
+ // Cache is fresher than 1 hour - no refetch
1508
+ expect(fetchMock).not.toHaveBeenCalled();
1509
+ expect(MostlyGoodMetrics.getVariant('fresh-exp')).toBe('a');
1510
+ });
1511
+
1512
+ it('should refetch in background when cache is older than an hour', async () => {
1513
+ seedCache({ 'stale-exp': 'a' }, Date.now() - 2 * HOUR_MS);
1514
+ const fetchMock = mockFetch({ 'stale-exp': 'b' });
1515
+
1516
+ configureSDK();
1517
+ await MostlyGoodMetrics.ready();
1518
+ await tick();
1519
+
1520
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1521
+ expect(MostlyGoodMetrics.getVariant('stale-exp')).toBe('b');
1522
+
1523
+ // The refreshed cache persists the new last-fetch timestamp
1524
+ const cached = JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}') as {
1525
+ variants: Record<string, string>;
1526
+ fetchedAt: number;
1527
+ };
1528
+ expect(cached.variants['stale-exp']).toBe('b');
1529
+ expect(Date.now() - cached.fetchedAt).toBeLessThan(5000);
1530
+ });
1531
+ });
1532
+
1533
+ describe('identify() refetch behavior', () => {
1534
+ it('should keep serving old variants until refetch responds, then swap atomically', async () => {
1535
+ mockFetch({ exp: 'anon-variant' });
1536
+ configureSDK();
1537
+ await MostlyGoodMetrics.ready();
1538
+ expect(MostlyGoodMetrics.getVariant('exp')).toBe('anon-variant');
1539
+
1540
+ // Refetch for the identified user stays in flight
1541
+ const resolveFetch = mockDeferredFetch({ exp: 'new-variant' });
1542
+ MostlyGoodMetrics.identify('mgm31-swap-user');
1543
+
1544
+ // Old variants are never cleared to null mid-session
1545
+ expect(MostlyGoodMetrics.getVariant('exp')).toBe('anon-variant');
1546
+ await tick();
1547
+ expect(MostlyGoodMetrics.getVariant('exp')).toBe('anon-variant');
1548
+
1549
+ resolveFetch();
1550
+ await MostlyGoodMetrics.ready();
1551
+
1552
+ expect(MostlyGoodMetrics.getVariant('exp')).toBe('new-variant');
1553
+ });
1554
+
1555
+ it('should include anonymous_id alongside user_id on post-identify refetch', async () => {
1556
+ mockFetch({ exp: 'anon-variant' });
1557
+ configureSDK();
1558
+ await MostlyGoodMetrics.ready();
1559
+
1560
+ // Initial anonymous fetch should not include anonymous_id
1561
+ expect(global.fetch).toHaveBeenCalledWith(
1562
+ expect.not.stringContaining('anonymous_id='),
1563
+ expect.any(Object)
1564
+ );
1565
+
1566
+ const fetchMock = mockFetch({ exp: 'new-variant' });
1567
+ MostlyGoodMetrics.identify('mgm31-anon-param-user');
1568
+ await MostlyGoodMetrics.ready();
1569
+
1570
+ expect(fetchMock).toHaveBeenCalledWith(
1571
+ expect.stringContaining(`user_id=${encodeURIComponent('mgm31-anon-param-user')}`),
1572
+ expect.any(Object)
1573
+ );
1574
+ expect(fetchMock).toHaveBeenCalledWith(
1575
+ expect.stringContaining(`anonymous_id=${encodeURIComponent(ANON_ID)}`),
1576
+ expect.any(Object)
1577
+ );
1578
+ });
1579
+
1580
+ it('should keep old variants when the post-identify refetch fails', async () => {
1581
+ mockFetch({ exp: 'anon-variant' });
1582
+ configureSDK();
1583
+ await MostlyGoodMetrics.ready();
1584
+
1585
+ global.fetch = jest
1586
+ .fn()
1587
+ .mockRejectedValue(new Error('Network error')) as unknown as typeof global.fetch;
1588
+ MostlyGoodMetrics.identify('mgm31-fail-user');
1589
+ await MostlyGoodMetrics.ready();
1590
+
1591
+ expect(MostlyGoodMetrics.getVariant('exp')).toBe('anon-variant');
1592
+ });
1593
+ });
1594
+
1595
+ describe('pluggable experiment storage', () => {
1596
+ class FakeAsyncStorage implements IExperimentStorage {
1597
+ public store: Record<string, string> = {};
1598
+
1599
+ async getItem(key: string): Promise<string | null> {
1600
+ await new Promise((resolve) => setTimeout(resolve, 5));
1601
+ return this.store[key] ?? null;
1602
+ }
1603
+
1604
+ async setItem(key: string, value: string): Promise<void> {
1605
+ await new Promise((resolve) => setTimeout(resolve, 5));
1606
+ this.store[key] = value;
1607
+ }
1608
+ }
1609
+
1610
+ it('should hydrate from an async adapter before ready() resolves', async () => {
1611
+ const adapter = new FakeAsyncStorage();
1612
+ adapter.store[CACHE_KEY] = JSON.stringify({
1613
+ userId: ANON_ID,
1614
+ variants: { 'rn-exp': 'b' },
1615
+ fetchedAt: Date.now(),
1616
+ });
1617
+
1618
+ const fetchMock = mockFetch({ 'rn-exp': 'server' });
1619
+
1620
+ configureSDK({ experimentStorage: adapter });
1621
+ await MostlyGoodMetrics.ready();
1622
+
1623
+ // Hydrated from the async adapter, no network fetch needed (fresh cache)
1624
+ expect(MostlyGoodMetrics.getVariant('rn-exp')).toBe('b');
1625
+ expect(fetchMock).not.toHaveBeenCalled();
1626
+ });
1627
+
1628
+ it('should persist cache and exposure flags through the adapter', async () => {
1629
+ const adapter = new FakeAsyncStorage();
1630
+ mockFetch({ 'rn-exp': 'a' });
1631
+
1632
+ configureSDK({ experimentStorage: adapter });
1633
+ await MostlyGoodMetrics.ready();
1634
+ await tick();
1635
+
1636
+ // Variants cached through the adapter, not localStorage
1637
+ expect(adapter.store[CACHE_KEY]).toContain('rn-exp');
1638
+ expect(localStorage.getItem(CACHE_KEY)).toBeNull();
1639
+
1640
+ MostlyGoodMetrics.getVariant('rn-exp');
1641
+ await tick();
1642
+
1643
+ // Exposure dedup flags persisted through the adapter
1644
+ expect(adapter.store[EXPOSURES_KEY]).toContain('rn-exp');
1645
+
1646
+ // Exposure dedup survives a restart with the same adapter
1647
+ MostlyGoodMetrics.reset();
1648
+ const storageAfterRestart = new InMemoryEventStorage(100);
1649
+ configureSDK({ experimentStorage: adapter, storage: storageAfterRestart });
1650
+ await MostlyGoodMetrics.ready();
1651
+
1652
+ MostlyGoodMetrics.getVariant('rn-exp');
1653
+ await tick();
1654
+ expect(await exposureEvents(storageAfterRestart)).toHaveLength(0);
1655
+ });
1656
+ });
1657
+
1658
+ describe('ready() timeout', () => {
1659
+ // Flush pending promise chains without advancing timers
1660
+ const flushMicrotasks = async () => {
1661
+ for (let i = 0; i < 25; i++) {
1662
+ await Promise.resolve();
1663
+ }
1664
+ };
1665
+
1666
+ beforeEach(() => {
1667
+ jest.useFakeTimers();
1668
+ });
1669
+
1670
+ afterEach(() => {
1671
+ jest.useRealTimers();
1672
+ });
1673
+
1674
+ it('should resolve after the default 5s timeout when the network hangs', async () => {
1675
+ // Fetch never settles (cold cache + hanging network)
1676
+ global.fetch = jest
1677
+ .fn()
1678
+ .mockImplementation(() => new Promise(() => {})) as unknown as typeof global.fetch;
1679
+
1680
+ configureSDK();
1681
+
1682
+ let resolved = false;
1683
+ const ready = MostlyGoodMetrics.ready().then(() => {
1684
+ resolved = true;
1685
+ });
1686
+
1687
+ await jest.advanceTimersByTimeAsync(4999);
1688
+ expect(resolved).toBe(false);
1689
+
1690
+ await jest.advanceTimersByTimeAsync(1);
1691
+ await ready;
1692
+ expect(resolved).toBe(true);
1693
+ });
1694
+
1695
+ it('should resolve as soon as experiments load, before the timeout', async () => {
1696
+ mockFetch({ 'fast-exp': 'a' });
1697
+ configureSDK();
1698
+
1699
+ let resolved = false;
1700
+ void MostlyGoodMetrics.ready().then(() => {
1701
+ resolved = true;
1702
+ });
1703
+
1704
+ // No timers advanced - resolution comes from the fetch settling
1705
+ await flushMicrotasks();
1706
+ expect(resolved).toBe(true);
1707
+ expect(MostlyGoodMetrics.getVariant('fast-exp')).toBe('a');
1708
+ });
1709
+
1710
+ it('should keep working with no arguments (backwards compatible)', async () => {
1711
+ mockFetch({ 'compat-exp': 'b' });
1712
+ configureSDK();
1713
+
1714
+ await MostlyGoodMetrics.ready();
1715
+ expect(MostlyGoodMetrics.getVariant('compat-exp')).toBe('b');
1716
+ });
1717
+
1718
+ it('should honor a custom timeoutMs', async () => {
1719
+ global.fetch = jest
1720
+ .fn()
1721
+ .mockImplementation(() => new Promise(() => {})) as unknown as typeof global.fetch;
1722
+
1723
+ configureSDK();
1724
+
1725
+ let resolved = false;
1726
+ void MostlyGoodMetrics.ready(1000).then(() => {
1727
+ resolved = true;
1728
+ });
1729
+
1730
+ await jest.advanceTimersByTimeAsync(999);
1731
+ expect(resolved).toBe(false);
1732
+
1733
+ await jest.advanceTimersByTimeAsync(1);
1734
+ await flushMicrotasks();
1735
+ expect(resolved).toBe(true);
1736
+ });
1737
+
1738
+ it('should still apply variants from a fetch that completes after the timeout', async () => {
1739
+ const resolveFetch = mockDeferredFetch({ 'late-exp': 'b' });
1740
+ configureSDK();
1741
+
1742
+ const ready = MostlyGoodMetrics.ready();
1743
+ await jest.advanceTimersByTimeAsync(5000);
1744
+ await ready; // resolved by the timeout, not the fetch
1745
+
1746
+ // Fetch still in flight - no variant yet
1747
+ expect(MostlyGoodMetrics.getVariant('late-exp')).toBeNull();
1748
+
1749
+ // Late response arrives after the timeout
1750
+ resolveFetch();
1751
+ await flushMicrotasks();
1752
+
1753
+ // Variants are atomically applied and persisted anyway
1754
+ expect(MostlyGoodMetrics.getVariant('late-exp')).toBe('b');
1755
+ expect(localStorage.getItem(CACHE_KEY)).toContain('late-exp');
1756
+ });
1757
+
1758
+ it('should abort a hung experiments fetch so the load always settles', async () => {
1759
+ const fetchMock = jest.fn().mockImplementation(
1760
+ (_url: string, options: { signal: AbortSignal }) =>
1761
+ new Promise((_resolve, reject) => {
1762
+ options.signal.addEventListener('abort', () => reject(new Error('Aborted')));
1763
+ })
1764
+ );
1765
+ global.fetch = fetchMock as unknown as typeof global.fetch;
1766
+
1767
+ configureSDK();
1768
+
1769
+ let resolved = false;
1770
+ void MostlyGoodMetrics.ready(120000).then(() => {
1771
+ resolved = true;
1772
+ });
1773
+
1774
+ // At the 60s fetch timeout the request is aborted, the load settles,
1775
+ // and ready() resolves well before its own 120s timeout.
1776
+ await jest.advanceTimersByTimeAsync(60000);
1777
+ await flushMicrotasks();
1778
+
1779
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1780
+ expect(resolved).toBe(true);
1781
+ });
1782
+ });
1783
+ });
1201
1784
  });