@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.
- package/LICENSE +21 -0
- package/README.md +198 -68
- package/dist/cjs/client.js +239 -70
- 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 +240 -71
- 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 +82 -20
- 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 +54 -3
- 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 +740 -7
- package/src/client.ts +260 -69
- package/src/index.ts +9 -1
- package/src/storage.ts +58 -1
- package/src/types.ts +66 -3
- 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
|
|
|
@@ -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).
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|