@reservamos/browser-analytics 1.1.8-alpha.0 → 1.2.0-alpha.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/CHANGELOG.md +21 -3
- package/README.md +33 -7
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +15 -2
- package/dist/browser-analytics.esm.js +590 -109
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/events/identify/identify.robustness.test.ts +112 -13
- package/src/events/identify/identify.ts +84 -34
- package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
- package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
- package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
- package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
- package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
- package/src/events/sharedSchemas/tripSchema.ts +3 -8
- package/src/index.ts +1 -0
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +1 -0
- package/src/init.ts +15 -2
- package/src/services/botDetectionRelay.test.ts +3 -2
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +190 -0
- package/src/services/fingerprint.ts +141 -13
- package/src/services/fingerprint.wait.integration.test.ts +129 -0
- package/src/services/mixpanel.test.ts +119 -73
- package/src/services/mixpanel.ts +82 -23
- package/src/services/trackingLifecycle.ts +54 -0
- package/src/services/validator/validator.test.ts +79 -0
- package/src/track.readiness.test.ts +131 -0
- package/src/track.recovery.test.ts +339 -0
- package/src/track.storage.test.ts +142 -0
- package/src/track.test.ts +130 -5
- package/src/track.ts +110 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
FpjsClient,
|
|
6
6
|
} from '@fingerprintjs/fingerprintjs-pro-spa';
|
|
7
7
|
import mixpanelService from '@/services/mixpanel';
|
|
8
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
8
9
|
|
|
9
10
|
declare global {
|
|
10
11
|
interface Window {
|
|
@@ -28,6 +29,33 @@ const PROXY_QUERY_PARAM =
|
|
|
28
29
|
* "FPJSAgent hasn't loaded yet" until init completes.
|
|
29
30
|
*/
|
|
30
31
|
let fingerprintAgentReady = false;
|
|
32
|
+
let initialization = 0;
|
|
33
|
+
let startedInitialization = -1;
|
|
34
|
+
const readinessSubscribers = new Set<() => void>();
|
|
35
|
+
const waitingConsumers = new Set<() => void>();
|
|
36
|
+
let pendingIdentification:
|
|
37
|
+
| {
|
|
38
|
+
promise: Promise<FingerprintIdentification>;
|
|
39
|
+
contexts: Set<IdentifyStageContext | undefined>;
|
|
40
|
+
isCurrent: () => boolean;
|
|
41
|
+
}
|
|
42
|
+
| undefined;
|
|
43
|
+
|
|
44
|
+
/** Notifies deferred observations only when the actual provider agent has loaded. */
|
|
45
|
+
function onReady(notify: () => void): () => void {
|
|
46
|
+
readinessSubscribers.add(notify);
|
|
47
|
+
return () => {
|
|
48
|
+
readinessSubscribers.delete(notify);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Prevents a previous configuration or late agent load from serving a new init. */
|
|
53
|
+
function invalidate(): void {
|
|
54
|
+
initialization++;
|
|
55
|
+
fingerprintAgentReady = false;
|
|
56
|
+
pendingIdentification = undefined;
|
|
57
|
+
waitingConsumers.forEach((cancel) => cancel());
|
|
58
|
+
}
|
|
31
59
|
|
|
32
60
|
/**
|
|
33
61
|
* Calculates the expiration date based on the cache time.
|
|
@@ -116,7 +144,9 @@ async function initFingerprint(
|
|
|
116
144
|
customScriptEndpointArray.unshift(buildProxyUrl(proxyUrl));
|
|
117
145
|
}
|
|
118
146
|
|
|
119
|
-
|
|
147
|
+
if (startedInitialization === initialization) invalidate();
|
|
148
|
+
startedInitialization = initialization;
|
|
149
|
+
const attempt = initialization;
|
|
120
150
|
window.fingerprintConfig = {};
|
|
121
151
|
const client = new FpjsClient({
|
|
122
152
|
loadOptions: {
|
|
@@ -129,12 +159,14 @@ async function initFingerprint(
|
|
|
129
159
|
|
|
130
160
|
try {
|
|
131
161
|
await client.init();
|
|
132
|
-
if (window.fpClient === client) {
|
|
162
|
+
if (window.fpClient === client && attempt === initialization) {
|
|
133
163
|
fingerprintAgentReady = true;
|
|
164
|
+
readinessSubscribers.forEach((notify) => notify());
|
|
134
165
|
}
|
|
135
166
|
} catch (error) {
|
|
136
|
-
if (window.fpClient === client) {
|
|
167
|
+
if (window.fpClient === client && attempt === initialization) {
|
|
137
168
|
fingerprintAgentReady = false;
|
|
169
|
+
waitingConsumers.forEach((cancel) => cancel());
|
|
138
170
|
}
|
|
139
171
|
console.error('Error initializing identification service:', error);
|
|
140
172
|
throw error;
|
|
@@ -148,8 +180,11 @@ async function initFingerprint(
|
|
|
148
180
|
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
|
|
149
181
|
* @throws {Error} Throws an error if retrieval fails after the agent has loaded.
|
|
150
182
|
*/
|
|
151
|
-
async function getFingerprint(
|
|
152
|
-
|
|
183
|
+
async function getFingerprint(
|
|
184
|
+
cacheOnly = false,
|
|
185
|
+
context?: IdentifyStageContext,
|
|
186
|
+
): Promise<string> {
|
|
187
|
+
return readFingerprint(cacheOnly, context);
|
|
153
188
|
}
|
|
154
189
|
|
|
155
190
|
/** Internal identify path with late-effect suppression and sanitized diagnostics. */
|
|
@@ -184,13 +219,58 @@ async function readFingerprint(
|
|
|
184
219
|
*/
|
|
185
220
|
async function identifyVisitor(
|
|
186
221
|
ignoreCache = false,
|
|
222
|
+
context?: IdentifyStageContext,
|
|
187
223
|
): Promise<FingerprintIdentification> {
|
|
188
|
-
return requestIdentification(ignoreCache);
|
|
224
|
+
return requestIdentification(ignoreCache, context);
|
|
189
225
|
}
|
|
190
226
|
|
|
191
227
|
async function requestIdentification(
|
|
192
228
|
ignoreCache: boolean,
|
|
193
229
|
context?: IdentifyStageContext,
|
|
230
|
+
): Promise<FingerprintIdentification> {
|
|
231
|
+
if (!ignoreCache && pendingIdentification?.isCurrent()) {
|
|
232
|
+
for (const participant of pendingIdentification.contexts) {
|
|
233
|
+
if (participant && !participant.isActive())
|
|
234
|
+
pendingIdentification.contexts.delete(participant);
|
|
235
|
+
}
|
|
236
|
+
pendingIdentification.contexts.add(context);
|
|
237
|
+
return pendingIdentification.promise;
|
|
238
|
+
}
|
|
239
|
+
const contexts = new Set([context]);
|
|
240
|
+
const attempt = initialization;
|
|
241
|
+
const currentLifecycle = trackingLifecycle.capture();
|
|
242
|
+
const isCurrent = () => attempt === initialization && currentLifecycle();
|
|
243
|
+
const sharedContext: IdentifyStageContext = {
|
|
244
|
+
isActive: () =>
|
|
245
|
+
isCurrent() &&
|
|
246
|
+
[...contexts].some(
|
|
247
|
+
(participant) => !participant || participant.isActive(),
|
|
248
|
+
),
|
|
249
|
+
report: (...args) => {
|
|
250
|
+
for (const participant of contexts) {
|
|
251
|
+
if (participant?.isActive()) participant.report(...args);
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
const request = {
|
|
256
|
+
contexts,
|
|
257
|
+
isCurrent,
|
|
258
|
+
promise: performIdentification(ignoreCache, sharedContext, () =>
|
|
259
|
+
contexts.has(undefined),
|
|
260
|
+
),
|
|
261
|
+
};
|
|
262
|
+
pendingIdentification = request;
|
|
263
|
+
const clear = () => {
|
|
264
|
+
if (pendingIdentification === request) pendingIdentification = undefined;
|
|
265
|
+
};
|
|
266
|
+
void request.promise.then(clear, clear);
|
|
267
|
+
return request.promise;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function performIdentification(
|
|
271
|
+
ignoreCache: boolean,
|
|
272
|
+
context: IdentifyStageContext,
|
|
273
|
+
hasUnscopedConsumer: () => boolean,
|
|
194
274
|
): Promise<FingerprintIdentification> {
|
|
195
275
|
if (!isFingerprintReady()) {
|
|
196
276
|
throw new Error('Identification service is not initialized.');
|
|
@@ -198,12 +278,11 @@ async function requestIdentification(
|
|
|
198
278
|
|
|
199
279
|
try {
|
|
200
280
|
const result = await window.fpClient.getVisitorData({}, ignoreCache);
|
|
201
|
-
if (
|
|
281
|
+
if (context.isActive()) {
|
|
202
282
|
try {
|
|
203
283
|
setCachedFingerprint(result.visitorId);
|
|
204
|
-
} catch
|
|
205
|
-
|
|
206
|
-
context.report('cache', 'unavailable');
|
|
284
|
+
} catch {
|
|
285
|
+
context?.report('cache', 'unavailable');
|
|
207
286
|
}
|
|
208
287
|
if (typeof result.visitorFound === 'boolean') {
|
|
209
288
|
try {
|
|
@@ -211,8 +290,9 @@ async function requestIdentification(
|
|
|
211
290
|
'Visitor Found': result.visitorFound,
|
|
212
291
|
});
|
|
213
292
|
} catch (error) {
|
|
214
|
-
|
|
215
|
-
|
|
293
|
+
context.report('fingerprint', 'failed');
|
|
294
|
+
if (hasUnscopedConsumer())
|
|
295
|
+
console.error('Error updating Mixpanel visitor profile:', error);
|
|
216
296
|
}
|
|
217
297
|
}
|
|
218
298
|
}
|
|
@@ -227,11 +307,53 @@ async function requestIdentification(
|
|
|
227
307
|
cacheHit: result.cacheHit,
|
|
228
308
|
};
|
|
229
309
|
} catch (error) {
|
|
230
|
-
if (
|
|
310
|
+
if (hasUnscopedConsumer())
|
|
311
|
+
console.error('Error retrieving fingerprint:', error);
|
|
231
312
|
throw error;
|
|
232
313
|
}
|
|
233
314
|
}
|
|
234
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Waits for cached or in-flight identity without forcing a fresh provider request.
|
|
318
|
+
* @param {number} timeoutMs - Maximum wait in milliseconds; defaults to 2000.
|
|
319
|
+
* @returns {Promise<string | null>} Visitor ID, or null when unavailable within the budget.
|
|
320
|
+
*/
|
|
321
|
+
function waitForFingerprintId(timeoutMs = 2000): Promise<string | null> {
|
|
322
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 2147483647)
|
|
323
|
+
return Promise.resolve(null);
|
|
324
|
+
const cached = getCachedFingerprint();
|
|
325
|
+
if (cached) return Promise.resolve(cached);
|
|
326
|
+
return new Promise((resolve) => {
|
|
327
|
+
let active = true;
|
|
328
|
+
let started = false;
|
|
329
|
+
const finish = (visitorId: string | null) => {
|
|
330
|
+
if (!active) return;
|
|
331
|
+
active = false;
|
|
332
|
+
clearTimeout(timer);
|
|
333
|
+
stopReady();
|
|
334
|
+
stopLifecycle();
|
|
335
|
+
waitingConsumers.delete(cancel);
|
|
336
|
+
resolve(visitorId);
|
|
337
|
+
};
|
|
338
|
+
const cancel = () => finish(null);
|
|
339
|
+
const start = () => {
|
|
340
|
+
if (!active || started || !isFingerprintReady()) return;
|
|
341
|
+
started = true;
|
|
342
|
+
const identity = getFingerprint(false, {
|
|
343
|
+
// Caller timeout ends waiting; the shared request still checks its lifecycle.
|
|
344
|
+
isActive: () => true,
|
|
345
|
+
report: () => undefined,
|
|
346
|
+
});
|
|
347
|
+
void identity.then((visitorId) => finish(visitorId || null), cancel);
|
|
348
|
+
};
|
|
349
|
+
const timer = setTimeout(cancel, timeoutMs);
|
|
350
|
+
const stopReady = onReady(start);
|
|
351
|
+
const stopLifecycle = trackingLifecycle.onChange(cancel);
|
|
352
|
+
waitingConsumers.add(cancel);
|
|
353
|
+
start();
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
235
357
|
/**
|
|
236
358
|
* Checks if the Fingerprint agent finished loading and can identify visitors.
|
|
237
359
|
* @returns {boolean} True only after `initFingerprint()` has successfully loaded the agent.
|
|
@@ -245,7 +367,13 @@ const fingerprintService = {
|
|
|
245
367
|
getFingerprint,
|
|
246
368
|
identifyVisitor,
|
|
247
369
|
isFingerprintReady,
|
|
370
|
+
onReady,
|
|
371
|
+
invalidate,
|
|
248
372
|
getCachedFingerprint,
|
|
373
|
+
waitForFingerprintId,
|
|
249
374
|
};
|
|
250
375
|
|
|
376
|
+
// One module-lifetime listener invalidates the old agent before Mixpanel signals readiness.
|
|
377
|
+
trackingLifecycle.onInitialization(invalidate);
|
|
378
|
+
|
|
251
379
|
export default fingerprintService;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import identify from '@/events/identify';
|
|
3
|
+
import init from '@/init';
|
|
4
|
+
import fingerprintService from '@/services/fingerprint';
|
|
5
|
+
import mixpanelService from '@/services/mixpanel';
|
|
6
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
7
|
+
import { trackEvent } from '@/track';
|
|
8
|
+
|
|
9
|
+
const mocks = vi.hoisted(() => ({ load: vi.fn(), get: vi.fn() }));
|
|
10
|
+
const visitor = {
|
|
11
|
+
visitorId: 'shared-visitor',
|
|
12
|
+
requestId: 'request',
|
|
13
|
+
visitorFound: true,
|
|
14
|
+
confidence: { score: 1 },
|
|
15
|
+
cacheHit: false,
|
|
16
|
+
};
|
|
17
|
+
vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
|
|
18
|
+
FingerprintJSPro: {
|
|
19
|
+
defaultEndpoint: 'https://fp.test',
|
|
20
|
+
defaultScriptUrlPattern: 'https://fp.test/agent',
|
|
21
|
+
},
|
|
22
|
+
FpjsClient: vi.fn(() => ({ init: mocks.load, getVisitorData: mocks.get })),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('@reservamos/js-api-client/core', () => ({
|
|
25
|
+
default: {
|
|
26
|
+
setConfig: vi.fn(),
|
|
27
|
+
profiles: { createAnonymousProfile: vi.fn().mockResolvedValue(undefined) },
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('@/services/config', () => ({
|
|
31
|
+
default: {
|
|
32
|
+
setEnvironment: vi.fn(),
|
|
33
|
+
setAppName: vi.fn(),
|
|
34
|
+
getCoreAPIConfig: vi
|
|
35
|
+
.fn()
|
|
36
|
+
.mockReturnValue({ baseUrl: 'https://core.test', defaultHeaders: {} }),
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('@/services/geolocation', () => ({
|
|
40
|
+
default: {
|
|
41
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
getCoordinates: vi.fn().mockReturnValue(null),
|
|
43
|
+
getGeolocationSource: vi.fn().mockReturnValue(null),
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
47
|
+
default: {
|
|
48
|
+
validateConfiguration: vi.fn(),
|
|
49
|
+
init: vi.fn(async () => {
|
|
50
|
+
trackingLifecycle.beginInitialization();
|
|
51
|
+
}),
|
|
52
|
+
isReady: vi.fn().mockReturnValue(true),
|
|
53
|
+
identify: vi.fn((user: string) => trackingLifecycle.identify(user)),
|
|
54
|
+
setPeopleProperties: vi.fn(),
|
|
55
|
+
attachProperty: vi.fn(),
|
|
56
|
+
getMixpanelDistinctId: vi.fn().mockReturnValue('user'),
|
|
57
|
+
track: vi.fn(),
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.useFakeTimers();
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
localStorage.clear();
|
|
65
|
+
mocks.load.mockResolvedValue(undefined);
|
|
66
|
+
mocks.get.mockResolvedValue(visitor);
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.clearAllTimers();
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
localStorage.clear();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves a waiter registered by Tracker Ready during that same initialization', async () => {
|
|
75
|
+
let loaded!: () => void;
|
|
76
|
+
mocks.load.mockReturnValue(
|
|
77
|
+
new Promise<void>((resolve) => {
|
|
78
|
+
loaded = resolve;
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
let waiting: Promise<string | null> | undefined;
|
|
82
|
+
let settled = false;
|
|
83
|
+
window.addEventListener(
|
|
84
|
+
'Tracker Ready',
|
|
85
|
+
() => {
|
|
86
|
+
waiting = fingerprintService.waitForFingerprintId(1000).then((value) => {
|
|
87
|
+
settled = true;
|
|
88
|
+
return value;
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
{ once: true },
|
|
92
|
+
);
|
|
93
|
+
const initialization = init({
|
|
94
|
+
mixpanelToken: 'test',
|
|
95
|
+
identificationKey: 'test',
|
|
96
|
+
});
|
|
97
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
98
|
+
expect(waiting).toBeDefined();
|
|
99
|
+
expect(settled).toBe(false);
|
|
100
|
+
loaded();
|
|
101
|
+
await initialization;
|
|
102
|
+
await expect(waiting).resolves.toBe('shared-visitor');
|
|
103
|
+
expect(mocks.get).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps cache and profile effects for a current Identify joining a superseded request', async () => {
|
|
107
|
+
await init({ mixpanelToken: 'test', identificationKey: 'test' });
|
|
108
|
+
let finish!: (value: typeof visitor) => void;
|
|
109
|
+
mocks.get.mockReturnValue(
|
|
110
|
+
new Promise((resolve) => {
|
|
111
|
+
finish = resolve;
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
await identify('user', { email: 'test@example.com' });
|
|
115
|
+
await identify('user', { email: 'test@example.com' });
|
|
116
|
+
finish(visitor);
|
|
117
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
118
|
+
expect(mocks.get).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(fingerprintService.getCachedFingerprint()).toBe('shared-visitor');
|
|
120
|
+
expect(mixpanelService.setPeopleProperties).toHaveBeenCalledWith({
|
|
121
|
+
'Visitor Found': true,
|
|
122
|
+
});
|
|
123
|
+
expect(mixpanelService.attachProperty).toHaveBeenCalledTimes(1);
|
|
124
|
+
await trackEvent('Cache Check', { step: 'after identify' });
|
|
125
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
126
|
+
'Cache Check',
|
|
127
|
+
expect.objectContaining({ 'User Fingerprint': 'shared-visitor' }),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import mixpanel from 'mixpanel-browser';
|
|
2
|
-
import { version as mixpanelVersion } from 'mixpanel-browser/package.json';
|
|
3
2
|
import {
|
|
4
3
|
afterAll,
|
|
5
4
|
afterEach,
|
|
@@ -13,92 +12,117 @@ import { trackEvent } from '@/track';
|
|
|
13
12
|
import { version } from '../../package.json';
|
|
14
13
|
import mixpanelService from './mixpanel';
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
const { provider } = vi.hoisted(() => ({
|
|
16
|
+
provider: {
|
|
17
|
+
init: vi.fn((_token: string, config: { loaded: () => void }) =>
|
|
18
|
+
config.loaded(),
|
|
19
|
+
),
|
|
20
|
+
register: vi.fn(),
|
|
21
|
+
set_config: vi.fn(),
|
|
22
|
+
identify: vi.fn(),
|
|
23
|
+
track: vi.fn(),
|
|
24
|
+
get_distinct_id: vi.fn(() => 'live-id'),
|
|
25
|
+
people: { set: vi.fn(), union: vi.fn() },
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
20
28
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
// Exercise SDK service methods while replacing the vendor before initialization.
|
|
30
|
+
vi.mock('mixpanel-browser', () => ({ default: provider }));
|
|
31
|
+
|
|
32
|
+
describe('distinct ID lookup before initialization', () => {
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
localStorage.clear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it.each(['cached', 'empty', 'blocked'])(
|
|
39
|
+
'uses the %s cache without calling the uninitialized provider',
|
|
40
|
+
(mode) => {
|
|
41
|
+
const previous = window.mixpanel;
|
|
42
|
+
Reflect.deleteProperty(window, 'mixpanel');
|
|
43
|
+
const lookup = vi
|
|
44
|
+
.spyOn(mixpanel, 'get_distinct_id')
|
|
45
|
+
.mockImplementation(() => {
|
|
46
|
+
throw new Error('Provider persistence is not initialized');
|
|
47
|
+
});
|
|
48
|
+
if (mode === 'cached')
|
|
49
|
+
localStorage.setItem('mp_distinct_id', 'cached-id');
|
|
50
|
+
if (mode === 'blocked')
|
|
51
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
52
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe(
|
|
56
|
+
mode === 'cached' ? 'cached-id' : null,
|
|
57
|
+
);
|
|
58
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
59
|
+
} finally {
|
|
60
|
+
if (previous) window.mixpanel = previous;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
});
|
|
26
65
|
|
|
27
66
|
describe('Mixpanel SDK version metadata', () => {
|
|
28
67
|
beforeAll(async () => {
|
|
29
68
|
localStorage.clear();
|
|
30
69
|
await mixpanelService.init('test-sdk-version-token');
|
|
31
|
-
transport.stop_batch_senders();
|
|
32
|
-
mixpanel.set_config({ disable_persistence: true });
|
|
33
70
|
});
|
|
34
71
|
|
|
35
72
|
afterEach(() => {
|
|
36
73
|
vi.restoreAllMocks();
|
|
74
|
+
vi.unstubAllGlobals();
|
|
37
75
|
vi.clearAllTimers();
|
|
38
76
|
vi.useRealTimers();
|
|
77
|
+
vi.clearAllMocks();
|
|
39
78
|
});
|
|
40
79
|
|
|
41
80
|
afterAll(() => {
|
|
42
|
-
transport.stop_batch_senders();
|
|
43
81
|
localStorage.clear();
|
|
44
82
|
});
|
|
45
83
|
|
|
46
|
-
it('registers SDK metadata
|
|
47
|
-
expect(mixpanel.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const send = vi
|
|
51
|
-
.spyOn(transport, '_track_or_batch')
|
|
52
|
-
.mockImplementation(({ data }) => data);
|
|
53
|
-
|
|
54
|
-
mixpanelService.identify('sdk-version-user', {});
|
|
55
|
-
const events = send.mock.calls
|
|
56
|
-
.map(([options]) => options.data)
|
|
57
|
-
.filter((data) => data.event);
|
|
58
|
-
|
|
59
|
-
expect(events).toHaveLength(1);
|
|
60
|
-
expect(events[0]).toEqual({
|
|
61
|
-
event: '$identify',
|
|
62
|
-
properties: expect.objectContaining({
|
|
63
|
-
'Event Source': 'sdk',
|
|
64
|
-
'Reservamos Version': version,
|
|
65
|
-
'$lib_version': mixpanelVersion,
|
|
66
|
-
}),
|
|
84
|
+
it('registers SDK metadata before identifying at the provider boundary', () => {
|
|
85
|
+
expect(mixpanel.register).toHaveBeenCalledWith({
|
|
86
|
+
'Event Source': 'sdk',
|
|
87
|
+
'Reservamos Version': version,
|
|
67
88
|
});
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
expect(mixpanel.register).not.toHaveBeenCalledWith(
|
|
90
|
+
expect.objectContaining({ $lib_version: expect.anything() }),
|
|
91
|
+
);
|
|
70
92
|
mixpanelService.identify('sdk-version-user', {});
|
|
71
|
-
expect(
|
|
93
|
+
expect(mixpanel.identify).toHaveBeenCalledWith('sdk-version-user');
|
|
94
|
+
expect(mixpanel.people.set).toHaveBeenCalledWith({});
|
|
72
95
|
});
|
|
73
96
|
|
|
74
97
|
it('keeps the SDK version authoritative for SDK-tracked events', () => {
|
|
75
|
-
const send = vi
|
|
76
|
-
.spyOn(transport, '_track_or_batch')
|
|
77
|
-
.mockImplementation(({ data }) => data);
|
|
78
|
-
|
|
79
98
|
mixpanelService.track('Test', { 'Reservamos Version': 'caller-version' });
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
event: 'Test',
|
|
83
|
-
properties: expect.objectContaining({
|
|
84
|
-
'Event Source': 'sdk',
|
|
85
|
-
'Reservamos Version': version,
|
|
86
|
-
}),
|
|
99
|
+
expect(mixpanel.track).toHaveBeenCalledWith('Test', {
|
|
100
|
+
'Reservamos Version': version,
|
|
87
101
|
});
|
|
88
102
|
});
|
|
89
103
|
|
|
90
|
-
it('
|
|
91
|
-
vi.
|
|
92
|
-
|
|
104
|
+
it('completes a repeated init with the installed singleton provider without waiting for another loaded callback', async () => {
|
|
105
|
+
const complete = vi.fn();
|
|
106
|
+
void mixpanelService.init('test-sdk-version-token').then(complete);
|
|
107
|
+
await Promise.resolve();
|
|
108
|
+
expect(complete).toHaveBeenCalledOnce();
|
|
109
|
+
expect(mixpanelService.isReady()).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects a different project before disturbing the existing provider session', () => {
|
|
113
|
+
expect(() => mixpanelService.init('different-project')).toThrow(
|
|
114
|
+
'new page session',
|
|
93
115
|
);
|
|
116
|
+
expect(mixpanelService.isReady()).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('still sets people properties when the SDK distinct ID cache is blocked', () => {
|
|
94
120
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
95
121
|
throw new DOMException('Blocked', 'SecurityError');
|
|
96
122
|
});
|
|
97
|
-
const people = vi
|
|
98
|
-
.spyOn(mixpanel.people, 'set')
|
|
99
|
-
.mockImplementation(() => undefined);
|
|
100
123
|
mixpanelService.identify('cache-blocked-user', {});
|
|
101
|
-
expect(
|
|
124
|
+
expect(mixpanel.identify).toHaveBeenCalledWith('cache-blocked-user');
|
|
125
|
+
expect(mixpanel.people.set).toHaveBeenCalledWith({});
|
|
102
126
|
});
|
|
103
127
|
|
|
104
128
|
it.each(['getItem', 'setItem'] as const)(
|
|
@@ -115,31 +139,59 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
115
139
|
it.each(['getItem', 'setItem'] as const)(
|
|
116
140
|
'submits the original event when SDK storage %s is blocked',
|
|
117
141
|
async (method) => {
|
|
118
|
-
|
|
119
|
-
.spyOn(transport, '_track_or_batch')
|
|
120
|
-
.mockImplementation(({ data }) => data);
|
|
121
|
-
mixpanel.identify('storage-test-id');
|
|
122
|
-
send.mockClear();
|
|
142
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('storage-test-id');
|
|
123
143
|
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
124
144
|
throw new DOMException('Blocked', 'SecurityError');
|
|
125
145
|
});
|
|
126
146
|
await trackEvent('Storage Failure Test', { source: 'checkout' });
|
|
127
|
-
expect(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
source: 'checkout',
|
|
131
|
-
|
|
147
|
+
expect(mixpanel.track).toHaveBeenCalledWith(
|
|
148
|
+
'Storage Failure Test',
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
'source': 'checkout',
|
|
151
|
+
'Reservamos Version': version,
|
|
132
152
|
}),
|
|
133
|
-
|
|
153
|
+
);
|
|
154
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('storage-test-id');
|
|
134
155
|
},
|
|
135
156
|
);
|
|
136
157
|
|
|
137
|
-
it('
|
|
158
|
+
it('submits the original event when no live distinct ID is available and the cache is blocked', async () => {
|
|
138
159
|
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
139
160
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
140
161
|
throw new DOMException('Blocked', 'SecurityError');
|
|
141
162
|
});
|
|
142
163
|
expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
|
|
164
|
+
await trackEvent('Storage Failure Test', { source: 'checkout' });
|
|
165
|
+
expect(mixpanel.track).toHaveBeenCalledWith(
|
|
166
|
+
'Storage Failure Test',
|
|
167
|
+
expect.objectContaining({ source: 'checkout' }),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('uses the live distinct ID first and null fallback when localStorage is null', () => {
|
|
172
|
+
vi.stubGlobal('localStorage', null);
|
|
173
|
+
const liveId = vi
|
|
174
|
+
.spyOn(mixpanel, 'get_distinct_id')
|
|
175
|
+
.mockReturnValue('live-id');
|
|
176
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
|
|
177
|
+
liveId.mockReturnValue('');
|
|
178
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('preserves identify diagnostics and people updates when localStorage is null', () => {
|
|
182
|
+
vi.useFakeTimers();
|
|
183
|
+
vi.stubGlobal('localStorage', null);
|
|
184
|
+
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
185
|
+
const people = vi
|
|
186
|
+
.spyOn(mixpanel.people, 'set')
|
|
187
|
+
.mockImplementation(() => undefined);
|
|
188
|
+
const report = vi.fn();
|
|
189
|
+
mixpanelService.identify('null-storage-user', { plan: 'standard' }, report);
|
|
190
|
+
expect(report).toHaveBeenCalledWith('cache', 'unavailable');
|
|
191
|
+
expect(people).toHaveBeenCalledWith(
|
|
192
|
+
{ plan: 'standard' },
|
|
193
|
+
expect.any(Function),
|
|
194
|
+
);
|
|
143
195
|
});
|
|
144
196
|
|
|
145
197
|
it('uses the cached distinct ID when Mixpanel has no live ID', () => {
|
|
@@ -172,9 +224,6 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
172
224
|
(response) => {
|
|
173
225
|
vi.useFakeTimers();
|
|
174
226
|
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
175
|
-
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
176
|
-
({ data }) => data,
|
|
177
|
-
);
|
|
178
227
|
let callback: ((response: unknown) => void) | undefined;
|
|
179
228
|
vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
|
|
180
229
|
callback = args[1] as (response: unknown) => void;
|
|
@@ -203,9 +252,6 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
203
252
|
it('reports missing people callback as unobserved and ignores a late result', () => {
|
|
204
253
|
vi.useFakeTimers();
|
|
205
254
|
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
206
|
-
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
207
|
-
({ data }) => data,
|
|
208
|
-
);
|
|
209
255
|
let callback: ((response: unknown) => void) | undefined;
|
|
210
256
|
vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
|
|
211
257
|
callback = args[1] as (response: unknown) => void;
|