@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.
- package/LICENSE +21 -0
- package/README.md +198 -68
- package/dist/cjs/client.js +181 -83
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/storage.js +52 -1
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/types.js +3 -0
- package/dist/cjs/types.js.map +1 -1
- package/dist/cjs/utils.js +1 -0
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/client.js +182 -84
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/storage.js +49 -1
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/types.js +3 -0
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +1 -0
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/client.d.ts +73 -27
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/storage.d.ts +23 -1
- package/dist/types/storage.d.ts.map +1 -1
- package/dist/types/types.d.ts +30 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +584 -1
- package/src/client.ts +202 -87
- package/src/index.ts +9 -1
- package/src/storage.ts +58 -1
- package/src/types.ts +39 -1
- package/src/utils.ts +1 -0
package/src/client.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { MostlyGoodMetrics } from './client';
|
|
2
2
|
import { InMemoryEventStorage } from './storage';
|
|
3
|
-
import {
|
|
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
|
});
|