mixpanel-browser 2.77.0 → 2.79.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/.claude/settings.local.json +6 -9
- package/.eslintrc.json +12 -0
- package/.github/workflows/openfeature-provider-tests.yml +31 -0
- package/CHANGELOG.md +11 -0
- package/build.sh +2 -2
- package/dist/async-modules/{mixpanel-recorder-wIWnMDLA.min.js → mixpanel-recorder-D5HJyV2E.min.js} +2 -2
- package/dist/async-modules/mixpanel-recorder-D5HJyV2E.min.js.map +1 -0
- package/dist/async-modules/{mixpanel-recorder-DLKbUIEE.js → mixpanel-recorder-P6SEnnPV.js} +57 -33
- package/dist/async-modules/mixpanel-targeting-1L9FyetZ.min.js +2 -0
- package/dist/async-modules/mixpanel-targeting-1L9FyetZ.min.js.map +1 -0
- package/dist/async-modules/{mixpanel-targeting-CmVvUyFM.js → mixpanel-targeting-BBMVbgJF.js} +24 -13
- package/dist/mixpanel-core.cjs.d.ts +46 -1
- package/dist/mixpanel-core.cjs.js +671 -272
- package/dist/mixpanel-recorder.js +57 -33
- package/dist/mixpanel-recorder.min.js +1 -1
- package/dist/mixpanel-recorder.min.js.map +1 -1
- package/dist/mixpanel-targeting.js +24 -13
- package/dist/mixpanel-targeting.min.js +1 -1
- package/dist/mixpanel-targeting.min.js.map +1 -1
- package/dist/mixpanel-with-async-modules.cjs.d.ts +46 -1
- package/dist/mixpanel-with-async-modules.cjs.js +673 -274
- package/dist/mixpanel-with-async-recorder.cjs.d.ts +46 -1
- package/dist/mixpanel-with-async-recorder.cjs.js +673 -274
- package/dist/mixpanel-with-recorder.d.ts +46 -1
- package/dist/mixpanel-with-recorder.js +596 -197
- package/dist/mixpanel-with-recorder.min.d.ts +46 -1
- package/dist/mixpanel-with-recorder.min.js +1 -1
- package/dist/mixpanel.amd.d.ts +46 -1
- package/dist/mixpanel.amd.js +596 -197
- package/dist/mixpanel.cjs.d.ts +46 -1
- package/dist/mixpanel.cjs.js +596 -197
- package/dist/mixpanel.globals.js +673 -274
- package/dist/mixpanel.min.js +200 -189
- package/dist/mixpanel.module.d.ts +46 -1
- package/dist/mixpanel.module.js +596 -197
- package/dist/mixpanel.umd.d.ts +46 -1
- package/dist/mixpanel.umd.js +596 -197
- package/package.json +1 -1
- package/packages/openfeature-web-provider/README.md +357 -0
- package/packages/openfeature-web-provider/package-lock.json +1636 -0
- package/packages/openfeature-web-provider/package.json +51 -0
- package/packages/openfeature-web-provider/rollup.config.browser.mjs +26 -0
- package/packages/openfeature-web-provider/src/MixpanelProvider.ts +302 -0
- package/packages/openfeature-web-provider/src/index.ts +1 -0
- package/packages/openfeature-web-provider/src/types.ts +72 -0
- package/packages/openfeature-web-provider/test/MixpanelProvider.spec.ts +484 -0
- package/packages/openfeature-web-provider/tsconfig.json +15 -0
- package/src/autocapture/index.js +7 -2
- package/src/config.js +1 -1
- package/src/flags/CLAUDE.md +24 -0
- package/src/flags/flags-persistence.js +176 -0
- package/src/flags/index.js +278 -98
- package/src/index.d.ts +46 -1
- package/src/mixpanel-core.js +27 -8
- package/src/recorder/idb-config.js +16 -0
- package/src/recorder/recording-registry.js +7 -2
- package/src/recorder/session-recording.js +9 -4
- package/src/recorder-manager.js +7 -2
- package/src/request-queue.js +1 -2
- package/src/shared-lock.js +2 -3
- package/src/storage/indexed-db.js +16 -15
- package/src/storage/local-storage.js +5 -3
- package/src/utils.js +25 -12
- package/testServer.js +2 -0
- package/tsconfig.base.json +9 -0
- package/dist/async-modules/mixpanel-recorder-wIWnMDLA.min.js.map +0 -1
- package/dist/async-modules/mixpanel-targeting-CTcftSJC.min.js +0 -2
- package/dist/async-modules/mixpanel-targeting-CTcftSJC.min.js.map +0 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import chai, { expect } from 'chai';
|
|
2
|
+
import sinon from 'sinon';
|
|
3
|
+
import sinonChai from 'sinon-chai';
|
|
4
|
+
import { MixpanelProvider } from '../src/MixpanelProvider';
|
|
5
|
+
import { FlagsManager } from '../src/types';
|
|
6
|
+
import { ErrorCode } from '@openfeature/web-sdk';
|
|
7
|
+
|
|
8
|
+
chai.use(sinonChai);
|
|
9
|
+
|
|
10
|
+
describe('MixpanelProvider', () => {
|
|
11
|
+
let mockFlagsManager: FlagsManager;
|
|
12
|
+
let mockFlags: Map<string, any>;
|
|
13
|
+
let mockLogger: any;
|
|
14
|
+
let provider: MixpanelProvider;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockFlags = new Map();
|
|
18
|
+
mockFlagsManager = {
|
|
19
|
+
are_flags_ready: sinon.stub().returns(true),
|
|
20
|
+
load_flags: sinon.stub().resolves(),
|
|
21
|
+
get_variant: sinon.stub().resolves(),
|
|
22
|
+
get_variant_sync: sinon.stub().callsFake((key: string, fallback: any) => {
|
|
23
|
+
return mockFlags.get(key) || fallback;
|
|
24
|
+
}),
|
|
25
|
+
get_variant_value: sinon.stub().resolves(),
|
|
26
|
+
get_variant_value_sync: sinon.stub(),
|
|
27
|
+
is_enabled: sinon.stub().resolves(),
|
|
28
|
+
is_enabled_sync: sinon.stub(),
|
|
29
|
+
update_context: sinon.stub().resolves(),
|
|
30
|
+
when_ready: sinon.stub().resolves(),
|
|
31
|
+
};
|
|
32
|
+
mockLogger = {
|
|
33
|
+
debug: sinon.stub(),
|
|
34
|
+
info: sinon.stub(),
|
|
35
|
+
warn: sinon.stub(),
|
|
36
|
+
error: sinon.stub(),
|
|
37
|
+
};
|
|
38
|
+
provider = new MixpanelProvider(mockFlagsManager);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
sinon.restore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('metadata', () => {
|
|
46
|
+
it('should have correct provider name', () => {
|
|
47
|
+
expect(provider.metadata.name).to.equal('mixpanel-provider');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should run on client', () => {
|
|
51
|
+
expect(provider.runsOn).to.equal('client');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('initialize', () => {
|
|
56
|
+
it('should wait for when_ready to resolve', async () => {
|
|
57
|
+
let readyResolved = false;
|
|
58
|
+
(mockFlagsManager.when_ready as sinon.SinonStub).returns(
|
|
59
|
+
new Promise<void>((resolve) => {
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
readyResolved = true;
|
|
62
|
+
resolve();
|
|
63
|
+
}, 10);
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(readyResolved).to.be.false;
|
|
68
|
+
await provider.initialize();
|
|
69
|
+
expect(readyResolved).to.be.true;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should call update_context when context is provided', async () => {
|
|
73
|
+
const context = { userId: 'test-user', email: 'test@example.com' };
|
|
74
|
+
|
|
75
|
+
await provider.initialize(context);
|
|
76
|
+
|
|
77
|
+
expect(mockFlagsManager.update_context).to.have.been.calledOnce;
|
|
78
|
+
expect(mockFlagsManager.update_context).to.have.been.calledWith(context, { replace: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should not call update_context when context is empty', async () => {
|
|
82
|
+
await provider.initialize({});
|
|
83
|
+
|
|
84
|
+
expect(mockFlagsManager.update_context).not.to.have.been.called;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should not call update_context when context is undefined', async () => {
|
|
88
|
+
await provider.initialize();
|
|
89
|
+
|
|
90
|
+
expect(mockFlagsManager.update_context).not.to.have.been.called;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should call when_ready during initialize', async () => {
|
|
94
|
+
await provider.initialize();
|
|
95
|
+
|
|
96
|
+
expect(mockFlagsManager.when_ready).to.have.been.calledOnce;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('onContextChange', () => {
|
|
101
|
+
it('should call update_context with new context and replace: true', async () => {
|
|
102
|
+
const oldContext = { userId: 'old-user' };
|
|
103
|
+
const newContext = { userId: 'new-user', plan: 'premium' };
|
|
104
|
+
|
|
105
|
+
await provider.onContextChange(oldContext, newContext);
|
|
106
|
+
|
|
107
|
+
expect(mockFlagsManager.update_context).to.have.been.calledOnce;
|
|
108
|
+
expect(mockFlagsManager.update_context).to.have.been.calledWith(newContext, { replace: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should handle empty new context', async () => {
|
|
112
|
+
await provider.onContextChange({ userId: 'old' }, {});
|
|
113
|
+
|
|
114
|
+
expect(mockFlagsManager.update_context).to.have.been.calledWith({}, { replace: true });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('onClose', () => {
|
|
119
|
+
it('should resolve without error', async () => {
|
|
120
|
+
await provider.onClose();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should return a promise', () => {
|
|
124
|
+
const result = provider.onClose();
|
|
125
|
+
|
|
126
|
+
expect(result).to.be.instanceOf(Promise);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('resolveBooleanEvaluation', () => {
|
|
131
|
+
it('should return correct value when flag exists with boolean value', () => {
|
|
132
|
+
mockFlags.set('feature-enabled', { key: 'enabled', value: true });
|
|
133
|
+
|
|
134
|
+
const result = provider.resolveBooleanEvaluation('feature-enabled', false, {}, mockLogger);
|
|
135
|
+
|
|
136
|
+
expect(result.value).to.equal(true);
|
|
137
|
+
expect(result.variant).to.equal('enabled');
|
|
138
|
+
expect(result.reason).to.equal('TARGETING_MATCH');
|
|
139
|
+
expect(result.errorCode).to.be.undefined;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return false boolean value correctly', () => {
|
|
143
|
+
mockFlags.set('feature-disabled', { key: 'disabled', value: false });
|
|
144
|
+
|
|
145
|
+
const result = provider.resolveBooleanEvaluation('feature-disabled', true, {}, mockLogger);
|
|
146
|
+
|
|
147
|
+
expect(result.value).to.equal(false);
|
|
148
|
+
expect(result.variant).to.equal('disabled');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return TYPE_MISMATCH error when value is not boolean (string)', () => {
|
|
152
|
+
mockFlags.set('string-flag', { key: 'variant-a', value: 'not-a-boolean' });
|
|
153
|
+
|
|
154
|
+
const result = provider.resolveBooleanEvaluation('string-flag', false, {}, mockLogger);
|
|
155
|
+
|
|
156
|
+
expect(result.value).to.equal(false);
|
|
157
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
158
|
+
expect(result.errorMessage).to.include('not a boolean');
|
|
159
|
+
expect(result.reason).to.equal('ERROR');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should return TYPE_MISMATCH error when value is not boolean (number)', () => {
|
|
163
|
+
mockFlags.set('number-flag', { key: 'variant-a', value: 42 });
|
|
164
|
+
|
|
165
|
+
const result = provider.resolveBooleanEvaluation('number-flag', true, {}, mockLogger);
|
|
166
|
+
|
|
167
|
+
expect(result.value).to.equal(true);
|
|
168
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should return TYPE_MISMATCH error when value is not boolean (object)', () => {
|
|
172
|
+
mockFlags.set('object-flag', { key: 'variant-a', value: { some: 'object' } });
|
|
173
|
+
|
|
174
|
+
const result = provider.resolveBooleanEvaluation('object-flag', false, {}, mockLogger);
|
|
175
|
+
|
|
176
|
+
expect(result.value).to.equal(false);
|
|
177
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('resolveStringEvaluation', () => {
|
|
182
|
+
it('should return correct value when flag exists with string value', () => {
|
|
183
|
+
mockFlags.set('theme-flag', { key: 'dark', value: 'dark-mode' });
|
|
184
|
+
|
|
185
|
+
const result = provider.resolveStringEvaluation('theme-flag', 'light-mode', {}, mockLogger);
|
|
186
|
+
|
|
187
|
+
expect(result.value).to.equal('dark-mode');
|
|
188
|
+
expect(result.variant).to.equal('dark');
|
|
189
|
+
expect(result.reason).to.equal('TARGETING_MATCH');
|
|
190
|
+
expect(result.errorCode).to.be.undefined;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should return empty string value correctly', () => {
|
|
194
|
+
mockFlags.set('empty-string-flag', { key: 'empty', value: '' });
|
|
195
|
+
|
|
196
|
+
const result = provider.resolveStringEvaluation('empty-string-flag', 'default', {}, mockLogger);
|
|
197
|
+
|
|
198
|
+
expect(result.value).to.equal('');
|
|
199
|
+
expect(result.variant).to.equal('empty');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should return TYPE_MISMATCH error when value is not string (boolean)', () => {
|
|
203
|
+
mockFlags.set('bool-flag', { key: 'variant-a', value: true });
|
|
204
|
+
|
|
205
|
+
const result = provider.resolveStringEvaluation('bool-flag', 'default', {}, mockLogger);
|
|
206
|
+
|
|
207
|
+
expect(result.value).to.equal('default');
|
|
208
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
209
|
+
expect(result.errorMessage).to.include('not a string');
|
|
210
|
+
expect(result.reason).to.equal('ERROR');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should return TYPE_MISMATCH error when value is not string (number)', () => {
|
|
214
|
+
mockFlags.set('number-flag', { key: 'variant-a', value: 123 });
|
|
215
|
+
|
|
216
|
+
const result = provider.resolveStringEvaluation('number-flag', 'default', {}, mockLogger);
|
|
217
|
+
|
|
218
|
+
expect(result.value).to.equal('default');
|
|
219
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should return TYPE_MISMATCH error when value is not string (object)', () => {
|
|
223
|
+
mockFlags.set('object-flag', { key: 'variant-a', value: { key: 'value' } });
|
|
224
|
+
|
|
225
|
+
const result = provider.resolveStringEvaluation('object-flag', 'default', {}, mockLogger);
|
|
226
|
+
|
|
227
|
+
expect(result.value).to.equal('default');
|
|
228
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('resolveNumberEvaluation', () => {
|
|
233
|
+
it('should return correct value when flag exists with number value', () => {
|
|
234
|
+
mockFlags.set('percentage-flag', { key: 'variant-50', value: 50 });
|
|
235
|
+
|
|
236
|
+
const result = provider.resolveNumberEvaluation('percentage-flag', 0, {}, mockLogger);
|
|
237
|
+
|
|
238
|
+
expect(result.value).to.equal(50);
|
|
239
|
+
expect(result.variant).to.equal('variant-50');
|
|
240
|
+
expect(result.reason).to.equal('TARGETING_MATCH');
|
|
241
|
+
expect(result.errorCode).to.be.undefined;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should return zero value correctly', () => {
|
|
245
|
+
mockFlags.set('zero-flag', { key: 'zero', value: 0 });
|
|
246
|
+
|
|
247
|
+
const result = provider.resolveNumberEvaluation('zero-flag', 100, {}, mockLogger);
|
|
248
|
+
|
|
249
|
+
expect(result.value).to.equal(0);
|
|
250
|
+
expect(result.variant).to.equal('zero');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should return negative number correctly', () => {
|
|
254
|
+
mockFlags.set('negative-flag', { key: 'negative', value: -42 });
|
|
255
|
+
|
|
256
|
+
const result = provider.resolveNumberEvaluation('negative-flag', 0, {}, mockLogger);
|
|
257
|
+
|
|
258
|
+
expect(result.value).to.equal(-42);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should return float value correctly', () => {
|
|
262
|
+
mockFlags.set('float-flag', { key: 'float', value: 3.14159 });
|
|
263
|
+
|
|
264
|
+
const result = provider.resolveNumberEvaluation('float-flag', 0, {}, mockLogger);
|
|
265
|
+
|
|
266
|
+
expect(result.value).to.equal(3.14159);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should return TYPE_MISMATCH error when value is not number (string)', () => {
|
|
270
|
+
mockFlags.set('string-flag', { key: 'variant-a', value: '42' });
|
|
271
|
+
|
|
272
|
+
const result = provider.resolveNumberEvaluation('string-flag', 0, {}, mockLogger);
|
|
273
|
+
|
|
274
|
+
expect(result.value).to.equal(0);
|
|
275
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
276
|
+
expect(result.errorMessage).to.include('not a number');
|
|
277
|
+
expect(result.reason).to.equal('ERROR');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should return TYPE_MISMATCH error when value is not number (boolean)', () => {
|
|
281
|
+
mockFlags.set('bool-flag', { key: 'variant-a', value: true });
|
|
282
|
+
|
|
283
|
+
const result = provider.resolveNumberEvaluation('bool-flag', 0, {}, mockLogger);
|
|
284
|
+
|
|
285
|
+
expect(result.value).to.equal(0);
|
|
286
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should return TYPE_MISMATCH error when value is not number (object)', () => {
|
|
290
|
+
mockFlags.set('object-flag', { key: 'variant-a', value: { num: 42 } });
|
|
291
|
+
|
|
292
|
+
const result = provider.resolveNumberEvaluation('object-flag', 0, {}, mockLogger);
|
|
293
|
+
|
|
294
|
+
expect(result.value).to.equal(0);
|
|
295
|
+
expect(result.errorCode).to.equal(ErrorCode.TYPE_MISMATCH);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('resolveObjectEvaluation', () => {
|
|
300
|
+
it('should return correct value when flag exists with object value', () => {
|
|
301
|
+
const objectValue = { feature: 'enabled', level: 2, options: ['a', 'b'] };
|
|
302
|
+
mockFlags.set('config-flag', { key: 'variant-full', value: objectValue });
|
|
303
|
+
|
|
304
|
+
const result = provider.resolveObjectEvaluation('config-flag', {}, {}, mockLogger);
|
|
305
|
+
|
|
306
|
+
expect(result.value).to.deep.equal(objectValue);
|
|
307
|
+
expect(result.variant).to.equal('variant-full');
|
|
308
|
+
expect(result.reason).to.equal('TARGETING_MATCH');
|
|
309
|
+
expect(result.errorCode).to.be.undefined;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should return empty object value correctly', () => {
|
|
313
|
+
mockFlags.set('empty-object-flag', { key: 'empty', value: {} });
|
|
314
|
+
|
|
315
|
+
const result = provider.resolveObjectEvaluation('empty-object-flag', { default: true }, {}, mockLogger);
|
|
316
|
+
|
|
317
|
+
expect(result.value).to.deep.equal({});
|
|
318
|
+
expect(result.variant).to.equal('empty');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should return array value correctly (object accepts any type)', () => {
|
|
322
|
+
const arrayValue = [1, 2, 3, 'four'];
|
|
323
|
+
mockFlags.set('array-flag', { key: 'array-variant', value: arrayValue });
|
|
324
|
+
|
|
325
|
+
const result = provider.resolveObjectEvaluation('array-flag', [], {}, mockLogger);
|
|
326
|
+
|
|
327
|
+
expect(result.value).to.deep.equal(arrayValue);
|
|
328
|
+
expect(result.variant).to.equal('array-variant');
|
|
329
|
+
expect(result.errorCode).to.be.undefined;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should return nested object value correctly', () => {
|
|
333
|
+
const nestedValue = { level1: { level2: { level3: 'deep' } } };
|
|
334
|
+
mockFlags.set('nested-flag', { key: 'nested', value: nestedValue });
|
|
335
|
+
|
|
336
|
+
const result = provider.resolveObjectEvaluation('nested-flag', {}, {}, mockLogger);
|
|
337
|
+
|
|
338
|
+
expect(result.value).to.deep.equal(nestedValue);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should return string value via object evaluation (object accepts any type)', () => {
|
|
342
|
+
mockFlags.set('string-flag', { key: 'variant-a', value: 'not-an-object' });
|
|
343
|
+
|
|
344
|
+
const result = provider.resolveObjectEvaluation('string-flag', {}, {}, mockLogger);
|
|
345
|
+
|
|
346
|
+
expect(result.value).to.equal('not-an-object');
|
|
347
|
+
expect(result.variant).to.equal('variant-a');
|
|
348
|
+
expect(result.errorCode).to.be.undefined;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should return number value via object evaluation (object accepts any type)', () => {
|
|
352
|
+
mockFlags.set('number-flag', { key: 'variant-a', value: 42 });
|
|
353
|
+
|
|
354
|
+
const result = provider.resolveObjectEvaluation('number-flag', {}, {}, mockLogger);
|
|
355
|
+
|
|
356
|
+
expect(result.value).to.equal(42);
|
|
357
|
+
expect(result.errorCode).to.be.undefined;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should return boolean value via object evaluation (object accepts any type)', () => {
|
|
361
|
+
mockFlags.set('bool-flag', { key: 'variant-a', value: true });
|
|
362
|
+
|
|
363
|
+
const result = provider.resolveObjectEvaluation('bool-flag', {}, {}, mockLogger);
|
|
364
|
+
|
|
365
|
+
expect(result.value).to.equal(true);
|
|
366
|
+
expect(result.errorCode).to.be.undefined;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should return null value via object evaluation (object accepts any type)', () => {
|
|
370
|
+
mockFlags.set('null-flag', { key: 'variant-a', value: null });
|
|
371
|
+
|
|
372
|
+
const result = provider.resolveObjectEvaluation('null-flag', { default: true }, {}, mockLogger);
|
|
373
|
+
|
|
374
|
+
expect(result.value).to.be.null;
|
|
375
|
+
expect(result.variant).to.equal('variant-a');
|
|
376
|
+
expect(result.errorCode).to.be.undefined;
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// FLAG_NOT_FOUND and PROVIDER_NOT_READY share the same code path (resolveFlag)
|
|
381
|
+
// across all resolve methods — test them once parameterized rather than 4x each.
|
|
382
|
+
describe('shared error behavior', () => {
|
|
383
|
+
const resolveMethodCases = [
|
|
384
|
+
{ method: 'resolveBooleanEvaluation' as const, defaultValue: false },
|
|
385
|
+
{ method: 'resolveStringEvaluation' as const, defaultValue: 'fallback' },
|
|
386
|
+
{ method: 'resolveNumberEvaluation' as const, defaultValue: 99 },
|
|
387
|
+
{ method: 'resolveObjectEvaluation' as const, defaultValue: { fallback: true } },
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
resolveMethodCases.forEach(({ method, defaultValue }) => {
|
|
391
|
+
it(`${method} should return FLAG_NOT_FOUND when flag does not exist`, () => {
|
|
392
|
+
const result = (provider as any)[method]('non-existent-flag', defaultValue, {}, mockLogger);
|
|
393
|
+
|
|
394
|
+
expect(result.value).to.deep.equal(defaultValue);
|
|
395
|
+
expect(result.errorCode).to.equal(ErrorCode.FLAG_NOT_FOUND);
|
|
396
|
+
expect(result.errorMessage).to.include('not found');
|
|
397
|
+
expect(result.reason).to.equal('DEFAULT');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
resolveMethodCases.forEach(({ method, defaultValue }) => {
|
|
402
|
+
it(`${method} should return PROVIDER_NOT_READY when flags not loaded`, () => {
|
|
403
|
+
(mockFlagsManager.are_flags_ready as sinon.SinonStub).returns(false);
|
|
404
|
+
|
|
405
|
+
const result = (provider as any)[method]('any-flag', defaultValue, {}, mockLogger);
|
|
406
|
+
|
|
407
|
+
expect(result.value).to.deep.equal(defaultValue);
|
|
408
|
+
expect(result.errorCode).to.equal(ErrorCode.PROVIDER_NOT_READY);
|
|
409
|
+
expect(result.errorMessage).to.include('not been loaded');
|
|
410
|
+
expect(result.reason).to.equal('ERROR');
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe('edge cases', () => {
|
|
416
|
+
it('should handle flag with experiment metadata', () => {
|
|
417
|
+
mockFlags.set('experiment-flag', {
|
|
418
|
+
key: 'treatment',
|
|
419
|
+
value: true,
|
|
420
|
+
experiment_id: 'exp-123',
|
|
421
|
+
is_experiment_active: true,
|
|
422
|
+
is_qa_tester: false,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const result = provider.resolveBooleanEvaluation('experiment-flag', false, {}, mockLogger);
|
|
426
|
+
|
|
427
|
+
expect(result.value).to.equal(true);
|
|
428
|
+
expect(result.variant).to.equal('treatment');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should handle special characters in flag key', () => {
|
|
432
|
+
mockFlags.set('flag-with-special_chars.and/slashes', { key: 'variant', value: 'special' });
|
|
433
|
+
|
|
434
|
+
const result = provider.resolveStringEvaluation(
|
|
435
|
+
'flag-with-special_chars.and/slashes',
|
|
436
|
+
'default',
|
|
437
|
+
{},
|
|
438
|
+
mockLogger
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
expect(result.value).to.equal('special');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// NaN passes typeof === 'number', documenting this intentional passthrough
|
|
445
|
+
it('should handle NaN as valid number (typeof NaN === number)', () => {
|
|
446
|
+
mockFlags.set('nan-flag', { key: 'nan', value: NaN });
|
|
447
|
+
|
|
448
|
+
const result = provider.resolveNumberEvaluation('nan-flag', 0, {}, mockLogger);
|
|
449
|
+
|
|
450
|
+
expect(result.value).to.be.NaN;
|
|
451
|
+
expect(result.errorCode).to.be.undefined;
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should handle Infinity as valid number', () => {
|
|
455
|
+
mockFlags.set('infinity-flag', { key: 'infinity', value: Infinity });
|
|
456
|
+
|
|
457
|
+
const result = provider.resolveNumberEvaluation('infinity-flag', 0, {}, mockLogger);
|
|
458
|
+
|
|
459
|
+
expect(result.value).to.equal(Infinity);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should handle evaluation context passed to methods', () => {
|
|
463
|
+
mockFlags.set('context-flag', { key: 'variant', value: true });
|
|
464
|
+
|
|
465
|
+
const context = { userId: 'user-123', plan: 'premium' };
|
|
466
|
+
const result = provider.resolveBooleanEvaluation('context-flag', false, context, mockLogger);
|
|
467
|
+
|
|
468
|
+
expect(result.value).to.equal(true);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('should create new provider instances independently', () => {
|
|
472
|
+
const provider1 = new MixpanelProvider(mockFlagsManager);
|
|
473
|
+
const provider2 = new MixpanelProvider(mockFlagsManager);
|
|
474
|
+
|
|
475
|
+
mockFlags.set('test-flag', { key: 'v', value: 'test' });
|
|
476
|
+
|
|
477
|
+
provider1.resolveStringEvaluation('test-flag', '', {}, mockLogger);
|
|
478
|
+
expect(mockFlagsManager.get_variant_sync).to.have.been.calledOnce;
|
|
479
|
+
|
|
480
|
+
provider2.resolveStringEvaluation('test-flag', '', {}, mockLogger);
|
|
481
|
+
expect(mockFlagsManager.get_variant_sync).to.have.been.calledTwice;
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2018",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"lib": ["ES2018", "DOM"],
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
15
|
+
}
|
package/src/autocapture/index.js
CHANGED
|
@@ -364,14 +364,15 @@ Autocapture.prototype.initInputTracking = function() {
|
|
|
364
364
|
Autocapture.prototype.initPageviewTracking = function() {
|
|
365
365
|
window.removeEventListener(EV_MP_LOCATION_CHANGE, this.listenerLocationchange);
|
|
366
366
|
|
|
367
|
-
if (!this.pageviewTrackingConfig()) {
|
|
367
|
+
if (!this.pageviewTrackingConfig() && !this.mp.get_config('record_heatmap_data')) {
|
|
368
368
|
return;
|
|
369
369
|
}
|
|
370
370
|
logger.log('Initializing pageview tracking');
|
|
371
371
|
|
|
372
372
|
var previousTrackedUrl = '';
|
|
373
373
|
var tracked = false;
|
|
374
|
-
if
|
|
374
|
+
// Track initial pageview if pageview tracking enabled OR heatmap recording is active
|
|
375
|
+
if ((this.pageviewTrackingConfig() || this.mp.is_recording_heatmap_data()) && !this.currentUrlBlocked()) {
|
|
375
376
|
tracked = this.mp.track_pageview(DEFAULT_PROPS);
|
|
376
377
|
}
|
|
377
378
|
if (tracked) {
|
|
@@ -387,6 +388,10 @@ Autocapture.prototype.initPageviewTracking = function() {
|
|
|
387
388
|
var shouldTrack = false;
|
|
388
389
|
var didPathChange = currentUrl.split('#')[0].split('?')[0] !== previousTrackedUrl.split('#')[0].split('?')[0];
|
|
389
390
|
var trackPageviewOption = this.pageviewTrackingConfig();
|
|
391
|
+
if (!trackPageviewOption && this.mp.is_recording_heatmap_data()) {
|
|
392
|
+
trackPageviewOption = PAGEVIEW_OPTION_FULL_URL;
|
|
393
|
+
}
|
|
394
|
+
|
|
390
395
|
if (trackPageviewOption === PAGEVIEW_OPTION_FULL_URL) {
|
|
391
396
|
shouldTrack = currentUrl !== previousTrackedUrl;
|
|
392
397
|
} else if (trackPageviewOption === PAGEVIEW_OPTION_URL_WITH_PATH_AND_QUERY_STRING) {
|
package/src/config.js
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Flags Module
|
|
2
|
+
|
|
3
|
+
## Testing
|
|
4
|
+
- Test runner is **mocha** (not jest): `BABEL_ENV=test npx mocha --require babel-core/register tests/unit/flags.js`
|
|
5
|
+
- Unit tests live in `tests/unit/flags.js`
|
|
6
|
+
|
|
7
|
+
## Code style
|
|
8
|
+
- ES5 prototypal classes — no arrow functions, no ES6 classes
|
|
9
|
+
- Use `.bind(this)` for `this` context in promise `.then()` / `.catch()` callbacks
|
|
10
|
+
- Flat promise chains preferred over nested `.then()` inside `.then()`
|
|
11
|
+
|
|
12
|
+
## Public API pattern
|
|
13
|
+
- Snake-case aliases are registered at the bottom of `index.js` (e.g., `prototype['load_flags'] = prototype.loadFlags`)
|
|
14
|
+
- New public methods need both the camelCase implementation and a snake_case alias
|
|
15
|
+
|
|
16
|
+
## Error handling convention
|
|
17
|
+
- `fetchFlags()` always rejects on error (single `.catch` that logs and re-throws)
|
|
18
|
+
- Fire-and-forget callers (`init`, `updateContext`, `mixpanel-core.js` identify call) swallow errors at the call site with `.catch(function() {})`
|
|
19
|
+
- User-facing methods like `loadFlags` propagate rejections so the caller can handle them
|
|
20
|
+
|
|
21
|
+
## Key files
|
|
22
|
+
- `src/flags/index.js` — `FeatureFlagManager` class (fetch, load, variants, first-time events)
|
|
23
|
+
- `src/mixpanel-core.js` — calls `fetchFlags()` on distinct_id change (~line 1764)
|
|
24
|
+
- `tests/unit/flags.js` — all flag unit tests
|