@prosdevlab/experience-sdk-plugins 0.1.4 → 0.2.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.
@@ -0,0 +1,423 @@
1
+ // packages/plugins/src/exit-intent/exit-intent.test.ts
2
+
3
+ import { SDK } from '@lytics/sdk-kit';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { exitIntentPlugin } from './index';
6
+
7
+ describe('Exit Intent Plugin', () => {
8
+ let sdk: SDK;
9
+ let mouseEventListeners: Record<string, EventListener> = {};
10
+
11
+ beforeEach(() => {
12
+ // Clear sessionStorage
13
+ sessionStorage.clear();
14
+
15
+ // Create fresh SDK instance
16
+ sdk = new SDK({ name: 'test-sdk' });
17
+
18
+ // Mock document event listeners
19
+ mouseEventListeners = {};
20
+ vi.spyOn(document, 'addEventListener').mockImplementation((event: string, handler: any) => {
21
+ mouseEventListeners[event] = handler;
22
+ });
23
+
24
+ vi.spyOn(document, 'removeEventListener').mockImplementation((event: string) => {
25
+ delete mouseEventListeners[event];
26
+ });
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ // Helper to initialize plugin with config
34
+ async function initPlugin(config: any = { sensitivity: 50, minTimeOnPage: 0 }) {
35
+ sdk.set('exitIntent', config);
36
+ sdk.use(exitIntentPlugin);
37
+ await sdk.init();
38
+ }
39
+
40
+ describe('Plugin Initialization', () => {
41
+ it('should register exitIntent namespace', async () => {
42
+ await initPlugin();
43
+ expect((sdk as any).exitIntent).toBeDefined();
44
+ });
45
+
46
+ it('should set up mouse event listeners', async () => {
47
+ await initPlugin({ sensitivity: 20 });
48
+ expect(mouseEventListeners.mousemove).toBeDefined();
49
+ expect(mouseEventListeners.mouseout).toBeDefined();
50
+ });
51
+
52
+ it('should not initialize on mobile devices', async () => {
53
+ const originalUserAgent = navigator.userAgent;
54
+ Object.defineProperty(navigator, 'userAgent', {
55
+ value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
56
+ configurable: true,
57
+ });
58
+
59
+ await initPlugin({ disableOnMobile: true });
60
+
61
+ expect(mouseEventListeners.mousemove).toBeUndefined();
62
+ expect(mouseEventListeners.mouseout).toBeUndefined();
63
+
64
+ Object.defineProperty(navigator, 'userAgent', {
65
+ value: originalUserAgent,
66
+ configurable: true,
67
+ });
68
+ });
69
+
70
+ it('should initialize on mobile if disableOnMobile is false', async () => {
71
+ const originalUserAgent = navigator.userAgent;
72
+ Object.defineProperty(navigator, 'userAgent', {
73
+ value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
74
+ configurable: true,
75
+ });
76
+
77
+ await initPlugin({ disableOnMobile: false });
78
+
79
+ expect(mouseEventListeners.mousemove).toBeDefined();
80
+ expect(mouseEventListeners.mouseout).toBeDefined();
81
+
82
+ Object.defineProperty(navigator, 'userAgent', {
83
+ value: originalUserAgent,
84
+ configurable: true,
85
+ });
86
+ });
87
+
88
+ it('should not initialize if already triggered this session', async () => {
89
+ sessionStorage.setItem('xp:exitIntent:triggered', Date.now().toString());
90
+
91
+ await initPlugin();
92
+
93
+ expect(mouseEventListeners.mousemove).toBeUndefined();
94
+ expect(mouseEventListeners.mouseout).toBeUndefined();
95
+ });
96
+ });
97
+
98
+ describe('Mouse Position Tracking', () => {
99
+ it('should track mouse positions', async () => {
100
+ await initPlugin();
101
+
102
+ const mouseMoveHandler = mouseEventListeners.mousemove;
103
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 200 }));
104
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 150, clientY: 250 }));
105
+
106
+ const positions = (sdk as any).exitIntent.getPositions();
107
+ expect(positions).toHaveLength(2);
108
+ expect(positions[0]).toEqual({ x: 100, y: 200 });
109
+ expect(positions[1]).toEqual({ x: 150, y: 250 });
110
+ });
111
+
112
+ it('should limit position history to configured size', async () => {
113
+ await initPlugin({ positionHistorySize: 3 });
114
+
115
+ const mouseMoveHandler = mouseEventListeners.mousemove;
116
+ for (let i = 0; i < 5; i++) {
117
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: i * 10, clientY: i * 10 }));
118
+ }
119
+
120
+ const positions = (sdk as any).exitIntent.getPositions();
121
+ expect(positions).toHaveLength(3);
122
+ expect(positions[0]).toEqual({ x: 20, y: 20 });
123
+ expect(positions[1]).toEqual({ x: 30, y: 30 });
124
+ expect(positions[2]).toEqual({ x: 40, y: 40 });
125
+ });
126
+ });
127
+
128
+ describe('Exit Intent Detection (Pathfora Test Cases)', () => {
129
+ it('should NOT trigger immediately on page load', async () => {
130
+ const triggerSpy = vi.fn();
131
+ sdk.on('trigger:exitIntent', triggerSpy);
132
+
133
+ await initPlugin();
134
+
135
+ expect(triggerSpy).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it('should NOT trigger when exiting from left edge', async () => {
139
+ const triggerSpy = vi.fn();
140
+ sdk.on('trigger:exitIntent', triggerSpy);
141
+
142
+ await initPlugin();
143
+
144
+ const mouseMoveHandler = mouseEventListeners.mousemove;
145
+ const mouseOutHandler = mouseEventListeners.mouseout;
146
+
147
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 300 }));
148
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 50, clientY: 300 }));
149
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 10, clientY: 300 }));
150
+
151
+ mouseOutHandler(
152
+ new MouseEvent('mouseout', { clientX: 0, clientY: 300, relatedTarget: null })
153
+ );
154
+
155
+ expect(triggerSpy).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('should NOT trigger when exiting from bottom', async () => {
159
+ const triggerSpy = vi.fn();
160
+ sdk.on('trigger:exitIntent', triggerSpy);
161
+
162
+ await initPlugin();
163
+
164
+ const mouseMoveHandler = mouseEventListeners.mousemove;
165
+ const mouseOutHandler = mouseEventListeners.mouseout;
166
+
167
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 300 }));
168
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 500 }));
169
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 700 }));
170
+
171
+ mouseOutHandler(
172
+ new MouseEvent('mouseout', { clientX: 500, clientY: 800, relatedTarget: null })
173
+ );
174
+
175
+ expect(triggerSpy).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('should NOT trigger on downward movement', async () => {
179
+ const triggerSpy = vi.fn();
180
+ sdk.on('trigger:exitIntent', triggerSpy);
181
+
182
+ await initPlugin();
183
+
184
+ const mouseMoveHandler = mouseEventListeners.mousemove;
185
+ const mouseOutHandler = mouseEventListeners.mouseout;
186
+
187
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 10 }));
188
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 30 }));
189
+
190
+ mouseOutHandler(
191
+ new MouseEvent('mouseout', { clientX: 500, clientY: 50, relatedTarget: null })
192
+ );
193
+
194
+ expect(triggerSpy).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it('SHOULD trigger on upward movement + top exit (Pathfora algorithm)', async () => {
198
+ const triggerSpy = vi.fn();
199
+ sdk.on('trigger:exitIntent', triggerSpy);
200
+
201
+ await initPlugin();
202
+
203
+ const mouseMoveHandler = mouseEventListeners.mousemove;
204
+ const mouseOutHandler = mouseEventListeners.mouseout;
205
+
206
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 200 }));
207
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
208
+
209
+ mouseOutHandler(
210
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
211
+ );
212
+
213
+ expect(triggerSpy).toHaveBeenCalledTimes(1);
214
+ });
215
+
216
+ it('should respect min time on page setting', async () => {
217
+ const triggerSpy = vi.fn();
218
+ sdk.on('trigger:exitIntent', triggerSpy);
219
+
220
+ await initPlugin({ sensitivity: 50, minTimeOnPage: 2000 });
221
+
222
+ const mouseMoveHandler = mouseEventListeners.mousemove;
223
+ const mouseOutHandler = mouseEventListeners.mouseout;
224
+
225
+ // Try immediately (should fail)
226
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
227
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
228
+ mouseOutHandler(
229
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
230
+ );
231
+
232
+ expect(triggerSpy).not.toHaveBeenCalled();
233
+
234
+ // Wait and try again
235
+ await new Promise((resolve) => setTimeout(resolve, 2100));
236
+
237
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
238
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
239
+ mouseOutHandler(
240
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
241
+ );
242
+
243
+ expect(triggerSpy).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+ it('should trigger only once per session', async () => {
247
+ const triggerSpy = vi.fn();
248
+ sdk.on('trigger:exitIntent', triggerSpy);
249
+
250
+ await initPlugin();
251
+
252
+ const mouseMoveHandler = mouseEventListeners.mousemove;
253
+ const mouseOutHandler = mouseEventListeners.mouseout;
254
+
255
+ // First trigger
256
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
257
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
258
+ mouseOutHandler(
259
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
260
+ );
261
+
262
+ expect(triggerSpy).toHaveBeenCalledTimes(1);
263
+
264
+ // Try again
265
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
266
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
267
+ mouseOutHandler(
268
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
269
+ );
270
+
271
+ expect(triggerSpy).toHaveBeenCalledTimes(1); // Still 1
272
+ });
273
+
274
+ it('should respect configurable sensitivity threshold', async () => {
275
+ const triggerSpy = vi.fn();
276
+ sdk.on('trigger:exitIntent', triggerSpy);
277
+
278
+ await initPlugin({ sensitivity: 20, minTimeOnPage: 0 });
279
+
280
+ const mouseMoveHandler = mouseEventListeners.mousemove;
281
+ const mouseOutHandler = mouseEventListeners.mouseout;
282
+
283
+ // Far from top with slow movement - should NOT trigger
284
+ // y=100, py=110, velocity=10
285
+ // 100 - 10 = 90, which is > 20, so won't trigger
286
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 110 }));
287
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
288
+ mouseOutHandler(
289
+ new MouseEvent('mouseout', { clientX: 500, clientY: 95, relatedTarget: null })
290
+ );
291
+
292
+ expect(triggerSpy).not.toHaveBeenCalled();
293
+
294
+ // Near top with upward movement - SHOULD trigger
295
+ // y=10, py=30, velocity=20
296
+ // 10 - 20 = -10, which is <= 20, so will trigger
297
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 30 }));
298
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 10 }));
299
+ mouseOutHandler(
300
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
301
+ );
302
+
303
+ expect(triggerSpy).toHaveBeenCalledTimes(1);
304
+ });
305
+
306
+ it('should clean up event listeners after trigger', async () => {
307
+ await initPlugin();
308
+
309
+ const mouseMoveHandler = mouseEventListeners.mousemove;
310
+ const mouseOutHandler = mouseEventListeners.mouseout;
311
+
312
+ expect(mouseMoveHandler).toBeDefined();
313
+ expect(mouseOutHandler).toBeDefined();
314
+
315
+ // Trigger exit intent
316
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
317
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
318
+ mouseOutHandler(
319
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
320
+ );
321
+
322
+ // Listeners should be removed
323
+ expect(mouseEventListeners.mousemove).toBeUndefined();
324
+ expect(mouseEventListeners.mouseout).toBeUndefined();
325
+ });
326
+
327
+ it('should apply delay before emitting trigger event', async () => {
328
+ const triggerSpy = vi.fn();
329
+ sdk.on('trigger:exitIntent', triggerSpy);
330
+
331
+ await initPlugin({ sensitivity: 50, minTimeOnPage: 0, delay: 1000 });
332
+
333
+ const mouseMoveHandler = mouseEventListeners.mousemove;
334
+ const mouseOutHandler = mouseEventListeners.mouseout;
335
+
336
+ // Trigger exit intent
337
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
338
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
339
+ mouseOutHandler(
340
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
341
+ );
342
+
343
+ // Should not trigger immediately
344
+ expect(triggerSpy).not.toHaveBeenCalled();
345
+
346
+ // Wait for delay
347
+ await new Promise((resolve) => setTimeout(resolve, 1100));
348
+
349
+ expect(triggerSpy).toHaveBeenCalledTimes(1);
350
+ });
351
+ });
352
+
353
+ describe('API Methods', () => {
354
+ it('should expose isTriggered() method', async () => {
355
+ await initPlugin();
356
+
357
+ expect((sdk as any).exitIntent.isTriggered()).toBe(false);
358
+
359
+ const mouseMoveHandler = mouseEventListeners.mousemove;
360
+ const mouseOutHandler = mouseEventListeners.mouseout;
361
+
362
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
363
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
364
+ mouseOutHandler(
365
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
366
+ );
367
+
368
+ expect((sdk as any).exitIntent.isTriggered()).toBe(true);
369
+ });
370
+
371
+ it('should expose reset() method for testing', async () => {
372
+ await initPlugin();
373
+
374
+ const mouseMoveHandler = mouseEventListeners.mousemove;
375
+ const mouseOutHandler = mouseEventListeners.mouseout;
376
+
377
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
378
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
379
+ mouseOutHandler(
380
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
381
+ );
382
+
383
+ expect((sdk as any).exitIntent.isTriggered()).toBe(true);
384
+
385
+ // Reset
386
+ (sdk as any).exitIntent.reset();
387
+
388
+ expect((sdk as any).exitIntent.isTriggered()).toBe(false);
389
+ expect((sdk as any).exitIntent.getPositions()).toHaveLength(0);
390
+ });
391
+
392
+ it('should expose getPositions() method', async () => {
393
+ await initPlugin();
394
+
395
+ const mouseMoveHandler = mouseEventListeners.mousemove;
396
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 200 }));
397
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 150, clientY: 250 }));
398
+
399
+ const positions = (sdk as any).exitIntent.getPositions();
400
+ expect(positions).toEqual([
401
+ { x: 100, y: 200 },
402
+ { x: 150, y: 250 },
403
+ ]);
404
+ });
405
+ });
406
+
407
+ describe('SessionStorage Persistence', () => {
408
+ it('should store trigger state in sessionStorage', async () => {
409
+ await initPlugin();
410
+
411
+ const mouseMoveHandler = mouseEventListeners.mousemove;
412
+ const mouseOutHandler = mouseEventListeners.mouseout;
413
+
414
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
415
+ mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
416
+ mouseOutHandler(
417
+ new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
418
+ );
419
+
420
+ expect(sessionStorage.getItem('xp:exitIntent:triggered')).toBeTruthy();
421
+ });
422
+ });
423
+ });