@reservamos/browser-analytics 1.1.8-alpha.1 → 1.1.8-alpha.3
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 +19 -2
- package/README.md +32 -4
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +23 -10
- package/dist/browser-analytics.esm.js +466 -154
- 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.recovery.test.ts +367 -0
- package/src/events/identify/identify.robustness.test.ts +112 -13
- package/src/events/identify/identify.ts +72 -35
- 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/init.ts +10 -1
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +18 -2
- 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.batch.test.ts +108 -0
- package/src/services/mixpanel.test.ts +77 -73
- package/src/services/mixpanel.ts +45 -18
- package/src/services/profileEnrichment.ts +258 -0
- package/src/track.storage.test.ts +27 -12
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import mixpanel from 'mixpanel-browser';
|
|
2
|
+
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
|
|
3
|
+
import mixpanelService from './mixpanel';
|
|
4
|
+
|
|
5
|
+
interface BatchPayload {
|
|
6
|
+
event?: string;
|
|
7
|
+
properties?: Record<string, unknown>;
|
|
8
|
+
$distinct_id?: string;
|
|
9
|
+
$set?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Keep the real identity handling, batching, storage, and timers; replace HTTP only.
|
|
13
|
+
const transport = mixpanel as unknown as {
|
|
14
|
+
_send_request(
|
|
15
|
+
url: string,
|
|
16
|
+
data: { data: string },
|
|
17
|
+
options: object,
|
|
18
|
+
callback?: (response: number) => void,
|
|
19
|
+
): boolean;
|
|
20
|
+
stop_batch_senders(): void;
|
|
21
|
+
_flags: { identify_called: boolean };
|
|
22
|
+
};
|
|
23
|
+
const batches: { url: string; payload: BatchPayload[] }[] = [];
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
localStorage.clear();
|
|
28
|
+
await mixpanelService.init('one-second-batching-token');
|
|
29
|
+
vi.spyOn(transport, '_send_request').mockImplementation(
|
|
30
|
+
(url, data, _options, callback) => {
|
|
31
|
+
if (url.endsWith('/track/') || url.endsWith('/engage/')) {
|
|
32
|
+
batches.push({
|
|
33
|
+
url,
|
|
34
|
+
payload: JSON.parse(data.data) as BatchPayload[],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
callback?.(1);
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
transport.stop_batch_senders();
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
vi.clearAllTimers();
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
localStorage.clear();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('batches native identity and people updates within one second', async () => {
|
|
52
|
+
mixpanelService.identify('redirect-user', { $email: 'redirect@example.com' });
|
|
53
|
+
|
|
54
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
55
|
+
expect(batches).toEqual([]);
|
|
56
|
+
|
|
57
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
58
|
+
expect(batches).toHaveLength(2);
|
|
59
|
+
expect(batches.find(({ url }) => url.endsWith('/track/'))?.payload).toEqual([
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
event: '$identify',
|
|
62
|
+
properties: expect.objectContaining({ distinct_id: 'redirect-user' }),
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
expect(
|
|
66
|
+
batches.find(({ url }) => url.endsWith('/engage/'))?.payload,
|
|
67
|
+
).toContainEqual(
|
|
68
|
+
expect.objectContaining({
|
|
69
|
+
$distinct_id: 'redirect-user',
|
|
70
|
+
$set: expect.objectContaining({ $email: 'redirect@example.com' }),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('restores people readiness for the unchanged user without another native identity event', async () => {
|
|
76
|
+
mixpanelService.identify('restored-user', {});
|
|
77
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
78
|
+
batches.length = 0;
|
|
79
|
+
transport._flags.identify_called = false;
|
|
80
|
+
const accepted = vi.fn();
|
|
81
|
+
const report = vi.fn();
|
|
82
|
+
|
|
83
|
+
mixpanelService.restorePeopleProperties(
|
|
84
|
+
'other-user',
|
|
85
|
+
{ reservamos_one_id: 'wrong' },
|
|
86
|
+
report,
|
|
87
|
+
accepted,
|
|
88
|
+
);
|
|
89
|
+
expect(accepted).not.toHaveBeenCalled();
|
|
90
|
+
|
|
91
|
+
mixpanelService.restorePeopleProperties(
|
|
92
|
+
'restored-user',
|
|
93
|
+
{ reservamos_one_id: 42 },
|
|
94
|
+
report,
|
|
95
|
+
accepted,
|
|
96
|
+
);
|
|
97
|
+
expect(accepted).toHaveBeenCalledTimes(1);
|
|
98
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
99
|
+
expect(batches).toHaveLength(1);
|
|
100
|
+
expect(batches[0].url).toContain('/engage/');
|
|
101
|
+
expect(batches[0].payload).toContainEqual(
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
$distinct_id: 'restored-user',
|
|
104
|
+
$set: expect.objectContaining({ reservamos_one_id: 42 }),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
expect(report).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
@@ -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,23 +12,61 @@ 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(() => {
|
|
@@ -37,54 +74,30 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
37
74
|
vi.unstubAllGlobals();
|
|
38
75
|
vi.clearAllTimers();
|
|
39
76
|
vi.useRealTimers();
|
|
77
|
+
vi.clearAllMocks();
|
|
40
78
|
});
|
|
41
79
|
|
|
42
80
|
afterAll(() => {
|
|
43
|
-
transport.stop_batch_senders();
|
|
44
81
|
localStorage.clear();
|
|
45
82
|
});
|
|
46
83
|
|
|
47
|
-
it('registers SDK metadata
|
|
48
|
-
expect(mixpanel.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const send = vi
|
|
52
|
-
.spyOn(transport, '_track_or_batch')
|
|
53
|
-
.mockImplementation(({ data }) => data);
|
|
54
|
-
|
|
55
|
-
mixpanelService.identify('sdk-version-user', {});
|
|
56
|
-
const events = send.mock.calls
|
|
57
|
-
.map(([options]) => options.data)
|
|
58
|
-
.filter((data) => data.event);
|
|
59
|
-
|
|
60
|
-
expect(events).toHaveLength(1);
|
|
61
|
-
expect(events[0]).toEqual({
|
|
62
|
-
event: '$identify',
|
|
63
|
-
properties: expect.objectContaining({
|
|
64
|
-
'Event Source': 'sdk',
|
|
65
|
-
'Reservamos Version': version,
|
|
66
|
-
'$lib_version': mixpanelVersion,
|
|
67
|
-
}),
|
|
84
|
+
it('registers SDK metadata before identifying at the provider boundary', () => {
|
|
85
|
+
expect(mixpanel.register).toHaveBeenCalledWith({
|
|
86
|
+
'Event Source': 'sdk',
|
|
87
|
+
'Reservamos Version': version,
|
|
68
88
|
});
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
expect(mixpanel.register).not.toHaveBeenCalledWith(
|
|
90
|
+
expect.objectContaining({ $lib_version: expect.anything() }),
|
|
91
|
+
);
|
|
71
92
|
mixpanelService.identify('sdk-version-user', {});
|
|
72
|
-
expect(
|
|
93
|
+
expect(mixpanel.identify).toHaveBeenCalledWith('sdk-version-user');
|
|
94
|
+
expect(mixpanel.people.set).toHaveBeenCalledWith({});
|
|
73
95
|
});
|
|
74
96
|
|
|
75
97
|
it('keeps the SDK version authoritative for SDK-tracked events', () => {
|
|
76
|
-
const send = vi
|
|
77
|
-
.spyOn(transport, '_track_or_batch')
|
|
78
|
-
.mockImplementation(({ data }) => data);
|
|
79
|
-
|
|
80
98
|
mixpanelService.track('Test', { 'Reservamos Version': 'caller-version' });
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
event: 'Test',
|
|
84
|
-
properties: expect.objectContaining({
|
|
85
|
-
'Event Source': 'sdk',
|
|
86
|
-
'Reservamos Version': version,
|
|
87
|
-
}),
|
|
99
|
+
expect(mixpanel.track).toHaveBeenCalledWith('Test', {
|
|
100
|
+
'Reservamos Version': version,
|
|
88
101
|
});
|
|
89
102
|
});
|
|
90
103
|
|
|
@@ -104,17 +117,12 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
104
117
|
});
|
|
105
118
|
|
|
106
119
|
it('still sets people properties when the SDK distinct ID cache is blocked', () => {
|
|
107
|
-
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
108
|
-
({ data }) => data,
|
|
109
|
-
);
|
|
110
120
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
111
121
|
throw new DOMException('Blocked', 'SecurityError');
|
|
112
122
|
});
|
|
113
|
-
const people = vi
|
|
114
|
-
.spyOn(mixpanel.people, 'set')
|
|
115
|
-
.mockImplementation(() => undefined);
|
|
116
123
|
mixpanelService.identify('cache-blocked-user', {});
|
|
117
|
-
expect(
|
|
124
|
+
expect(mixpanel.identify).toHaveBeenCalledWith('cache-blocked-user');
|
|
125
|
+
expect(mixpanel.people.set).toHaveBeenCalledWith({});
|
|
118
126
|
});
|
|
119
127
|
|
|
120
128
|
it.each(['getItem', 'setItem'] as const)(
|
|
@@ -131,31 +139,33 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
131
139
|
it.each(['getItem', 'setItem'] as const)(
|
|
132
140
|
'submits the original event when SDK storage %s is blocked',
|
|
133
141
|
async (method) => {
|
|
134
|
-
|
|
135
|
-
.spyOn(transport, '_track_or_batch')
|
|
136
|
-
.mockImplementation(({ data }) => data);
|
|
137
|
-
mixpanel.identify('storage-test-id');
|
|
138
|
-
send.mockClear();
|
|
142
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('storage-test-id');
|
|
139
143
|
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
140
144
|
throw new DOMException('Blocked', 'SecurityError');
|
|
141
145
|
});
|
|
142
146
|
await trackEvent('Storage Failure Test', { source: 'checkout' });
|
|
143
|
-
expect(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
source: 'checkout',
|
|
147
|
-
|
|
147
|
+
expect(mixpanel.track).toHaveBeenCalledWith(
|
|
148
|
+
'Storage Failure Test',
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
'source': 'checkout',
|
|
151
|
+
'Reservamos Version': version,
|
|
148
152
|
}),
|
|
149
|
-
|
|
153
|
+
);
|
|
154
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('storage-test-id');
|
|
150
155
|
},
|
|
151
156
|
);
|
|
152
157
|
|
|
153
|
-
it('
|
|
158
|
+
it('submits the original event when no live distinct ID is available and the cache is blocked', async () => {
|
|
154
159
|
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
155
160
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
156
161
|
throw new DOMException('Blocked', 'SecurityError');
|
|
157
162
|
});
|
|
158
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
|
+
);
|
|
159
169
|
});
|
|
160
170
|
|
|
161
171
|
it('uses the live distinct ID first and null fallback when localStorage is null', () => {
|
|
@@ -214,9 +224,6 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
214
224
|
(response) => {
|
|
215
225
|
vi.useFakeTimers();
|
|
216
226
|
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
217
|
-
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
218
|
-
({ data }) => data,
|
|
219
|
-
);
|
|
220
227
|
let callback: ((response: unknown) => void) | undefined;
|
|
221
228
|
vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
|
|
222
229
|
callback = args[1] as (response: unknown) => void;
|
|
@@ -245,9 +252,6 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
245
252
|
it('reports missing people callback as unobserved and ignores a late result', () => {
|
|
246
253
|
vi.useFakeTimers();
|
|
247
254
|
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
248
|
-
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
249
|
-
({ data }) => data,
|
|
250
|
-
);
|
|
251
255
|
let callback: ((response: unknown) => void) | undefined;
|
|
252
256
|
vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
|
|
253
257
|
callback = args[1] as (response: unknown) => void;
|