@mostly-good-metrics/javascript 0.6.1 → 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
 
@@ -799,11 +889,19 @@ describe('MostlyGoodMetrics', () => {
799
889
 
800
890
  beforeEach(() => {
801
891
  originalFetch = global.fetch;
892
+ // Clear localStorage cache for experiments between tests
893
+ if (typeof localStorage !== 'undefined') {
894
+ localStorage.removeItem('mgm_experiment_variants');
895
+ }
802
896
  });
803
897
 
804
898
  afterEach(() => {
805
899
  global.fetch = originalFetch;
806
900
  MostlyGoodMetrics.reset();
901
+ // Also clear after to ensure clean state
902
+ if (typeof localStorage !== 'undefined') {
903
+ localStorage.removeItem('mgm_experiment_variants');
904
+ }
807
905
  });
808
906
 
809
907
  it('should return null when SDK not configured', () => {
@@ -852,6 +950,7 @@ describe('MostlyGoodMetrics', () => {
852
950
  json: () =>
853
951
  Promise.resolve({
854
952
  experiments: [{ id: 'button-color', variants: ['a', 'b'] }],
953
+ assigned_variants: { 'button-color': 'a' },
855
954
  }),
856
955
  });
857
956
 
@@ -865,8 +964,7 @@ describe('MostlyGoodMetrics', () => {
865
964
  await MostlyGoodMetrics.ready();
866
965
 
867
966
  const variant = MostlyGoodMetrics.getVariant('button-color');
868
- expect(variant).not.toBeNull();
869
- expect(['a', 'b']).toContain(variant);
967
+ expect(variant).toBe('a');
870
968
  });
871
969
 
872
970
  it('should be deterministic - same user gets same variant', async () => {
@@ -875,6 +973,7 @@ describe('MostlyGoodMetrics', () => {
875
973
  json: () =>
876
974
  Promise.resolve({
877
975
  experiments: [{ id: 'button-color', variants: ['a', 'b', 'c'] }],
976
+ assigned_variants: { 'button-color': 'b' },
878
977
  }),
879
978
  });
880
979
 
@@ -887,11 +986,12 @@ describe('MostlyGoodMetrics', () => {
887
986
 
888
987
  await MostlyGoodMetrics.ready();
889
988
 
890
- // Call multiple times - should return same variant
989
+ // Call multiple times - should return same server-assigned variant
891
990
  const variant1 = MostlyGoodMetrics.getVariant('button-color');
892
991
  const variant2 = MostlyGoodMetrics.getVariant('button-color');
893
992
  const variant3 = MostlyGoodMetrics.getVariant('button-color');
894
993
 
994
+ expect(variant1).toBe('b');
895
995
  expect(variant1).toBe(variant2);
896
996
  expect(variant2).toBe(variant3);
897
997
  });
@@ -902,6 +1002,7 @@ describe('MostlyGoodMetrics', () => {
902
1002
  json: () =>
903
1003
  Promise.resolve({
904
1004
  experiments: [{ id: 'button-color', variants: ['a', 'b'] }],
1005
+ assigned_variants: { 'button-color': 'a' },
905
1006
  }),
906
1007
  });
907
1008
 
@@ -917,7 +1018,7 @@ describe('MostlyGoodMetrics', () => {
917
1018
  const variant = MostlyGoodMetrics.getVariant('button-color');
918
1019
 
919
1020
  const superProps = MostlyGoodMetrics.getSuperProperties();
920
- expect(superProps.experiment_button_color).toBe(variant);
1021
+ expect(superProps.$experiment_button_color).toBe(variant);
921
1022
  });
922
1023
 
923
1024
  it('should attach experiment variant to tracked events', async () => {
@@ -926,6 +1027,7 @@ describe('MostlyGoodMetrics', () => {
926
1027
  json: () =>
927
1028
  Promise.resolve({
928
1029
  experiments: [{ id: 'checkout-flow', variants: ['control', 'treatment'] }],
1030
+ assigned_variants: { 'checkout-flow': 'treatment' },
929
1031
  }),
930
1032
  });
931
1033
 
@@ -944,7 +1046,7 @@ describe('MostlyGoodMetrics', () => {
944
1046
  await new Promise((resolve) => setTimeout(resolve, 10));
945
1047
 
946
1048
  const events = await storage.fetchEvents(1);
947
- expect(events[0].properties?.experiment_checkout_flow).toBe(variant);
1049
+ expect(events[0].properties?.$experiment_checkout_flow).toBe(variant);
948
1050
  });
949
1051
 
950
1052
  it('should handle fetch failure gracefully', async () => {
@@ -989,6 +1091,7 @@ describe('MostlyGoodMetrics', () => {
989
1091
  json: () =>
990
1092
  Promise.resolve({
991
1093
  experiments: [{ id: 'newOnboardingFlow', variants: ['a', 'b'] }],
1094
+ assigned_variants: { newOnboardingFlow: 'b' },
992
1095
  }),
993
1096
  });
994
1097
 
@@ -1004,7 +1107,7 @@ describe('MostlyGoodMetrics', () => {
1004
1107
  MostlyGoodMetrics.getVariant('newOnboardingFlow');
1005
1108
 
1006
1109
  const superProps = MostlyGoodMetrics.getSuperProperties();
1007
- expect(superProps.experiment_new_onboarding_flow).toBeDefined();
1110
+ expect(superProps.$experiment_new_onboarding_flow).toBeDefined();
1008
1111
  });
1009
1112
 
1010
1113
  it('should return null for empty experimentName', async () => {
@@ -1047,5 +1150,635 @@ describe('MostlyGoodMetrics', () => {
1047
1150
  const variant = MostlyGoodMetrics.getVariant('empty-experiment');
1048
1151
  expect(variant).toBeNull();
1049
1152
  });
1153
+
1154
+ it('should use localStorage cache on subsequent loads', async () => {
1155
+ // First call - fetches from server
1156
+ global.fetch = jest.fn().mockResolvedValue({
1157
+ ok: true,
1158
+ json: () =>
1159
+ Promise.resolve({
1160
+ experiments: [{ id: 'cached-exp', variants: ['a', 'b'] }],
1161
+ assigned_variants: { 'cached-exp': 'a' },
1162
+ }),
1163
+ });
1164
+
1165
+ MostlyGoodMetrics.configure({
1166
+ apiKey: 'test-key',
1167
+ storage,
1168
+ networkClient,
1169
+ trackAppLifecycleEvents: false,
1170
+ });
1171
+
1172
+ await MostlyGoodMetrics.ready();
1173
+ expect(global.fetch).toHaveBeenCalledTimes(1);
1174
+
1175
+ const variant1 = MostlyGoodMetrics.getVariant('cached-exp');
1176
+ expect(variant1).toBe('a');
1177
+
1178
+ // Reset and reconfigure - should use cache
1179
+ MostlyGoodMetrics.reset();
1180
+
1181
+ // Change the mock to return different variant (shouldn't be used due to cache)
1182
+ global.fetch = jest.fn().mockResolvedValue({
1183
+ ok: true,
1184
+ json: () =>
1185
+ Promise.resolve({
1186
+ experiments: [{ id: 'cached-exp', variants: ['a', 'b'] }],
1187
+ assigned_variants: { 'cached-exp': 'b' },
1188
+ }),
1189
+ });
1190
+
1191
+ MostlyGoodMetrics.configure({
1192
+ apiKey: 'test-key',
1193
+ storage,
1194
+ networkClient,
1195
+ trackAppLifecycleEvents: false,
1196
+ });
1197
+
1198
+ await MostlyGoodMetrics.ready();
1199
+
1200
+ // Should still be 'a' from cache, not 'b' from new fetch
1201
+ const variant2 = MostlyGoodMetrics.getVariant('cached-exp');
1202
+ expect(variant2).toBe('a');
1203
+
1204
+ // Fetch should not have been called again (cached)
1205
+ expect(global.fetch).not.toHaveBeenCalled();
1206
+ });
1207
+
1208
+ it('should refetch experiments when identify() changes user', async () => {
1209
+ // Clear localStorage first
1210
+ localStorage.removeItem('mgm_experiment_variants');
1211
+
1212
+ // Initial fetch for anonymous user
1213
+ global.fetch = jest.fn().mockResolvedValue({
1214
+ ok: true,
1215
+ json: () =>
1216
+ Promise.resolve({
1217
+ experiments: [{ id: 'user-exp', variants: ['anon', 'identified'] }],
1218
+ assigned_variants: { 'user-exp': 'anon' },
1219
+ }),
1220
+ });
1221
+
1222
+ MostlyGoodMetrics.configure({
1223
+ apiKey: 'test-key',
1224
+ storage,
1225
+ networkClient,
1226
+ trackAppLifecycleEvents: false,
1227
+ });
1228
+
1229
+ await MostlyGoodMetrics.ready();
1230
+ expect(MostlyGoodMetrics.getVariant('user-exp')).toBe('anon');
1231
+ expect(global.fetch).toHaveBeenCalledTimes(1);
1232
+
1233
+ // Now identify - should trigger refetch with new user ID
1234
+ global.fetch = jest.fn().mockResolvedValue({
1235
+ ok: true,
1236
+ json: () =>
1237
+ Promise.resolve({
1238
+ experiments: [{ id: 'user-exp', variants: ['anon', 'identified'] }],
1239
+ assigned_variants: { 'user-exp': 'identified' },
1240
+ }),
1241
+ });
1242
+
1243
+ MostlyGoodMetrics.identify('real_user_123');
1244
+
1245
+ // Wait for refetch
1246
+ await MostlyGoodMetrics.ready();
1247
+
1248
+ // Should now have the identified user's variant
1249
+ expect(MostlyGoodMetrics.getVariant('user-exp')).toBe('identified');
1250
+ expect(global.fetch).toHaveBeenCalledTimes(1);
1251
+
1252
+ // Verify the fetch included user_id
1253
+ expect(global.fetch).toHaveBeenCalledWith(
1254
+ expect.stringContaining('user_id=real_user_123'),
1255
+ expect.any(Object)
1256
+ );
1257
+ });
1258
+
1259
+ it('should not refetch experiments if identify() called with same user', async () => {
1260
+ localStorage.removeItem('mgm_experiment_variants');
1261
+
1262
+ global.fetch = jest.fn().mockResolvedValue({
1263
+ ok: true,
1264
+ json: () =>
1265
+ Promise.resolve({
1266
+ experiments: [{ id: 'same-user-exp', variants: ['a', 'b'] }],
1267
+ assigned_variants: { 'same-user-exp': 'a' },
1268
+ }),
1269
+ });
1270
+
1271
+ MostlyGoodMetrics.configure({
1272
+ apiKey: 'test-key',
1273
+ storage,
1274
+ networkClient,
1275
+ trackAppLifecycleEvents: false,
1276
+ });
1277
+
1278
+ // First identify
1279
+ MostlyGoodMetrics.identify('same_user');
1280
+ await MostlyGoodMetrics.ready();
1281
+
1282
+ const callCount = (global.fetch as jest.Mock).mock.calls.length;
1283
+
1284
+ // Identify again with same user
1285
+ MostlyGoodMetrics.identify('same_user');
1286
+
1287
+ // Should not trigger another fetch
1288
+ expect(global.fetch).toHaveBeenCalledTimes(callCount);
1289
+ });
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
+ });
1050
1783
  });
1051
1784
  });