@reservamos/browser-analytics 1.1.8-alpha.0 → 1.1.8-alpha.2
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
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import coreApi from '@reservamos/js-api-client/core';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const provider = vi.hoisted(() => ({
|
|
5
|
+
initMixpanel: vi.fn(),
|
|
6
|
+
track: vi.fn(),
|
|
7
|
+
identify: vi.fn(),
|
|
8
|
+
distinctId: vi.fn(),
|
|
9
|
+
register: vi.fn(),
|
|
10
|
+
setPeople: vi.fn(),
|
|
11
|
+
setConfig: vi.fn(),
|
|
12
|
+
initFingerprint: vi.fn(),
|
|
13
|
+
getVisitorData: vi.fn(),
|
|
14
|
+
client: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('mixpanel-browser', () => ({
|
|
17
|
+
default: {
|
|
18
|
+
init: provider.initMixpanel,
|
|
19
|
+
track: provider.track,
|
|
20
|
+
identify: provider.identify,
|
|
21
|
+
get_distinct_id: provider.distinctId,
|
|
22
|
+
register: provider.register,
|
|
23
|
+
set_config: provider.setConfig,
|
|
24
|
+
people: { set: provider.setPeople },
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
|
|
28
|
+
FingerprintJSPro: {
|
|
29
|
+
defaultEndpoint: 'endpoint',
|
|
30
|
+
defaultScriptUrlPattern: 'agent',
|
|
31
|
+
},
|
|
32
|
+
FpjsClient: provider.client,
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('@/services/geolocation', () => ({
|
|
35
|
+
default: {
|
|
36
|
+
init: async () => undefined,
|
|
37
|
+
getCoordinates: () => null,
|
|
38
|
+
getGeolocationSource: () => null,
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const search = {
|
|
43
|
+
'Departure': '2026-11-01',
|
|
44
|
+
'Departure Delta': 1,
|
|
45
|
+
'Destination': 'Puebla',
|
|
46
|
+
'Destination Terminal': 'CAPU',
|
|
47
|
+
'Origin': 'Mexico City',
|
|
48
|
+
'Origin Terminal': 'TAPO',
|
|
49
|
+
'Passengers': 1,
|
|
50
|
+
'Route': 'Mexico City-Puebla',
|
|
51
|
+
'product': 'web' as const,
|
|
52
|
+
};
|
|
53
|
+
const visitor = {
|
|
54
|
+
visitorId: 'visitor',
|
|
55
|
+
requestId: 'request',
|
|
56
|
+
visitorFound: true,
|
|
57
|
+
confidence: { score: 0.99 },
|
|
58
|
+
cacheHit: false,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe('tracking recovery through real SDK services and validation', () => {
|
|
62
|
+
let tracking: typeof import('./track');
|
|
63
|
+
let mixpanel: typeof import('@/services/mixpanel').default;
|
|
64
|
+
let fingerprint: typeof import('@/services/fingerprint').default;
|
|
65
|
+
let lifecycle: typeof import('@/services/trackingLifecycle').default;
|
|
66
|
+
let loaded: () => void;
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
vi.resetModules();
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
vi.resetAllMocks();
|
|
71
|
+
localStorage.clear();
|
|
72
|
+
Reflect.deleteProperty(window, 'mixpanel');
|
|
73
|
+
Reflect.deleteProperty(window, 'fingerprintConfig');
|
|
74
|
+
Reflect.deleteProperty(window, 'fpClient');
|
|
75
|
+
provider.distinctId.mockReturnValue('anonymous');
|
|
76
|
+
provider.initMixpanel.mockImplementation(
|
|
77
|
+
(_token, options: { loaded: () => void }) => {
|
|
78
|
+
loaded = options.loaded;
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
provider.initFingerprint.mockResolvedValue(undefined);
|
|
82
|
+
provider.getVisitorData.mockResolvedValue(visitor);
|
|
83
|
+
provider.client.mockImplementation(() => ({
|
|
84
|
+
init: provider.initFingerprint,
|
|
85
|
+
getVisitorData: provider.getVisitorData,
|
|
86
|
+
}));
|
|
87
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
88
|
+
vi.spyOn(coreApi.fingerprint, 'sendObservation').mockResolvedValue(true);
|
|
89
|
+
tracking = await import('./track');
|
|
90
|
+
mixpanel = (await import('@/services/mixpanel')).default;
|
|
91
|
+
fingerprint = (await import('@/services/fingerprint')).default;
|
|
92
|
+
lifecycle = (await import('@/services/trackingLifecycle')).default;
|
|
93
|
+
});
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
lifecycle.beginInitialization();
|
|
96
|
+
lifecycle.beginInitialization();
|
|
97
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
98
|
+
vi.clearAllTimers();
|
|
99
|
+
vi.restoreAllMocks();
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
});
|
|
102
|
+
async function readyMixpanel() {
|
|
103
|
+
const pending = mixpanel.init('test');
|
|
104
|
+
loaded();
|
|
105
|
+
await pending;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('recovers at 5.2 seconds with the original snapshot and exactly one provider call', async () => {
|
|
109
|
+
const properties = { source: 'original', values: [1, 2] };
|
|
110
|
+
const pending = tracking.trackEvent('Recovery Test', properties);
|
|
111
|
+
properties.source = 'mutated';
|
|
112
|
+
properties.values.push(3);
|
|
113
|
+
await vi.advanceTimersByTimeAsync(5200);
|
|
114
|
+
await pending;
|
|
115
|
+
expect(provider.track).not.toHaveBeenCalled();
|
|
116
|
+
await readyMixpanel();
|
|
117
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
118
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
119
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
120
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
121
|
+
expect(provider.track).toHaveBeenCalledWith(
|
|
122
|
+
'Recovery Test',
|
|
123
|
+
expect.objectContaining({ source: 'original', values: [1, 2] }),
|
|
124
|
+
);
|
|
125
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
126
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('never replays an accepted event after its provider throws and later recovers', async () => {
|
|
130
|
+
await readyMixpanel();
|
|
131
|
+
const accepted: string[] = [];
|
|
132
|
+
provider.track.mockImplementationOnce((event: string) => {
|
|
133
|
+
accepted.push(event);
|
|
134
|
+
throw new Error('accepted');
|
|
135
|
+
});
|
|
136
|
+
await tracking.trackEvent('Recovery Test', { source: 'checkout' });
|
|
137
|
+
provider.track.mockImplementation(() => undefined);
|
|
138
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
139
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
140
|
+
expect(accepted).toEqual(['Recovery Test']);
|
|
141
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('keeps two legitimate identical calls and does not collapse them', async () => {
|
|
145
|
+
const first = tracking.trackEvent('Recovery Test', { value: 1 });
|
|
146
|
+
const second = tracking.trackEvent('Recovery Test', { value: 1 });
|
|
147
|
+
await readyMixpanel();
|
|
148
|
+
await Promise.all([first, second]);
|
|
149
|
+
expect(provider.track).toHaveBeenCalledTimes(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('preserves allowed undefined array values instead of revalidating them as null', async () => {
|
|
153
|
+
const pending = tracking.trackEvent('Recovery Test', {
|
|
154
|
+
values: [undefined],
|
|
155
|
+
source: 'checkout',
|
|
156
|
+
});
|
|
157
|
+
await readyMixpanel();
|
|
158
|
+
await pending;
|
|
159
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
160
|
+
expect(provider.track).toHaveBeenCalledWith(
|
|
161
|
+
'Recovery Test',
|
|
162
|
+
expect.objectContaining({ source: 'checkout' }),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('drops expired entries and invalidates waiting work on reinitialization', async () => {
|
|
167
|
+
const expired = tracking.trackEvent('Expired', {});
|
|
168
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
169
|
+
await expired;
|
|
170
|
+
void mixpanel.init('first');
|
|
171
|
+
const stale = tracking.trackEvent('Stale', {});
|
|
172
|
+
const currentInit = mixpanel.init('second');
|
|
173
|
+
loaded();
|
|
174
|
+
await currentInit;
|
|
175
|
+
await stale;
|
|
176
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
177
|
+
expect(provider.track).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('sends the original at tracker readiness and observes only after Fingerprint loads', async () => {
|
|
181
|
+
await readyMixpanel();
|
|
182
|
+
await tracking.trackEvent('Search', search);
|
|
183
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
184
|
+
expect(provider.getVisitorData).not.toHaveBeenCalled();
|
|
185
|
+
await vi.advanceTimersByTimeAsync(5200);
|
|
186
|
+
await fingerprint.initFingerprint('test-key');
|
|
187
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
188
|
+
expect(provider.getVisitorData).toHaveBeenCalledOnce();
|
|
189
|
+
expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledWith(
|
|
190
|
+
expect.objectContaining({
|
|
191
|
+
requestId: 'request',
|
|
192
|
+
distinctId: 'anonymous',
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('suppresses late cache, profile and relay effects after observation expiry', async () => {
|
|
199
|
+
await readyMixpanel();
|
|
200
|
+
await fingerprint.initFingerprint('test-key');
|
|
201
|
+
let finish!: (value: typeof visitor) => void;
|
|
202
|
+
provider.getVisitorData.mockReturnValue(
|
|
203
|
+
new Promise((resolve) => {
|
|
204
|
+
finish = resolve;
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
await tracking.trackEvent('Search', search);
|
|
208
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
209
|
+
finish(visitor);
|
|
210
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
211
|
+
expect(provider.setPeople).not.toHaveBeenCalled();
|
|
212
|
+
expect(fingerprint.getCachedFingerprint()).toBeNull();
|
|
213
|
+
expect(coreApi.fingerprint.sendObservation).not.toHaveBeenCalled();
|
|
214
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('suppresses old observation effects when identify switches user', async () => {
|
|
218
|
+
await readyMixpanel();
|
|
219
|
+
await tracking.trackEvent('Search', search);
|
|
220
|
+
mixpanel.identify('new-user', {});
|
|
221
|
+
provider.setPeople.mockClear();
|
|
222
|
+
await fingerprint.initFingerprint('test-key');
|
|
223
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
224
|
+
expect(provider.getVisitorData).not.toHaveBeenCalled();
|
|
225
|
+
expect(coreApi.fingerprint.sendObservation).not.toHaveBeenCalled();
|
|
226
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('caps actual concurrent identification requests even after their local deadlines expire', async () => {
|
|
230
|
+
await readyMixpanel();
|
|
231
|
+
await fingerprint.initFingerprint('test-key');
|
|
232
|
+
provider.getVisitorData.mockReturnValue(new Promise(() => undefined));
|
|
233
|
+
for (let i = 0; i < 5; i++) await tracking.trackEvent('Search', search);
|
|
234
|
+
expect(provider.getVisitorData).toHaveBeenCalledTimes(3);
|
|
235
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
236
|
+
await tracking.trackEvent('Search', search);
|
|
237
|
+
expect(provider.getVisitorData).toHaveBeenCalledTimes(3);
|
|
238
|
+
expect(provider.track).toHaveBeenCalledTimes(6);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('waits for the current init instead of using the old provider or a stale loaded callback', async () => {
|
|
242
|
+
void mixpanel.init('superseded');
|
|
243
|
+
const staleLoaded = loaded;
|
|
244
|
+
const currentInit = mixpanel.init('current');
|
|
245
|
+
const pending = tracking.trackEvent('Current', {});
|
|
246
|
+
staleLoaded();
|
|
247
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
248
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
249
|
+
expect(provider.track).not.toHaveBeenCalled();
|
|
250
|
+
loaded();
|
|
251
|
+
await currentInit;
|
|
252
|
+
await pending;
|
|
253
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('invalidates an event whose identity changes before its asynchronous cache read completes', async () => {
|
|
257
|
+
await readyMixpanel();
|
|
258
|
+
const pending = tracking.trackEvent('Old User', {});
|
|
259
|
+
mixpanel.identify('new-user', {});
|
|
260
|
+
await pending;
|
|
261
|
+
expect(provider.track).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('uses the public Search wrapper from Tracker Ready and observes after the real agent loads', async () => {
|
|
265
|
+
let finish!: () => void;
|
|
266
|
+
provider.initFingerprint.mockReturnValue(
|
|
267
|
+
new Promise<void>((resolve) => {
|
|
268
|
+
finish = resolve;
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
const analytics = (await import('./index')).default;
|
|
272
|
+
const onReady = () => {
|
|
273
|
+
analytics.track.search(search);
|
|
274
|
+
};
|
|
275
|
+
window.addEventListener('Tracker Ready', onReady, { once: true });
|
|
276
|
+
const initialized = analytics.init({
|
|
277
|
+
mixpanelToken: 'test',
|
|
278
|
+
identificationKey: 'test-key',
|
|
279
|
+
});
|
|
280
|
+
loaded();
|
|
281
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
282
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
283
|
+
expect(provider.getVisitorData).not.toHaveBeenCalled();
|
|
284
|
+
await vi.advanceTimersByTimeAsync(5200);
|
|
285
|
+
await initialized;
|
|
286
|
+
finish();
|
|
287
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
288
|
+
expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledOnce();
|
|
289
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('prevents an older public init from replacing the newer Core API and agent configuration', async () => {
|
|
293
|
+
const configure = vi.spyOn(coreApi, 'setConfig');
|
|
294
|
+
const analytics = (await import('./index')).default;
|
|
295
|
+
const oldInit = analytics.init({
|
|
296
|
+
mixpanelToken: 'test',
|
|
297
|
+
identificationKey: 'old-agent',
|
|
298
|
+
appName: 'old-app',
|
|
299
|
+
});
|
|
300
|
+
const oldLoaded = loaded;
|
|
301
|
+
const currentInit = analytics.init({
|
|
302
|
+
mixpanelToken: 'test',
|
|
303
|
+
identificationKey: 'new-agent',
|
|
304
|
+
appName: 'new-app',
|
|
305
|
+
});
|
|
306
|
+
loaded();
|
|
307
|
+
await currentInit;
|
|
308
|
+
oldLoaded();
|
|
309
|
+
await oldInit;
|
|
310
|
+
expect(configure).toHaveBeenCalledOnce();
|
|
311
|
+
expect(configure.mock.calls[0][0]?.defaultHeaders?.['x-app-name']).toBe(
|
|
312
|
+
'new-app',
|
|
313
|
+
);
|
|
314
|
+
expect(provider.client).toHaveBeenCalledOnce();
|
|
315
|
+
expect(provider.client).toHaveBeenCalledWith(
|
|
316
|
+
expect.objectContaining({
|
|
317
|
+
loadOptions: expect.objectContaining({ apiKey: 'new-agent' }),
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('suppresses expired View Results refresh effects without replaying the event', async () => {
|
|
323
|
+
await readyMixpanel();
|
|
324
|
+
await fingerprint.initFingerprint('test-key');
|
|
325
|
+
let finish!: (value: typeof visitor) => void;
|
|
326
|
+
provider.getVisitorData.mockReturnValue(
|
|
327
|
+
new Promise((resolve) => {
|
|
328
|
+
finish = resolve;
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
await tracking.trackEvent('View Results', { ...search, 'Bus count': 10 });
|
|
332
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
333
|
+
finish(visitor);
|
|
334
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
335
|
+
expect(provider.setPeople).not.toHaveBeenCalled();
|
|
336
|
+
expect(fingerprint.getCachedFingerprint()).toBeNull();
|
|
337
|
+
expect(provider.track).toHaveBeenCalledOnce();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import coreApi from '@reservamos/js-api-client/core';
|
|
2
|
+
import mixpanel from 'mixpanel-browser';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import fingerprintService from '@/services/fingerprint';
|
|
5
|
+
import mixpanelService from '@/services/mixpanel';
|
|
6
|
+
import { trackEvent } from './track';
|
|
7
|
+
|
|
8
|
+
const { getVisitorData, init, client } = vi.hoisted(() => ({
|
|
9
|
+
getVisitorData: vi.fn(),
|
|
10
|
+
init: vi.fn(),
|
|
11
|
+
client: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const { provider } = vi.hoisted(() => ({
|
|
15
|
+
provider: {
|
|
16
|
+
init: vi.fn((_token: string, config: { loaded: () => void }) =>
|
|
17
|
+
config.loaded(),
|
|
18
|
+
),
|
|
19
|
+
register: vi.fn(),
|
|
20
|
+
set_config: vi.fn(),
|
|
21
|
+
identify: vi.fn(),
|
|
22
|
+
track: vi.fn(),
|
|
23
|
+
get_distinct_id: vi.fn(() => 'live-id'),
|
|
24
|
+
people: { set: vi.fn(), union: vi.fn() },
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('mixpanel-browser', () => ({ default: provider }));
|
|
28
|
+
|
|
29
|
+
// Keep the SDK Fingerprint cache, Mixpanel service, tracking, validation, and relay real.
|
|
30
|
+
vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
|
|
31
|
+
FingerprintJSPro: {
|
|
32
|
+
defaultEndpoint: 'https://fpjscdn.net',
|
|
33
|
+
defaultScriptUrlPattern: 'https://fpjscdn.net/agent',
|
|
34
|
+
},
|
|
35
|
+
FpjsClient: client,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const search = {
|
|
39
|
+
'Departure': '2026-11-01',
|
|
40
|
+
'Departure Delta': 1,
|
|
41
|
+
'Destination': 'Puebla',
|
|
42
|
+
'Destination Terminal': 'CAPU',
|
|
43
|
+
'Origin': 'Mexico City',
|
|
44
|
+
'Origin Terminal': 'TAPO',
|
|
45
|
+
'Passengers': 1,
|
|
46
|
+
'Route': 'Mexico City-Puebla',
|
|
47
|
+
'product': 'web',
|
|
48
|
+
};
|
|
49
|
+
const checkout = {
|
|
50
|
+
'Trips': [
|
|
51
|
+
{
|
|
52
|
+
'Departure Arrival': '2026-11-01T12:00:00',
|
|
53
|
+
'Departure Destination': 'Puebla',
|
|
54
|
+
'Departure Destination Terminal': 'CAPU',
|
|
55
|
+
'Departure Line': 'Bus line',
|
|
56
|
+
'Departure Origin': 'Mexico City',
|
|
57
|
+
'Departure Origin Terminal': 'TAPO',
|
|
58
|
+
'Departure Price': 100,
|
|
59
|
+
'Departure Route': 'Mexico City-Puebla',
|
|
60
|
+
'Departure Stops': 1,
|
|
61
|
+
'Departure Time': '2026-11-01T10:00:00',
|
|
62
|
+
'Departure Transport Type': 'bus',
|
|
63
|
+
'Departure Transporter': 'Carrier',
|
|
64
|
+
'Recommended Trip': false,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
'Passenger Count': 1,
|
|
68
|
+
'Trip Count': 1,
|
|
69
|
+
'Total': 100,
|
|
70
|
+
'product': 'web',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe('tracking with blocked SDK Fingerprint cache writes', () => {
|
|
74
|
+
beforeEach(async () => {
|
|
75
|
+
localStorage.clear();
|
|
76
|
+
await mixpanelService.init('test-storage-token');
|
|
77
|
+
client.mockImplementation(() => ({ init, getVisitorData }));
|
|
78
|
+
init.mockResolvedValue(undefined);
|
|
79
|
+
getVisitorData.mockResolvedValue({
|
|
80
|
+
visitorId: 'visitor-123',
|
|
81
|
+
requestId: 'request-123',
|
|
82
|
+
visitorFound: true,
|
|
83
|
+
confidence: { score: 0.99 },
|
|
84
|
+
cacheHit: false,
|
|
85
|
+
});
|
|
86
|
+
await fingerprintService.initFingerprint('test-api-key');
|
|
87
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
|
|
88
|
+
vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
|
|
89
|
+
vi.spyOn(coreApi.fingerprint, 'sendObservation').mockResolvedValue(true);
|
|
90
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
91
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
vi.restoreAllMocks();
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it.each([
|
|
101
|
+
['Search', search, true],
|
|
102
|
+
['View Results', { ...search, 'Bus count': 10 }, false],
|
|
103
|
+
['Purchase Attempt', checkout, true],
|
|
104
|
+
['Payment Attempt', { ...checkout, 'Payment Type': 'credit_card' }, true],
|
|
105
|
+
] as const)(
|
|
106
|
+
'preserves valid %s and its eligible observation',
|
|
107
|
+
async (eventName, properties, eligible) => {
|
|
108
|
+
await trackEvent(eventName, properties);
|
|
109
|
+
|
|
110
|
+
expect(mixpanel.track).toHaveBeenCalledOnce();
|
|
111
|
+
expect(mixpanel.track).toHaveBeenCalledWith(
|
|
112
|
+
eventName,
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
'User Fingerprint': expect.any(String),
|
|
115
|
+
'product': 'web',
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
/**
|
|
119
|
+
* Checks effects that may finish after event submission during background refresh.
|
|
120
|
+
* @returns {void} Throws until the expected profile and relay effects are observed.
|
|
121
|
+
*/
|
|
122
|
+
const verifyIdentificationEffects = () => {
|
|
123
|
+
expect(mixpanel.people.set).toHaveBeenCalledWith({
|
|
124
|
+
'Visitor Found': true,
|
|
125
|
+
});
|
|
126
|
+
expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledTimes(
|
|
127
|
+
eligible ? 1 : 0,
|
|
128
|
+
);
|
|
129
|
+
if (eligible) {
|
|
130
|
+
expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledWith(
|
|
131
|
+
expect.objectContaining({
|
|
132
|
+
requestId: 'request-123',
|
|
133
|
+
visitorId: 'visitor-123',
|
|
134
|
+
distinctId: 'live-id',
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
await vi.waitFor(verifyIdentificationEffects);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
});
|
package/src/track.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import botDetectionRelayService from '@/services/botDetectionRelay';
|
|
3
3
|
import fingerprintService from '@/services/fingerprint';
|
|
4
4
|
import mixpanelService from '@/services/mixpanel';
|
|
5
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
5
6
|
import validator from '@/services/validator';
|
|
6
7
|
import { trackEvent, trackEventError, tryTrackEvent } from './track';
|
|
7
8
|
|
|
@@ -18,6 +19,7 @@ vi.mock('@/services/fingerprint', () => ({
|
|
|
18
19
|
getCachedFingerprint: vi.fn(),
|
|
19
20
|
identifyVisitor: vi.fn(),
|
|
20
21
|
isFingerprintReady: vi.fn(),
|
|
22
|
+
onReady: vi.fn().mockReturnValue(() => undefined),
|
|
21
23
|
},
|
|
22
24
|
}));
|
|
23
25
|
|
|
@@ -30,6 +32,7 @@ vi.mock('@/services/geolocation', () => ({
|
|
|
30
32
|
|
|
31
33
|
vi.mock('@/services/mixpanel', () => ({
|
|
32
34
|
default: {
|
|
35
|
+
onReady: vi.fn().mockReturnValue(() => undefined),
|
|
33
36
|
isReady: vi.fn().mockReturnValue(true),
|
|
34
37
|
getMixpanelDistinctId: vi.fn().mockReturnValue('distinct-123'),
|
|
35
38
|
track: vi.fn(),
|
|
@@ -42,9 +45,12 @@ vi.mock('@/services/validator', () => ({
|
|
|
42
45
|
},
|
|
43
46
|
}));
|
|
44
47
|
|
|
48
|
+
let testIdentity = 0;
|
|
45
49
|
describe('trackEvent Bot Detection relay', () => {
|
|
46
50
|
beforeEach(() => {
|
|
51
|
+
trackingLifecycle.identify(`test-${testIdentity++}`);
|
|
47
52
|
vi.resetAllMocks();
|
|
53
|
+
vi.mocked(fingerprintService.onReady).mockReturnValue(() => undefined);
|
|
48
54
|
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
49
55
|
vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
|
|
50
56
|
'distinct-123',
|
|
@@ -97,7 +103,10 @@ describe('trackEvent Bot Detection relay', () => {
|
|
|
97
103
|
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
98
104
|
await completion;
|
|
99
105
|
expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
|
|
100
|
-
expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(
|
|
106
|
+
expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(
|
|
107
|
+
true,
|
|
108
|
+
expect.objectContaining({ isActive: expect.any(Function) }),
|
|
109
|
+
);
|
|
101
110
|
await vi.waitFor(() =>
|
|
102
111
|
expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
|
|
103
112
|
);
|
|
@@ -248,13 +257,64 @@ describe('trackEvent Bot Detection relay', () => {
|
|
|
248
257
|
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
249
258
|
});
|
|
250
259
|
|
|
260
|
+
it('submits View Results while identification remains pending beyond 5.2 seconds', async () => {
|
|
261
|
+
vi.useFakeTimers();
|
|
262
|
+
try {
|
|
263
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
264
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(null);
|
|
265
|
+
vi.mocked(fingerprintService.getFingerprint).mockReturnValue(
|
|
266
|
+
new Promise(() => undefined),
|
|
267
|
+
);
|
|
268
|
+
const onComplete = vi.fn();
|
|
269
|
+
void trackEvent('View Results', { 'Results Count': 2 }).then(onComplete);
|
|
270
|
+
await vi.advanceTimersByTimeAsync(5200);
|
|
271
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
|
|
272
|
+
'User Fingerprint': '',
|
|
273
|
+
'Results Count': 2,
|
|
274
|
+
});
|
|
275
|
+
expect(onComplete).toHaveBeenCalledOnce();
|
|
276
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
277
|
+
} finally {
|
|
278
|
+
vi.useRealTimers();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('keeps cached View Results identity and does not replay after late identification', async () => {
|
|
283
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
284
|
+
let finish!: (visitor: string) => void;
|
|
285
|
+
vi.mocked(fingerprintService.getFingerprint).mockImplementation(() => {
|
|
286
|
+
expect(mixpanelService.track).toHaveBeenCalledOnce();
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
finish = resolve;
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
await trackEvent('View Results', { 'Results Count': 2 }, { source: 'web' });
|
|
292
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
|
|
293
|
+
'User Fingerprint': 'cached-visitor',
|
|
294
|
+
'Results Count': 2,
|
|
295
|
+
'source': 'web',
|
|
296
|
+
});
|
|
297
|
+
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(
|
|
298
|
+
false,
|
|
299
|
+
expect.objectContaining({ isActive: expect.any(Function) }),
|
|
300
|
+
);
|
|
301
|
+
finish('late-visitor');
|
|
302
|
+
await Promise.resolve();
|
|
303
|
+
expect(mixpanelService.track).toHaveBeenCalledOnce();
|
|
304
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
|
|
251
307
|
it('submits View Results with an empty fingerprint if identification rejects', async () => {
|
|
252
308
|
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
309
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(null);
|
|
253
310
|
vi.mocked(fingerprintService.getFingerprint).mockRejectedValue(
|
|
254
311
|
new Error('Fingerprint failed'),
|
|
255
312
|
);
|
|
256
313
|
await trackEvent('View Results', { 'Results Count': 2 }, { source: 'web' });
|
|
257
|
-
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(
|
|
314
|
+
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(
|
|
315
|
+
false,
|
|
316
|
+
expect.objectContaining({ isActive: expect.any(Function) }),
|
|
317
|
+
);
|
|
258
318
|
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
259
319
|
expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
|
|
260
320
|
'User Fingerprint': '',
|
|
@@ -277,6 +337,70 @@ describe('trackEvent Bot Detection relay', () => {
|
|
|
277
337
|
});
|
|
278
338
|
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
279
339
|
});
|
|
340
|
+
|
|
341
|
+
it('keeps the original event exactly once when identification omits requestId', async () => {
|
|
342
|
+
const identification = {
|
|
343
|
+
visitorId: 'fresh-visitor',
|
|
344
|
+
confidenceScore: 0.99,
|
|
345
|
+
cacheHit: false,
|
|
346
|
+
} as Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>;
|
|
347
|
+
vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue(
|
|
348
|
+
identification,
|
|
349
|
+
);
|
|
350
|
+
await trackEvent('Search', {});
|
|
351
|
+
await Promise.resolve();
|
|
352
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
353
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
|
|
354
|
+
'User Fingerprint': 'cached-visitor',
|
|
355
|
+
});
|
|
356
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
357
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('omits an unavailable distinctId from the background relay', async () => {
|
|
361
|
+
vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(null);
|
|
362
|
+
await trackEvent('Search', {});
|
|
363
|
+
await Promise.resolve();
|
|
364
|
+
await vi.waitFor(() => {
|
|
365
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledTimes(1);
|
|
366
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
367
|
+
requestId: 'fresh-request',
|
|
368
|
+
visitorId: 'fresh-visitor',
|
|
369
|
+
eventName: 'Search',
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it.each(['fingerprint', 'relay'] as const)(
|
|
376
|
+
'reports a controlled local diagnostic for a background %s failure',
|
|
377
|
+
async (stage) => {
|
|
378
|
+
const error = new Error('private visitor identifier');
|
|
379
|
+
if (stage === 'fingerprint') {
|
|
380
|
+
vi.mocked(fingerprintService.identifyVisitor).mockRejectedValue(error);
|
|
381
|
+
} else {
|
|
382
|
+
vi.mocked(botDetectionRelayService.relay).mockRejectedValue(error);
|
|
383
|
+
}
|
|
384
|
+
const diagnostic = vi
|
|
385
|
+
.spyOn(console, 'error')
|
|
386
|
+
.mockImplementation(() => undefined);
|
|
387
|
+
try {
|
|
388
|
+
await trackEvent('Search', {});
|
|
389
|
+
await Promise.resolve();
|
|
390
|
+
await vi.waitFor(() => {
|
|
391
|
+
expect(diagnostic).toHaveBeenCalledWith('Bot Detection diagnostic', {
|
|
392
|
+
eventName: 'Search',
|
|
393
|
+
stage,
|
|
394
|
+
reason: 'failed',
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
expect(diagnostic.mock.calls.flat()).not.toContain(error);
|
|
398
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
399
|
+
} finally {
|
|
400
|
+
diagnostic.mockRestore();
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
);
|
|
280
404
|
});
|
|
281
405
|
|
|
282
406
|
describe('bounded tracking readiness', () => {
|
|
@@ -284,6 +408,7 @@ describe('bounded tracking readiness', () => {
|
|
|
284
408
|
vi.useFakeTimers();
|
|
285
409
|
vi.resetAllMocks();
|
|
286
410
|
vi.mocked(mixpanelService.isReady).mockReturnValue(false);
|
|
411
|
+
vi.mocked(mixpanelService.onReady).mockReturnValue(() => undefined);
|
|
287
412
|
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
288
413
|
vi.mocked(fingerprintService.getFingerprint).mockResolvedValue('');
|
|
289
414
|
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
|
|
@@ -336,15 +461,15 @@ describe('bounded tracking readiness', () => {
|
|
|
336
461
|
expect(vi.getTimerCount()).toBe(0);
|
|
337
462
|
});
|
|
338
463
|
|
|
339
|
-
it('expires
|
|
464
|
+
it('expires after the retention deadline and ignores later readiness', async () => {
|
|
340
465
|
const remove = vi.spyOn(window, 'removeEventListener');
|
|
341
466
|
const pending = trackEvent('Payment Attempt', {
|
|
342
467
|
secret: 'private-payload',
|
|
343
468
|
});
|
|
344
469
|
void pending.catch(() => undefined);
|
|
345
|
-
await vi.advanceTimersByTimeAsync(
|
|
470
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
346
471
|
await expect(pending).resolves.toBeUndefined();
|
|
347
|
-
expect(console.error).toHaveBeenCalledWith('Tracking diagnostic', {
|
|
472
|
+
expect(console.error).toHaveBeenCalledWith('Tracking recovery diagnostic', {
|
|
348
473
|
eventName: 'Payment Attempt',
|
|
349
474
|
stage: 'readiness',
|
|
350
475
|
reason: 'timeout',
|