@reservamos/browser-analytics 1.1.7-alpha.1 → 1.1.8-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 +9 -0
- package/README.md +12 -10
- package/dist/browser-analytics.cjs +3 -3
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +911 -817
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +3 -3
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +4 -4
- package/src/init.test.ts +287 -0
- package/src/init.ts +34 -12
- package/src/services/botDetectionRelay.test.ts +58 -31
- package/src/services/botDetectionRelay.ts +13 -23
- package/src/services/fingerprint.test.ts +18 -0
- package/src/services/fingerprint.ts +2 -11
- package/src/services/mixpanel.test.ts +61 -0
- package/src/services/mixpanel.ts +16 -16
- package/src/track.test.ts +315 -71
- package/src/track.ts +88 -24
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
it,
|
|
10
10
|
vi,
|
|
11
11
|
} from 'vitest';
|
|
12
|
+
import { trackEvent } from '@/track';
|
|
12
13
|
import { version } from '../../package.json';
|
|
13
14
|
import mixpanelService from './mixpanel';
|
|
14
15
|
|
|
@@ -100,6 +101,66 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
100
101
|
expect(people).toHaveBeenCalledWith({});
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
it.each(['getItem', 'setItem'] as const)(
|
|
105
|
+
'keeps the live distinct ID when SDK storage %s fails',
|
|
106
|
+
(method) => {
|
|
107
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
|
|
108
|
+
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
109
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
110
|
+
});
|
|
111
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it.each(['getItem', 'setItem'] as const)(
|
|
116
|
+
'submits the original event when SDK storage %s is blocked',
|
|
117
|
+
async (method) => {
|
|
118
|
+
const send = vi
|
|
119
|
+
.spyOn(transport, '_track_or_batch')
|
|
120
|
+
.mockImplementation(({ data }) => data);
|
|
121
|
+
mixpanel.identify('storage-test-id');
|
|
122
|
+
send.mockClear();
|
|
123
|
+
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
124
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
125
|
+
});
|
|
126
|
+
await trackEvent('Storage Failure Test', { source: 'checkout' });
|
|
127
|
+
expect(send.mock.calls.map(([options]) => options.data)).toContainEqual({
|
|
128
|
+
event: 'Storage Failure Test',
|
|
129
|
+
properties: expect.objectContaining({
|
|
130
|
+
source: 'checkout',
|
|
131
|
+
distinct_id: 'storage-test-id',
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
it('returns null when no live distinct ID is available and the cache is blocked', () => {
|
|
138
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
139
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
140
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
141
|
+
});
|
|
142
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('uses the cached distinct ID when Mixpanel has no live ID', () => {
|
|
146
|
+
localStorage.setItem('mp_distinct_id', 'cached-id');
|
|
147
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
148
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('cached-id');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('preserves identify cache diagnostics and people updates on a write failure', () => {
|
|
152
|
+
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
153
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
154
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
155
|
+
});
|
|
156
|
+
vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
|
|
157
|
+
vi.useFakeTimers();
|
|
158
|
+
const report = vi.fn();
|
|
159
|
+
mixpanelService.identify('user-id', {}, report);
|
|
160
|
+
expect(report).toHaveBeenCalledWith('cache', 'unavailable');
|
|
161
|
+
expect(mixpanel.people.set).toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
103
164
|
it.each([
|
|
104
165
|
1,
|
|
105
166
|
0,
|
package/src/services/mixpanel.ts
CHANGED
|
@@ -57,11 +57,7 @@ function identify(
|
|
|
57
57
|
report?: IdentifyReporter,
|
|
58
58
|
): void {
|
|
59
59
|
mixpanel.identify(userId);
|
|
60
|
-
|
|
61
|
-
updateCachedDistinctId(userId);
|
|
62
|
-
} catch {
|
|
63
|
-
report?.('cache', 'unavailable');
|
|
64
|
-
}
|
|
60
|
+
if (!updateCachedDistinctId(userId)) report?.('cache', 'unavailable');
|
|
65
61
|
if (!report) {
|
|
66
62
|
mixpanel.people.set(properties);
|
|
67
63
|
return;
|
|
@@ -136,26 +132,30 @@ function track(eventName: string, properties: Record<string, unknown>): void {
|
|
|
136
132
|
* @returns {string | null} The distinct ID if available, or null if Mixpanel is not initialized.
|
|
137
133
|
*/
|
|
138
134
|
export const getMixpanelDistinctId = (): string | null => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
if (mixpanel && mixpanel.get_distinct_id) {
|
|
142
|
-
const distinctId = mixpanel.get_distinct_id();
|
|
143
|
-
|
|
135
|
+
const distinctId = mixpanel?.get_distinct_id?.();
|
|
136
|
+
if (distinctId) {
|
|
144
137
|
updateCachedDistinctId(distinctId);
|
|
145
|
-
|
|
146
138
|
return distinctId;
|
|
147
|
-
} else if (cachedId) {
|
|
148
|
-
return cachedId;
|
|
149
139
|
}
|
|
150
|
-
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
return localStorage.getItem(MIXPANEL_DISTINCT_ID_CACHE_KEY);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
151
146
|
};
|
|
152
147
|
/**
|
|
153
148
|
* Updates the cached Mixpanel distinct ID.
|
|
154
149
|
* Should be called when identifying a user to ensure cache stays in sync.
|
|
155
150
|
* @param {string} distinctId - The distinct ID to cache
|
|
156
151
|
*/
|
|
157
|
-
function updateCachedDistinctId(distinctId: string):
|
|
158
|
-
|
|
152
|
+
function updateCachedDistinctId(distinctId: string): boolean {
|
|
153
|
+
try {
|
|
154
|
+
localStorage.setItem(MIXPANEL_DISTINCT_ID_CACHE_KEY, distinctId);
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
const mixpanelService = {
|
package/src/track.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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 validator from '@/services/validator';
|
|
6
|
+
import { trackEvent, trackEventError, tryTrackEvent } from './track';
|
|
6
7
|
|
|
7
8
|
vi.mock('@/services/botDetectionRelay', () => ({
|
|
8
9
|
default: {
|
|
@@ -14,6 +15,7 @@ vi.mock('@/services/botDetectionRelay', () => ({
|
|
|
14
15
|
vi.mock('@/services/fingerprint', () => ({
|
|
15
16
|
default: {
|
|
16
17
|
getFingerprint: vi.fn(),
|
|
18
|
+
getCachedFingerprint: vi.fn(),
|
|
17
19
|
identifyVisitor: vi.fn(),
|
|
18
20
|
isFingerprintReady: vi.fn(),
|
|
19
21
|
},
|
|
@@ -42,115 +44,357 @@ vi.mock('@/services/validator', () => ({
|
|
|
42
44
|
|
|
43
45
|
describe('trackEvent Bot Detection relay', () => {
|
|
44
46
|
beforeEach(() => {
|
|
45
|
-
vi.
|
|
47
|
+
vi.resetAllMocks();
|
|
46
48
|
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
47
49
|
vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
|
|
48
50
|
'distinct-123',
|
|
49
51
|
);
|
|
50
|
-
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('uses a fresh identification and relays a configured event', async () => {
|
|
54
52
|
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
|
|
53
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
|
|
54
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(
|
|
55
|
+
'cached-visitor',
|
|
56
|
+
);
|
|
55
57
|
vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue({
|
|
56
|
-
visitorId: 'visitor
|
|
57
|
-
requestId: 'request
|
|
58
|
-
visitorFound: true,
|
|
58
|
+
visitorId: 'fresh-visitor',
|
|
59
|
+
requestId: 'fresh-request',
|
|
59
60
|
confidenceScore: 0.99,
|
|
60
61
|
cacheHit: false,
|
|
61
62
|
});
|
|
63
|
+
vi.mocked(botDetectionRelayService.relay).mockResolvedValue(undefined);
|
|
64
|
+
});
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
describe.each(['Search', 'Payment Attempt', 'Purchase Attempt'])(
|
|
67
|
+
'%s',
|
|
68
|
+
(eventName) => {
|
|
69
|
+
it.each(['cached-visitor', null])(
|
|
70
|
+
'submits with cached identity %s before starting identification',
|
|
71
|
+
async (cachedVisitor) => {
|
|
72
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(
|
|
73
|
+
cachedVisitor,
|
|
74
|
+
);
|
|
75
|
+
vi.mocked(fingerprintService.identifyVisitor).mockImplementation(
|
|
76
|
+
() => {
|
|
77
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
78
|
+
'User Fingerprint': cachedVisitor ?? '',
|
|
79
|
+
'Payment Type': 'credit_card',
|
|
80
|
+
'Checkout Source': 'web',
|
|
81
|
+
});
|
|
82
|
+
return Promise.resolve({
|
|
83
|
+
visitorId: 'fresh-visitor',
|
|
84
|
+
requestId: 'fresh-request',
|
|
85
|
+
confidenceScore: 0.99,
|
|
86
|
+
cacheHit: false,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
);
|
|
64
90
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
const completion = trackEvent(
|
|
92
|
+
eventName,
|
|
93
|
+
{ 'Payment Type': 'credit_card' },
|
|
94
|
+
{ 'Checkout Source': 'web' },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
98
|
+
await completion;
|
|
99
|
+
expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
|
|
100
|
+
expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
|
|
101
|
+
await vi.waitFor(() =>
|
|
102
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
|
|
103
|
+
);
|
|
104
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
105
|
+
requestId: 'fresh-request',
|
|
106
|
+
visitorId: 'fresh-visitor',
|
|
107
|
+
distinctId: 'distinct-123',
|
|
108
|
+
eventName,
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
it('completes tracking while fresh identification is still pending', async () => {
|
|
114
|
+
let resolveIdentification!: (
|
|
115
|
+
value: Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>,
|
|
116
|
+
) => void;
|
|
117
|
+
vi.mocked(fingerprintService.identifyVisitor).mockReturnValue(
|
|
118
|
+
new Promise((resolve) => {
|
|
119
|
+
resolveIdentification = resolve;
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const onComplete = vi.fn();
|
|
123
|
+
const completion = trackEvent(eventName, {}).then(onComplete);
|
|
124
|
+
// A pending remote operation must not delay submission or completion.
|
|
125
|
+
await Promise.resolve();
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
128
|
+
'User Fingerprint': 'cached-visitor',
|
|
129
|
+
});
|
|
130
|
+
expect(onComplete).toHaveBeenCalledOnce();
|
|
131
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
132
|
+
resolveIdentification({
|
|
133
|
+
visitorId: 'late-visitor',
|
|
134
|
+
requestId: 'late-request',
|
|
135
|
+
confidenceScore: 0.99,
|
|
136
|
+
cacheHit: false,
|
|
137
|
+
});
|
|
138
|
+
await completion;
|
|
139
|
+
await vi.waitFor(() =>
|
|
140
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
|
|
141
|
+
);
|
|
142
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
143
|
+
requestId: 'late-request',
|
|
144
|
+
visitorId: 'late-visitor',
|
|
145
|
+
distinctId: 'distinct-123',
|
|
146
|
+
eventName,
|
|
147
|
+
});
|
|
148
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('preserves the original event when identification rejects', async () => {
|
|
152
|
+
vi.mocked(fingerprintService.identifyVisitor).mockRejectedValue(
|
|
153
|
+
new Error('Identification failed'),
|
|
154
|
+
);
|
|
155
|
+
await trackEvent(eventName, {});
|
|
156
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
158
|
+
'User Fingerprint': 'cached-visitor',
|
|
159
|
+
});
|
|
160
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('submits without identity if reading the cache throws', async () => {
|
|
164
|
+
vi.mocked(fingerprintService.getCachedFingerprint).mockImplementation(
|
|
165
|
+
() => {
|
|
166
|
+
throw new Error('Cache unavailable');
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
await trackEvent(eventName, {});
|
|
170
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
172
|
+
'User Fingerprint': '',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('submits without starting identification when the agent is unavailable', async () => {
|
|
177
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
|
|
178
|
+
await trackEvent(eventName, {});
|
|
179
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
|
|
180
|
+
'User Fingerprint': 'cached-visitor',
|
|
181
|
+
});
|
|
182
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
183
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
it('relays the original distinct ID even if identity changes while identification is pending', async () => {
|
|
189
|
+
let resolveIdentification!: (
|
|
190
|
+
value: Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>,
|
|
191
|
+
) => void;
|
|
192
|
+
vi.mocked(fingerprintService.identifyVisitor).mockReturnValue(
|
|
193
|
+
new Promise((resolve) => {
|
|
194
|
+
resolveIdentification = resolve;
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
await trackEvent('Payment Attempt', {});
|
|
198
|
+
vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
|
|
199
|
+
'new-distinct-id',
|
|
200
|
+
);
|
|
201
|
+
resolveIdentification({
|
|
202
|
+
visitorId: 'fresh-visitor',
|
|
203
|
+
requestId: 'fresh-request',
|
|
204
|
+
confidenceScore: 0.99,
|
|
205
|
+
cacheHit: false,
|
|
76
206
|
});
|
|
207
|
+
await vi.waitFor(() =>
|
|
208
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
209
|
+
requestId: 'fresh-request',
|
|
210
|
+
visitorId: 'fresh-visitor',
|
|
211
|
+
distinctId: 'distinct-123',
|
|
212
|
+
eventName: 'Payment Attempt',
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
77
215
|
});
|
|
78
216
|
|
|
79
|
-
it('
|
|
80
|
-
vi.mocked(botDetectionRelayService.
|
|
81
|
-
|
|
82
|
-
new Error('Fingerprint identification failed'),
|
|
217
|
+
it('contains an unexpected background relay rejection', async () => {
|
|
218
|
+
vi.mocked(botDetectionRelayService.relay).mockRejectedValue(
|
|
219
|
+
new Error('Relay failed'),
|
|
83
220
|
);
|
|
84
|
-
|
|
221
|
+
await trackEvent('Payment Attempt', {});
|
|
222
|
+
await Promise.resolve();
|
|
223
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
|
|
225
|
+
'User Fingerprint': 'cached-visitor',
|
|
226
|
+
});
|
|
227
|
+
});
|
|
85
228
|
|
|
86
|
-
|
|
229
|
+
it('retains tracking error behavior for invalid event properties', async () => {
|
|
230
|
+
vi.mocked(validator.parseEventProps).mockImplementationOnce(() => {
|
|
231
|
+
throw new Error('Invalid event properties');
|
|
232
|
+
});
|
|
233
|
+
await trackEvent('Payment Attempt', {});
|
|
234
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
235
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
236
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
237
|
+
'Track Event Error',
|
|
238
|
+
expect.objectContaining({ 'Failed Event Name': 'Payment Attempt' }),
|
|
239
|
+
);
|
|
240
|
+
});
|
|
87
241
|
|
|
88
|
-
|
|
89
|
-
|
|
242
|
+
it('does not start bot detection when submitting the original event throws', async () => {
|
|
243
|
+
vi.mocked(mixpanelService.track).mockImplementationOnce(() => {
|
|
244
|
+
throw new Error('Tracking failed');
|
|
245
|
+
});
|
|
246
|
+
await trackEvent('Payment Attempt', {});
|
|
247
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
90
248
|
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('submits View Results with an empty fingerprint if identification rejects', async () => {
|
|
252
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
253
|
+
vi.mocked(fingerprintService.getFingerprint).mockRejectedValue(
|
|
254
|
+
new Error('Fingerprint failed'),
|
|
255
|
+
);
|
|
256
|
+
await trackEvent('View Results', { 'Results Count': 2 }, { source: 'web' });
|
|
257
|
+
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(false);
|
|
91
258
|
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
92
|
-
expect(mixpanelService.track).toHaveBeenCalledWith('
|
|
93
|
-
'
|
|
94
|
-
'
|
|
95
|
-
'
|
|
96
|
-
'Event Payload': eventProperties,
|
|
259
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
|
|
260
|
+
'User Fingerprint': '',
|
|
261
|
+
'Results Count': 2,
|
|
262
|
+
'source': 'web',
|
|
97
263
|
});
|
|
98
264
|
});
|
|
99
265
|
|
|
100
|
-
it('preserves existing fingerprint behavior for
|
|
266
|
+
it('preserves existing fingerprint behavior for events outside bot detection', async () => {
|
|
101
267
|
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
102
268
|
vi.mocked(fingerprintService.getFingerprint).mockResolvedValue(
|
|
103
269
|
'cached-visitor',
|
|
104
270
|
);
|
|
105
|
-
|
|
106
|
-
await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
|
|
107
|
-
|
|
271
|
+
await trackEvent('Seat Change', {});
|
|
108
272
|
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(true);
|
|
273
|
+
expect(fingerprintService.getCachedFingerprint).not.toHaveBeenCalled();
|
|
109
274
|
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
110
|
-
expect(mixpanelService.track).toHaveBeenCalledWith('
|
|
275
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Seat Change', {
|
|
111
276
|
'User Fingerprint': 'cached-visitor',
|
|
112
|
-
'Payment Type': 'credit_card',
|
|
113
277
|
});
|
|
114
278
|
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
115
279
|
});
|
|
280
|
+
});
|
|
116
281
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
vi.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
282
|
+
describe('bounded tracking readiness', () => {
|
|
283
|
+
beforeEach(() => {
|
|
284
|
+
vi.useFakeTimers();
|
|
285
|
+
vi.resetAllMocks();
|
|
286
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(false);
|
|
287
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
288
|
+
vi.mocked(fingerprintService.getFingerprint).mockResolvedValue('');
|
|
289
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
|
|
290
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
291
|
+
});
|
|
292
|
+
afterEach(() => {
|
|
293
|
+
vi.clearAllTimers();
|
|
294
|
+
vi.useRealTimers();
|
|
295
|
+
vi.restoreAllMocks();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('submits at the deadline if Mixpanel loaded without a Tracker Ready signal', async () => {
|
|
299
|
+
const pending = trackEvent('Payment Attempt', { source: 'checkout' });
|
|
300
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
301
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
302
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
303
|
+
await pending;
|
|
304
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
305
|
+
'Payment Attempt',
|
|
306
|
+
expect.objectContaining({ source: 'checkout' }),
|
|
307
|
+
);
|
|
308
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
309
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
310
|
+
});
|
|
126
311
|
|
|
127
|
-
|
|
312
|
+
it('holds multiple events until readiness and submits each once', async () => {
|
|
313
|
+
const remove = vi.spyOn(window, 'removeEventListener');
|
|
314
|
+
const first = trackEvent('Payment Attempt', { source: 'first' });
|
|
315
|
+
const second = trackEvent('Purchase Attempt', { source: 'second' });
|
|
316
|
+
void first.catch(() => undefined);
|
|
317
|
+
void second.catch(() => undefined);
|
|
318
|
+
expect(mixpanelService.track).not.toHaveBeenCalled();
|
|
319
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
320
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
321
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
322
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
323
|
+
undefined,
|
|
324
|
+
undefined,
|
|
325
|
+
]);
|
|
326
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(2);
|
|
327
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
328
|
+
'Payment Attempt',
|
|
329
|
+
expect.objectContaining({ source: 'first' }),
|
|
330
|
+
);
|
|
331
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
332
|
+
'Purchase Attempt',
|
|
333
|
+
expect.objectContaining({ source: 'second' }),
|
|
334
|
+
);
|
|
335
|
+
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
336
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
337
|
+
});
|
|
128
338
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
339
|
+
it('expires cleanly, reports a controlled diagnostic, and ignores late readiness', async () => {
|
|
340
|
+
const remove = vi.spyOn(window, 'removeEventListener');
|
|
341
|
+
const pending = trackEvent('Payment Attempt', {
|
|
342
|
+
secret: 'private-payload',
|
|
133
343
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
eventName: '
|
|
344
|
+
void pending.catch(() => undefined);
|
|
345
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
346
|
+
await expect(pending).resolves.toBeUndefined();
|
|
347
|
+
expect(console.error).toHaveBeenCalledWith('Tracking diagnostic', {
|
|
348
|
+
eventName: 'Payment Attempt',
|
|
349
|
+
stage: 'readiness',
|
|
350
|
+
reason: 'timeout',
|
|
139
351
|
});
|
|
352
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
353
|
+
'private-payload',
|
|
354
|
+
);
|
|
355
|
+
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
356
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
357
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
358
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
359
|
+
await Promise.resolve();
|
|
360
|
+
expect(mixpanelService.track).not.toHaveBeenCalled();
|
|
140
361
|
});
|
|
141
362
|
|
|
142
|
-
it('
|
|
143
|
-
vi.
|
|
144
|
-
|
|
145
|
-
|
|
363
|
+
it('does not execute when a readiness signal arrives before Mixpanel is ready', async () => {
|
|
364
|
+
const callback = vi.fn().mockResolvedValue(undefined);
|
|
365
|
+
const pending = tryTrackEvent(callback);
|
|
366
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
367
|
+
await Promise.resolve();
|
|
368
|
+
expect(callback).not.toHaveBeenCalled();
|
|
369
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
370
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
371
|
+
await pending;
|
|
372
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
373
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
374
|
+
});
|
|
146
375
|
|
|
147
|
-
|
|
376
|
+
it('consumes callback rejection after readiness', async () => {
|
|
377
|
+
const callback = vi
|
|
378
|
+
.fn()
|
|
379
|
+
.mockRejectedValue(new Error('private-callback-error'));
|
|
380
|
+
const pending = tryTrackEvent(callback);
|
|
381
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
382
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
383
|
+
await expect(pending).resolves.toBeUndefined();
|
|
384
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
385
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
386
|
+
'private-callback-error',
|
|
387
|
+
);
|
|
388
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
389
|
+
});
|
|
148
390
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
391
|
+
it('expires a queued tracking diagnostic without creating another listener', async () => {
|
|
392
|
+
const add = vi.spyOn(window, 'addEventListener');
|
|
393
|
+
trackEventError('Payment Attempt', new Error('failed'), {});
|
|
394
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
395
|
+
expect(
|
|
396
|
+
add.mock.calls.filter(([name]) => name === 'Tracker Ready'),
|
|
397
|
+
).toHaveLength(1);
|
|
398
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
155
399
|
});
|
|
156
400
|
});
|