@reservamos/browser-analytics 1.1.8-alpha.1 → 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 +15 -2
- package/README.md +25 -3
- 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 +176 -54
- 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/index.ts +1 -0
- package/src/services/botDetectionRelay.test.ts +3 -2
- package/src/services/fingerprint.test.ts +172 -0
- package/src/services/fingerprint.ts +106 -5
- package/src/services/fingerprint.wait.integration.test.ts +129 -0
- package/src/services/mixpanel.test.ts +77 -73
- package/src/services/mixpanel.ts +28 -18
- package/src/track.storage.test.ts +27 -12
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
2
3
|
import fingerprintService, { getFingerprintForIdentify } from './fingerprint';
|
|
3
4
|
|
|
4
5
|
const visitorData = {
|
|
@@ -396,3 +397,174 @@ describe('fingerprintService', () => {
|
|
|
396
397
|
consoleError.mockRestore();
|
|
397
398
|
});
|
|
398
399
|
});
|
|
400
|
+
|
|
401
|
+
describe('bounded fingerprint waiting', () => {
|
|
402
|
+
beforeEach(async () => {
|
|
403
|
+
vi.useFakeTimers();
|
|
404
|
+
localStorage.clear();
|
|
405
|
+
initMock.mockResolvedValue(undefined);
|
|
406
|
+
getVisitorDataMock.mockReset().mockResolvedValue(visitorData);
|
|
407
|
+
clientMock.mockImplementation(() => ({
|
|
408
|
+
init: initMock,
|
|
409
|
+
getVisitorData: getVisitorDataMock,
|
|
410
|
+
}));
|
|
411
|
+
await fingerprintService.initFingerprint('wait-key');
|
|
412
|
+
});
|
|
413
|
+
afterEach(() => {
|
|
414
|
+
vi.clearAllTimers();
|
|
415
|
+
vi.useRealTimers();
|
|
416
|
+
vi.restoreAllMocks();
|
|
417
|
+
localStorage.clear();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('returns cache without a provider request', async () => {
|
|
421
|
+
localStorage.setItem(
|
|
422
|
+
'default_fingerprint_cache',
|
|
423
|
+
JSON.stringify({ fingerprint: 'cached', expiresAt: Date.now() + 1000 }),
|
|
424
|
+
);
|
|
425
|
+
await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
|
|
426
|
+
'cached',
|
|
427
|
+
);
|
|
428
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
429
|
+
});
|
|
430
|
+
it('joins a fresh request already in flight and serves concurrent waiters', async () => {
|
|
431
|
+
let finish!: (value: typeof visitorData) => void;
|
|
432
|
+
getVisitorDataMock.mockReturnValue(
|
|
433
|
+
new Promise((resolve) => {
|
|
434
|
+
finish = resolve;
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
const existing = fingerprintService.identifyVisitor(true);
|
|
438
|
+
const first = fingerprintService.waitForFingerprintId();
|
|
439
|
+
const second = fingerprintService.waitForFingerprintId();
|
|
440
|
+
finish(visitorData);
|
|
441
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
442
|
+
'visitor-123',
|
|
443
|
+
'visitor-123',
|
|
444
|
+
]);
|
|
445
|
+
await existing;
|
|
446
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
447
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
448
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
449
|
+
});
|
|
450
|
+
it('waits for an agent still loading, then identifies only once', async () => {
|
|
451
|
+
let finish!: () => void;
|
|
452
|
+
initMock.mockReturnValue(
|
|
453
|
+
new Promise<void>((resolve) => {
|
|
454
|
+
finish = resolve;
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
const initialization = fingerprintService.initFingerprint('late-key');
|
|
458
|
+
const first = fingerprintService.waitForFingerprintId();
|
|
459
|
+
const second = fingerprintService.waitForFingerprintId();
|
|
460
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
461
|
+
finish();
|
|
462
|
+
await initialization;
|
|
463
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
464
|
+
'visitor-123',
|
|
465
|
+
'visitor-123',
|
|
466
|
+
]);
|
|
467
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
468
|
+
expect(getVisitorDataMock).toHaveBeenCalledWith({}, false);
|
|
469
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
470
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
471
|
+
});
|
|
472
|
+
it('resolves null when the pending agent fails to load', async () => {
|
|
473
|
+
let fail!: (error: Error) => void;
|
|
474
|
+
initMock.mockReturnValue(
|
|
475
|
+
new Promise((_resolve, reject) => {
|
|
476
|
+
fail = reject;
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
const initialization = fingerprintService.initFingerprint('blocked-key');
|
|
480
|
+
const pending = fingerprintService.waitForFingerprintId();
|
|
481
|
+
const rejected = expect(initialization).rejects.toThrow('blocked');
|
|
482
|
+
fail(new Error('blocked'));
|
|
483
|
+
await rejected;
|
|
484
|
+
await expect(pending).resolves.toBeNull();
|
|
485
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
it.each([-1, Infinity, NaN])(
|
|
488
|
+
'resolves null for invalid budget %s',
|
|
489
|
+
async (budget) => {
|
|
490
|
+
await expect(
|
|
491
|
+
fingerprintService.waitForFingerprintId(budget),
|
|
492
|
+
).resolves.toBeNull();
|
|
493
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
it('keeps a late successful result after the caller times out without another lookup', async () => {
|
|
497
|
+
let finish!: (value: typeof visitorData) => void;
|
|
498
|
+
getVisitorDataMock.mockReturnValue(
|
|
499
|
+
new Promise((resolve) => {
|
|
500
|
+
finish = resolve;
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
setPeoplePropertiesMock.mockClear();
|
|
504
|
+
const pending = fingerprintService.waitForFingerprintId(2000);
|
|
505
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
506
|
+
await expect(pending).resolves.toBeNull();
|
|
507
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
508
|
+
finish(visitorData);
|
|
509
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
510
|
+
expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
|
|
511
|
+
expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
|
|
512
|
+
'Visitor Found': true,
|
|
513
|
+
});
|
|
514
|
+
await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
|
|
515
|
+
'visitor-123',
|
|
516
|
+
);
|
|
517
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
518
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
519
|
+
});
|
|
520
|
+
it('suppresses a timed-out request after its user lifecycle changes', async () => {
|
|
521
|
+
let finish!: (value: typeof visitorData) => void;
|
|
522
|
+
getVisitorDataMock.mockReturnValue(
|
|
523
|
+
new Promise((resolve) => {
|
|
524
|
+
finish = resolve;
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
setPeoplePropertiesMock.mockClear();
|
|
528
|
+
const pending = fingerprintService.waitForFingerprintId(2000);
|
|
529
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
530
|
+
await expect(pending).resolves.toBeNull();
|
|
531
|
+
trackingLifecycle.identify('replacement-user-after-timeout');
|
|
532
|
+
finish(visitorData);
|
|
533
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
534
|
+
expect(fingerprintService.getCachedFingerprint()).toBeNull();
|
|
535
|
+
expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
|
|
536
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
537
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
538
|
+
});
|
|
539
|
+
it('resolves null at its timeout and consumes late rejection', async () => {
|
|
540
|
+
let fail!: (error: Error) => void;
|
|
541
|
+
getVisitorDataMock.mockReturnValue(
|
|
542
|
+
new Promise((_resolve, reject) => {
|
|
543
|
+
fail = reject;
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
const pending = fingerprintService.waitForFingerprintId(100);
|
|
547
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
548
|
+
await expect(pending).resolves.toBeNull();
|
|
549
|
+
fail(new Error('offline'));
|
|
550
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
551
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
552
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
553
|
+
});
|
|
554
|
+
it('cancels when initialization changes and ignores an old result', async () => {
|
|
555
|
+
let finish!: (value: typeof visitorData) => void;
|
|
556
|
+
getVisitorDataMock.mockReturnValue(
|
|
557
|
+
new Promise((resolve) => {
|
|
558
|
+
finish = resolve;
|
|
559
|
+
}),
|
|
560
|
+
);
|
|
561
|
+
const pending = fingerprintService.waitForFingerprintId();
|
|
562
|
+
await fingerprintService.initFingerprint('replacement');
|
|
563
|
+
await expect(pending).resolves.toBeNull();
|
|
564
|
+
finish(visitorData);
|
|
565
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
566
|
+
expect(fingerprintService.getCachedFingerprint()).toBeNull();
|
|
567
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
568
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
@@ -30,7 +30,16 @@ const PROXY_QUERY_PARAM =
|
|
|
30
30
|
*/
|
|
31
31
|
let fingerprintAgentReady = false;
|
|
32
32
|
let initialization = 0;
|
|
33
|
+
let startedInitialization = -1;
|
|
33
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;
|
|
34
43
|
|
|
35
44
|
/** Notifies deferred observations only when the actual provider agent has loaded. */
|
|
36
45
|
function onReady(notify: () => void): () => void {
|
|
@@ -44,6 +53,8 @@ function onReady(notify: () => void): () => void {
|
|
|
44
53
|
function invalidate(): void {
|
|
45
54
|
initialization++;
|
|
46
55
|
fingerprintAgentReady = false;
|
|
56
|
+
pendingIdentification = undefined;
|
|
57
|
+
waitingConsumers.forEach((cancel) => cancel());
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/**
|
|
@@ -133,7 +144,8 @@ async function initFingerprint(
|
|
|
133
144
|
customScriptEndpointArray.unshift(buildProxyUrl(proxyUrl));
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
invalidate();
|
|
147
|
+
if (startedInitialization === initialization) invalidate();
|
|
148
|
+
startedInitialization = initialization;
|
|
137
149
|
const attempt = initialization;
|
|
138
150
|
window.fingerprintConfig = {};
|
|
139
151
|
const client = new FpjsClient({
|
|
@@ -154,6 +166,7 @@ async function initFingerprint(
|
|
|
154
166
|
} catch (error) {
|
|
155
167
|
if (window.fpClient === client && attempt === initialization) {
|
|
156
168
|
fingerprintAgentReady = false;
|
|
169
|
+
waitingConsumers.forEach((cancel) => cancel());
|
|
157
170
|
}
|
|
158
171
|
console.error('Error initializing identification service:', error);
|
|
159
172
|
throw error;
|
|
@@ -214,6 +227,50 @@ async function identifyVisitor(
|
|
|
214
227
|
async function requestIdentification(
|
|
215
228
|
ignoreCache: boolean,
|
|
216
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,
|
|
217
274
|
): Promise<FingerprintIdentification> {
|
|
218
275
|
if (!isFingerprintReady()) {
|
|
219
276
|
throw new Error('Identification service is not initialized.');
|
|
@@ -221,7 +278,7 @@ async function requestIdentification(
|
|
|
221
278
|
|
|
222
279
|
try {
|
|
223
280
|
const result = await window.fpClient.getVisitorData({}, ignoreCache);
|
|
224
|
-
if (
|
|
281
|
+
if (context.isActive()) {
|
|
225
282
|
try {
|
|
226
283
|
setCachedFingerprint(result.visitorId);
|
|
227
284
|
} catch {
|
|
@@ -233,8 +290,9 @@ async function requestIdentification(
|
|
|
233
290
|
'Visitor Found': result.visitorFound,
|
|
234
291
|
});
|
|
235
292
|
} catch (error) {
|
|
236
|
-
|
|
237
|
-
|
|
293
|
+
context.report('fingerprint', 'failed');
|
|
294
|
+
if (hasUnscopedConsumer())
|
|
295
|
+
console.error('Error updating Mixpanel visitor profile:', error);
|
|
238
296
|
}
|
|
239
297
|
}
|
|
240
298
|
}
|
|
@@ -249,11 +307,53 @@ async function requestIdentification(
|
|
|
249
307
|
cacheHit: result.cacheHit,
|
|
250
308
|
};
|
|
251
309
|
} catch (error) {
|
|
252
|
-
if (
|
|
310
|
+
if (hasUnscopedConsumer())
|
|
311
|
+
console.error('Error retrieving fingerprint:', error);
|
|
253
312
|
throw error;
|
|
254
313
|
}
|
|
255
314
|
}
|
|
256
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
|
+
|
|
257
357
|
/**
|
|
258
358
|
* Checks if the Fingerprint agent finished loading and can identify visitors.
|
|
259
359
|
* @returns {boolean} True only after `initFingerprint()` has successfully loaded the agent.
|
|
@@ -270,6 +370,7 @@ const fingerprintService = {
|
|
|
270
370
|
onReady,
|
|
271
371
|
invalidate,
|
|
272
372
|
getCachedFingerprint,
|
|
373
|
+
waitForFingerprintId,
|
|
273
374
|
};
|
|
274
375
|
|
|
275
376
|
// One module-lifetime listener invalidates the old agent before Mixpanel signals readiness.
|
|
@@ -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
|
+
});
|