@reservamos/browser-analytics 1.1.7 → 1.1.8-alpha.1
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 +13 -0
- package/README.md +16 -10
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +500 -81
- 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/sharedSchemas/tripSchema.ts +3 -8
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +288 -0
- package/src/init.ts +47 -12
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +36 -0
- package/src/services/fingerprint.ts +38 -20
- package/src/services/mixpanel.test.ts +103 -0
- package/src/services/mixpanel.ts +70 -21
- 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 +127 -0
- package/src/track.test.ts +435 -66
- package/src/track.ts +174 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
package/src/track.test.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
1
|
+
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
|
|
5
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
6
|
+
import validator from '@/services/validator';
|
|
7
|
+
import { trackEvent, trackEventError, tryTrackEvent } from './track';
|
|
6
8
|
|
|
7
9
|
vi.mock('@/services/botDetectionRelay', () => ({
|
|
8
10
|
default: {
|
|
@@ -14,8 +16,10 @@ vi.mock('@/services/botDetectionRelay', () => ({
|
|
|
14
16
|
vi.mock('@/services/fingerprint', () => ({
|
|
15
17
|
default: {
|
|
16
18
|
getFingerprint: vi.fn(),
|
|
19
|
+
getCachedFingerprint: vi.fn(),
|
|
17
20
|
identifyVisitor: vi.fn(),
|
|
18
21
|
isFingerprintReady: vi.fn(),
|
|
22
|
+
onReady: vi.fn().mockReturnValue(() => undefined),
|
|
19
23
|
},
|
|
20
24
|
}));
|
|
21
25
|
|
|
@@ -28,6 +32,7 @@ vi.mock('@/services/geolocation', () => ({
|
|
|
28
32
|
|
|
29
33
|
vi.mock('@/services/mixpanel', () => ({
|
|
30
34
|
default: {
|
|
35
|
+
onReady: vi.fn().mockReturnValue(() => undefined),
|
|
31
36
|
isReady: vi.fn().mockReturnValue(true),
|
|
32
37
|
getMixpanelDistinctId: vi.fn().mockReturnValue('distinct-123'),
|
|
33
38
|
track: vi.fn(),
|
|
@@ -40,117 +45,481 @@ vi.mock('@/services/validator', () => ({
|
|
|
40
45
|
},
|
|
41
46
|
}));
|
|
42
47
|
|
|
48
|
+
let testIdentity = 0;
|
|
43
49
|
describe('trackEvent Bot Detection relay', () => {
|
|
44
50
|
beforeEach(() => {
|
|
45
|
-
|
|
51
|
+
trackingLifecycle.identify(`test-${testIdentity++}`);
|
|
52
|
+
vi.resetAllMocks();
|
|
53
|
+
vi.mocked(fingerprintService.onReady).mockReturnValue(() => undefined);
|
|
46
54
|
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
47
55
|
vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
|
|
48
56
|
'distinct-123',
|
|
49
57
|
);
|
|
50
|
-
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('uses a fresh identification and relays a configured event', async () => {
|
|
54
58
|
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
|
|
59
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
|
|
60
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(
|
|
61
|
+
'cached-visitor',
|
|
62
|
+
);
|
|
55
63
|
vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue({
|
|
56
|
-
visitorId: 'visitor
|
|
57
|
-
requestId: 'request
|
|
58
|
-
visitorFound: true,
|
|
64
|
+
visitorId: 'fresh-visitor',
|
|
65
|
+
requestId: 'fresh-request',
|
|
59
66
|
confidenceScore: 0.99,
|
|
60
67
|
cacheHit: false,
|
|
61
68
|
});
|
|
69
|
+
vi.mocked(botDetectionRelayService.relay).mockResolvedValue(undefined);
|
|
70
|
+
});
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
describe.each(['Search', 'Payment Attempt', 'Purchase Attempt'])(
|
|
73
|
+
'%s',
|
|
74
|
+
(eventName) => {
|
|
75
|
+
it.each(['cached-visitor', null])(
|
|
76
|
+
'submits with cached identity %s before starting identification',
|
|
77
|
+
async (cachedVisitor) => {
|
|
78
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(
|
|
79
|
+
cachedVisitor,
|
|
80
|
+
);
|
|
81
|
+
vi.mocked(fingerprintService.identifyVisitor).mockImplementation(
|
|
82
|
+
() => {
|
|
83
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
84
|
+
'User Fingerprint': cachedVisitor ?? '',
|
|
85
|
+
'Payment Type': 'credit_card',
|
|
86
|
+
'Checkout Source': 'web',
|
|
87
|
+
});
|
|
88
|
+
return Promise.resolve({
|
|
89
|
+
visitorId: 'fresh-visitor',
|
|
90
|
+
requestId: 'fresh-request',
|
|
91
|
+
confidenceScore: 0.99,
|
|
92
|
+
cacheHit: false,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
);
|
|
64
96
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
const completion = trackEvent(
|
|
98
|
+
eventName,
|
|
99
|
+
{ 'Payment Type': 'credit_card' },
|
|
100
|
+
{ 'Checkout Source': 'web' },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
104
|
+
await completion;
|
|
105
|
+
expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
|
|
106
|
+
expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(
|
|
107
|
+
true,
|
|
108
|
+
expect.objectContaining({ isActive: expect.any(Function) }),
|
|
109
|
+
);
|
|
110
|
+
await vi.waitFor(() =>
|
|
111
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
|
|
112
|
+
);
|
|
113
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
114
|
+
requestId: 'fresh-request',
|
|
115
|
+
visitorId: 'fresh-visitor',
|
|
116
|
+
distinctId: 'distinct-123',
|
|
117
|
+
eventName,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it('completes tracking while fresh identification is still pending', async () => {
|
|
123
|
+
let resolveIdentification!: (
|
|
124
|
+
value: Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>,
|
|
125
|
+
) => void;
|
|
126
|
+
vi.mocked(fingerprintService.identifyVisitor).mockReturnValue(
|
|
127
|
+
new Promise((resolve) => {
|
|
128
|
+
resolveIdentification = resolve;
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
const onComplete = vi.fn();
|
|
132
|
+
const completion = trackEvent(eventName, {}).then(onComplete);
|
|
133
|
+
// A pending remote operation must not delay submission or completion.
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
137
|
+
'User Fingerprint': 'cached-visitor',
|
|
138
|
+
});
|
|
139
|
+
expect(onComplete).toHaveBeenCalledOnce();
|
|
140
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
141
|
+
resolveIdentification({
|
|
142
|
+
visitorId: 'late-visitor',
|
|
143
|
+
requestId: 'late-request',
|
|
144
|
+
confidenceScore: 0.99,
|
|
145
|
+
cacheHit: false,
|
|
146
|
+
});
|
|
147
|
+
await completion;
|
|
148
|
+
await vi.waitFor(() =>
|
|
149
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
|
|
150
|
+
);
|
|
151
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
152
|
+
requestId: 'late-request',
|
|
153
|
+
visitorId: 'late-visitor',
|
|
154
|
+
distinctId: 'distinct-123',
|
|
155
|
+
eventName,
|
|
156
|
+
});
|
|
157
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('preserves the original event when identification rejects', async () => {
|
|
161
|
+
vi.mocked(fingerprintService.identifyVisitor).mockRejectedValue(
|
|
162
|
+
new Error('Identification failed'),
|
|
163
|
+
);
|
|
164
|
+
await trackEvent(eventName, {});
|
|
165
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
167
|
+
'User Fingerprint': 'cached-visitor',
|
|
168
|
+
});
|
|
169
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('submits without identity if reading the cache throws', async () => {
|
|
173
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockImplementation(
|
|
174
|
+
() => {
|
|
175
|
+
throw new Error('Cache unavailable');
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
await trackEvent(eventName, {});
|
|
179
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
181
|
+
'User Fingerprint': '',
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('submits without starting identification when the agent is unavailable', async () => {
|
|
186
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
|
|
187
|
+
await trackEvent(eventName, {});
|
|
188
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
189
|
+
'User Fingerprint': 'cached-visitor',
|
|
190
|
+
});
|
|
191
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
192
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
it('relays the original distinct ID even if identity changes while identification is pending', async () => {
|
|
198
|
+
let resolveIdentification!: (
|
|
199
|
+
value: Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>,
|
|
200
|
+
) => void;
|
|
201
|
+
vi.mocked(fingerprintService.identifyVisitor).mockReturnValue(
|
|
202
|
+
new Promise((resolve) => {
|
|
203
|
+
resolveIdentification = resolve;
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
await trackEvent('Payment Attempt', {});
|
|
207
|
+
vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
|
|
208
|
+
'new-distinct-id',
|
|
209
|
+
);
|
|
210
|
+
resolveIdentification({
|
|
211
|
+
visitorId: 'fresh-visitor',
|
|
212
|
+
requestId: 'fresh-request',
|
|
213
|
+
confidenceScore: 0.99,
|
|
214
|
+
cacheHit: false,
|
|
70
215
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
216
|
+
await vi.waitFor(() =>
|
|
217
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
218
|
+
requestId: 'fresh-request',
|
|
219
|
+
visitorId: 'fresh-visitor',
|
|
220
|
+
distinctId: 'distinct-123',
|
|
221
|
+
eventName: 'Payment Attempt',
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('contains an unexpected background relay rejection', async () => {
|
|
227
|
+
vi.mocked(botDetectionRelayService.relay).mockRejectedValue(
|
|
228
|
+
new Error('Relay failed'),
|
|
229
|
+
);
|
|
230
|
+
await trackEvent('Payment Attempt', {});
|
|
231
|
+
await Promise.resolve();
|
|
232
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
|
|
234
|
+
'User Fingerprint': 'cached-visitor',
|
|
76
235
|
});
|
|
77
236
|
});
|
|
78
237
|
|
|
79
|
-
it('
|
|
80
|
-
vi.mocked(
|
|
81
|
-
|
|
82
|
-
|
|
238
|
+
it('retains tracking error behavior for invalid event properties', async () => {
|
|
239
|
+
vi.mocked(validator.parseEventProps).mockImplementationOnce(() => {
|
|
240
|
+
throw new Error('Invalid event properties');
|
|
241
|
+
});
|
|
242
|
+
await trackEvent('Payment Attempt', {});
|
|
243
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
244
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
246
|
+
'Track Event Error',
|
|
247
|
+
expect.objectContaining({ 'Failed Event Name': 'Payment Attempt' }),
|
|
83
248
|
);
|
|
84
|
-
|
|
249
|
+
});
|
|
85
250
|
|
|
86
|
-
|
|
251
|
+
it('does not start bot detection when submitting the original event throws', async () => {
|
|
252
|
+
vi.mocked(mixpanelService.track).mockImplementationOnce(() => {
|
|
253
|
+
throw new Error('Tracking failed');
|
|
254
|
+
});
|
|
255
|
+
await trackEvent('Payment Attempt', {});
|
|
256
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
257
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
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
|
+
});
|
|
87
281
|
|
|
88
|
-
|
|
89
|
-
|
|
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();
|
|
90
304
|
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('submits View Results with an empty fingerprint if identification rejects', async () => {
|
|
308
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
309
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(null);
|
|
310
|
+
vi.mocked(fingerprintService.getFingerprint).mockRejectedValue(
|
|
311
|
+
new Error('Fingerprint failed'),
|
|
312
|
+
);
|
|
313
|
+
await trackEvent('View Results', { 'Results Count': 2 }, { source: 'web' });
|
|
314
|
+
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(
|
|
315
|
+
false,
|
|
316
|
+
expect.objectContaining({ isActive: expect.any(Function) }),
|
|
317
|
+
);
|
|
91
318
|
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
92
|
-
expect(mixpanelService.track).toHaveBeenCalledWith('
|
|
93
|
-
'
|
|
94
|
-
'
|
|
95
|
-
'
|
|
96
|
-
'Event Payload': eventProperties,
|
|
319
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
|
|
320
|
+
'User Fingerprint': '',
|
|
321
|
+
'Results Count': 2,
|
|
322
|
+
'source': 'web',
|
|
97
323
|
});
|
|
98
324
|
});
|
|
99
325
|
|
|
100
|
-
it('preserves existing fingerprint behavior for
|
|
326
|
+
it('preserves existing fingerprint behavior for events outside bot detection', async () => {
|
|
101
327
|
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
102
328
|
vi.mocked(fingerprintService.getFingerprint).mockResolvedValue(
|
|
103
329
|
'cached-visitor',
|
|
104
330
|
);
|
|
105
|
-
|
|
106
|
-
await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
|
|
107
|
-
|
|
331
|
+
await trackEvent('Seat Change', {});
|
|
108
332
|
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(true);
|
|
333
|
+
expect(fingerprintService.getCachedFingerprint).not.toHaveBeenCalled();
|
|
109
334
|
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
110
|
-
expect(mixpanelService.track).toHaveBeenCalledWith('
|
|
335
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Seat Change', {
|
|
111
336
|
'User Fingerprint': 'cached-visitor',
|
|
112
|
-
'Payment Type': 'credit_card',
|
|
113
337
|
});
|
|
114
338
|
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
115
339
|
});
|
|
116
340
|
|
|
117
|
-
it('
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
visitorId: 'visitor-123',
|
|
121
|
-
requestId: 'search-request-123',
|
|
122
|
-
visitorFound: true,
|
|
341
|
+
it('keeps the original event exactly once when identification omits requestId', async () => {
|
|
342
|
+
const identification = {
|
|
343
|
+
visitorId: 'fresh-visitor',
|
|
123
344
|
confidenceScore: 0.99,
|
|
124
345
|
cacheHit: false,
|
|
125
|
-
}
|
|
126
|
-
|
|
346
|
+
} as Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>;
|
|
347
|
+
vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue(
|
|
348
|
+
identification,
|
|
349
|
+
);
|
|
127
350
|
await trackEvent('Search', {});
|
|
128
|
-
|
|
129
|
-
expect(
|
|
130
|
-
expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
|
|
351
|
+
await Promise.resolve();
|
|
352
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
131
353
|
expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
|
|
132
|
-
'User Fingerprint': 'visitor
|
|
354
|
+
'User Fingerprint': 'cached-visitor',
|
|
133
355
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
});
|
|
139
371
|
});
|
|
372
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
140
373
|
});
|
|
141
374
|
|
|
142
|
-
it('
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('bounded tracking readiness', () => {
|
|
407
|
+
beforeEach(() => {
|
|
408
|
+
vi.useFakeTimers();
|
|
409
|
+
vi.resetAllMocks();
|
|
410
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(false);
|
|
411
|
+
vi.mocked(mixpanelService.onReady).mockReturnValue(() => undefined);
|
|
412
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
145
413
|
vi.mocked(fingerprintService.getFingerprint).mockResolvedValue('');
|
|
414
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
|
|
415
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
416
|
+
});
|
|
417
|
+
afterEach(() => {
|
|
418
|
+
vi.clearAllTimers();
|
|
419
|
+
vi.useRealTimers();
|
|
420
|
+
vi.restoreAllMocks();
|
|
421
|
+
});
|
|
146
422
|
|
|
147
|
-
|
|
423
|
+
it('submits at the deadline if Mixpanel loaded without a Tracker Ready signal', async () => {
|
|
424
|
+
const pending = trackEvent('Payment Attempt', { source: 'checkout' });
|
|
425
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
426
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
427
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
428
|
+
await pending;
|
|
429
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
430
|
+
'Payment Attempt',
|
|
431
|
+
expect.objectContaining({ source: 'checkout' }),
|
|
432
|
+
);
|
|
433
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
434
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
435
|
+
});
|
|
148
436
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
437
|
+
it('holds multiple events until readiness and submits each once', async () => {
|
|
438
|
+
const remove = vi.spyOn(window, 'removeEventListener');
|
|
439
|
+
const first = trackEvent('Payment Attempt', { source: 'first' });
|
|
440
|
+
const second = trackEvent('Purchase Attempt', { source: 'second' });
|
|
441
|
+
void first.catch(() => undefined);
|
|
442
|
+
void second.catch(() => undefined);
|
|
443
|
+
expect(mixpanelService.track).not.toHaveBeenCalled();
|
|
444
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
445
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
446
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
447
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
448
|
+
undefined,
|
|
449
|
+
undefined,
|
|
450
|
+
]);
|
|
451
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(2);
|
|
452
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
453
|
+
'Payment Attempt',
|
|
454
|
+
expect.objectContaining({ source: 'first' }),
|
|
455
|
+
);
|
|
456
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
457
|
+
'Purchase Attempt',
|
|
458
|
+
expect.objectContaining({ source: 'second' }),
|
|
459
|
+
);
|
|
460
|
+
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
461
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('expires after the retention deadline and ignores later readiness', async () => {
|
|
465
|
+
const remove = vi.spyOn(window, 'removeEventListener');
|
|
466
|
+
const pending = trackEvent('Payment Attempt', {
|
|
467
|
+
secret: 'private-payload',
|
|
153
468
|
});
|
|
154
|
-
|
|
469
|
+
void pending.catch(() => undefined);
|
|
470
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
471
|
+
await expect(pending).resolves.toBeUndefined();
|
|
472
|
+
expect(console.error).toHaveBeenCalledWith('Tracking recovery diagnostic', {
|
|
473
|
+
eventName: 'Payment Attempt',
|
|
474
|
+
stage: 'readiness',
|
|
475
|
+
reason: 'timeout',
|
|
476
|
+
});
|
|
477
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
478
|
+
'private-payload',
|
|
479
|
+
);
|
|
480
|
+
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
481
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
482
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
483
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
484
|
+
await Promise.resolve();
|
|
485
|
+
expect(mixpanelService.track).not.toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('does not execute when a readiness signal arrives before Mixpanel is ready', async () => {
|
|
489
|
+
const callback = vi.fn().mockResolvedValue(undefined);
|
|
490
|
+
const pending = tryTrackEvent(callback);
|
|
491
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
492
|
+
await Promise.resolve();
|
|
493
|
+
expect(callback).not.toHaveBeenCalled();
|
|
494
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
495
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
496
|
+
await pending;
|
|
497
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
498
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('consumes callback rejection after readiness', async () => {
|
|
502
|
+
const callback = vi
|
|
503
|
+
.fn()
|
|
504
|
+
.mockRejectedValue(new Error('private-callback-error'));
|
|
505
|
+
const pending = tryTrackEvent(callback);
|
|
506
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
507
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
508
|
+
await expect(pending).resolves.toBeUndefined();
|
|
509
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
510
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
511
|
+
'private-callback-error',
|
|
512
|
+
);
|
|
513
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('expires a queued tracking diagnostic without creating another listener', async () => {
|
|
517
|
+
const add = vi.spyOn(window, 'addEventListener');
|
|
518
|
+
trackEventError('Payment Attempt', new Error('failed'), {});
|
|
519
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
520
|
+
expect(
|
|
521
|
+
add.mock.calls.filter(([name]) => name === 'Tracker Ready'),
|
|
522
|
+
).toHaveLength(1);
|
|
523
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
155
524
|
});
|
|
156
525
|
});
|