@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,545 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { SDK } from '@lytics/sdk-kit';
6
+ import { storagePlugin } from '@lytics/sdk-kit-plugins';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { scrollDepthPlugin } from './index';
9
+ import type { ScrollDepthPluginConfig } from './types';
10
+
11
+ // Extend SDK type to include scrollDepth API
12
+ interface SDKWithScrollDepth extends SDK {
13
+ scrollDepth: {
14
+ getMaxPercent: () => number;
15
+ getCurrentPercent: () => number;
16
+ getThresholdsCrossed: () => number[];
17
+ reset: () => void;
18
+ };
19
+ }
20
+
21
+ describe('scrollDepthPlugin', () => {
22
+ let sdk: SDKWithScrollDepth;
23
+ let scrollEventListeners: Record<string, EventListener> = {};
24
+ let resizeEventListeners: Record<string, EventListener> = {};
25
+ let addEventListenerSpy: any;
26
+ let _removeEventListenerSpy: any;
27
+
28
+ /**
29
+ * Helper to initialize plugin with config
30
+ */
31
+ const initPlugin = async (config?: ScrollDepthPluginConfig['scrollDepth']) => {
32
+ sdk = new SDK({
33
+ name: 'test-sdk',
34
+ storage: { backend: 'memory' },
35
+ }) as SDKWithScrollDepth;
36
+
37
+ if (config) {
38
+ sdk.set('scrollDepth', config);
39
+ }
40
+
41
+ sdk.use(storagePlugin);
42
+ sdk.use(scrollDepthPlugin);
43
+ await sdk.init();
44
+ };
45
+
46
+ /**
47
+ * Helper to set document height and scroll position
48
+ */
49
+ const setScrollPosition = (scrollTop: number, scrollHeight: number, clientHeight: number) => {
50
+ // Mock scrollingElement
51
+ Object.defineProperty(document, 'scrollingElement', {
52
+ writable: true,
53
+ configurable: true,
54
+ value: {
55
+ scrollTop,
56
+ scrollHeight,
57
+ clientHeight,
58
+ },
59
+ });
60
+
61
+ // Also mock documentElement as fallback
62
+ Object.defineProperty(document, 'documentElement', {
63
+ writable: true,
64
+ configurable: true,
65
+ value: {
66
+ scrollTop,
67
+ scrollHeight,
68
+ clientHeight,
69
+ },
70
+ });
71
+ };
72
+
73
+ /**
74
+ * Helper to simulate scroll event
75
+ */
76
+ const simulateScroll = (scrollTop: number, scrollHeight: number, clientHeight: number) => {
77
+ setScrollPosition(scrollTop, scrollHeight, clientHeight);
78
+ const handler = scrollEventListeners.scroll;
79
+ if (handler) {
80
+ handler();
81
+ }
82
+ };
83
+
84
+ beforeEach(() => {
85
+ vi.clearAllMocks();
86
+ vi.useFakeTimers();
87
+
88
+ // Reset event listener tracking
89
+ scrollEventListeners = {};
90
+ resizeEventListeners = {};
91
+
92
+ // Spy on addEventListener/removeEventListener
93
+ addEventListenerSpy = vi
94
+ .spyOn(window, 'addEventListener')
95
+ .mockImplementation((event: string, handler: any) => {
96
+ if (event === 'scroll') {
97
+ scrollEventListeners[event] = handler;
98
+ } else if (event === 'resize') {
99
+ resizeEventListeners[event] = handler;
100
+ }
101
+ });
102
+
103
+ _removeEventListenerSpy = vi
104
+ .spyOn(window, 'removeEventListener')
105
+ .mockImplementation((event: string) => {
106
+ if (event === 'scroll') {
107
+ delete scrollEventListeners[event];
108
+ } else if (event === 'resize') {
109
+ delete resizeEventListeners[event];
110
+ }
111
+ });
112
+ });
113
+
114
+ afterEach(async () => {
115
+ if (sdk) {
116
+ await sdk.destroy();
117
+ }
118
+ vi.restoreAllMocks();
119
+ vi.useRealTimers();
120
+ });
121
+
122
+ describe('initialization', () => {
123
+ it('should register with default config', async () => {
124
+ await initPlugin();
125
+
126
+ expect(sdk.scrollDepth).toBeDefined();
127
+ expect(sdk.scrollDepth.getMaxPercent).toBeDefined();
128
+ expect(sdk.scrollDepth.getCurrentPercent).toBeDefined();
129
+ expect(sdk.scrollDepth.getThresholdsCrossed).toBeDefined();
130
+ expect(sdk.scrollDepth.reset).toBeDefined();
131
+ });
132
+
133
+ it('should use default thresholds [25, 50, 75, 100]', async () => {
134
+ await initPlugin();
135
+ vi.advanceTimersByTime(0);
136
+
137
+ // No thresholds crossed initially (no scroll event yet)
138
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([]);
139
+
140
+ // After scrolling, thresholds should trigger
141
+ simulateScroll(1000, 2000, 1000); // 100%
142
+ vi.advanceTimersByTime(100);
143
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50, 75, 100]);
144
+ });
145
+
146
+ it('should register scroll listener', async () => {
147
+ await initPlugin();
148
+ vi.advanceTimersByTime(0);
149
+
150
+ expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), {
151
+ passive: true,
152
+ });
153
+ });
154
+
155
+ it('should register resize listener by default', async () => {
156
+ await initPlugin();
157
+ vi.advanceTimersByTime(0);
158
+
159
+ expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function), {
160
+ passive: true,
161
+ });
162
+ });
163
+
164
+ it('should not register resize listener when disabled', async () => {
165
+ await initPlugin({ recalculateOnResize: false });
166
+ vi.advanceTimersByTime(0);
167
+
168
+ const resizeCalls = addEventListenerSpy.mock.calls.filter((call) => call[0] === 'resize');
169
+ expect(resizeCalls).toHaveLength(0);
170
+ });
171
+
172
+ it('should not check initial scroll position (waits for user interaction)', async () => {
173
+ // Set initial scroll to 100%
174
+ setScrollPosition(1000, 2000, 1000);
175
+
176
+ await initPlugin();
177
+ vi.advanceTimersByTime(0);
178
+
179
+ // Should not trigger thresholds until first scroll event
180
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([]);
181
+
182
+ // Simulate scroll - now thresholds should trigger
183
+ simulateScroll(1000, 2000, 1000);
184
+ vi.advanceTimersByTime(100);
185
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50, 75, 100]);
186
+ });
187
+ });
188
+
189
+ describe('scroll percentage calculation', () => {
190
+ it('should calculate percentage with viewport included (default)', async () => {
191
+ await initPlugin();
192
+ vi.advanceTimersByTime(0);
193
+
194
+ // scrollTop=0, scrollHeight=2000, clientHeight=1000
195
+ // (0 + 1000) / 2000 = 50%
196
+ setScrollPosition(0, 2000, 1000);
197
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(50);
198
+
199
+ // scrollTop=500, scrollHeight=2000, clientHeight=1000
200
+ // (500 + 1000) / 2000 = 75%
201
+ setScrollPosition(500, 2000, 1000);
202
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(75);
203
+
204
+ // scrollTop=1000, scrollHeight=2000, clientHeight=1000
205
+ // (1000 + 1000) / 2000 = 100%
206
+ setScrollPosition(1000, 2000, 1000);
207
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(100);
208
+ });
209
+
210
+ it('should calculate percentage without viewport (Pathfora method)', async () => {
211
+ await initPlugin({ includeViewportHeight: false });
212
+ vi.advanceTimersByTime(0);
213
+
214
+ // scrollTop=0, scrollHeight=2000, clientHeight=1000
215
+ // 0 / (2000 - 1000) = 0%
216
+ setScrollPosition(0, 2000, 1000);
217
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(0);
218
+
219
+ // scrollTop=500, scrollHeight=2000, clientHeight=1000
220
+ // 500 / (2000 - 1000) = 50%
221
+ setScrollPosition(500, 2000, 1000);
222
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(50);
223
+
224
+ // scrollTop=1000, scrollHeight=2000, clientHeight=1000
225
+ // 1000 / (2000 - 1000) = 100%
226
+ setScrollPosition(1000, 2000, 1000);
227
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(100);
228
+ });
229
+
230
+ it('should handle content shorter than viewport', async () => {
231
+ await initPlugin();
232
+ vi.advanceTimersByTime(0);
233
+
234
+ // scrollHeight <= clientHeight → treat as 100%
235
+ setScrollPosition(0, 500, 1000);
236
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(100);
237
+ });
238
+
239
+ it('should cap percentage at 100', async () => {
240
+ await initPlugin();
241
+ vi.advanceTimersByTime(0);
242
+
243
+ // Edge case: scrollTop + clientHeight > scrollHeight
244
+ setScrollPosition(1500, 2000, 1000);
245
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(100);
246
+ });
247
+ });
248
+
249
+ describe('threshold triggering', () => {
250
+ it('should emit event when threshold is crossed', async () => {
251
+ const emitSpy = vi.fn();
252
+
253
+ await initPlugin({ thresholds: [50] });
254
+ sdk.on('trigger:scrollDepth', emitSpy);
255
+ vi.advanceTimersByTime(0);
256
+
257
+ // Scroll to 50% (scrollTop=0, scrollHeight=2000, clientHeight=1000 → 50%)
258
+ simulateScroll(0, 2000, 1000);
259
+ vi.advanceTimersByTime(100); // Throttle delay
260
+
261
+ expect(emitSpy).toHaveBeenCalledWith(
262
+ expect.objectContaining({
263
+ triggered: true,
264
+ threshold: 50,
265
+ percent: 50,
266
+ maxPercent: 50,
267
+ thresholdsCrossed: [50],
268
+ })
269
+ );
270
+ });
271
+
272
+ it('should trigger multiple thresholds in order', async () => {
273
+ const events: any[] = [];
274
+
275
+ await initPlugin({ thresholds: [25, 50, 75] });
276
+ sdk.on('trigger:scrollDepth', (payload) => events.push(payload));
277
+ vi.advanceTimersByTime(0);
278
+
279
+ // Scroll to 55%
280
+ simulateScroll(100, 2000, 1000); // (100 + 1000) / 2000 = 55%
281
+ vi.advanceTimersByTime(100);
282
+
283
+ expect(events).toHaveLength(2);
284
+ expect(events[0].threshold).toBe(25);
285
+ expect(events[1].threshold).toBe(50);
286
+
287
+ // Scroll to 80%
288
+ simulateScroll(600, 2000, 1000); // (600 + 1000) / 2000 = 80%
289
+ vi.advanceTimersByTime(100);
290
+
291
+ expect(events).toHaveLength(3);
292
+ expect(events[2].threshold).toBe(75);
293
+ });
294
+
295
+ it('should only trigger each threshold once', async () => {
296
+ const emitSpy = vi.fn();
297
+
298
+ await initPlugin({ thresholds: [50] });
299
+ sdk.on('trigger:scrollDepth', emitSpy);
300
+ vi.advanceTimersByTime(0);
301
+
302
+ // Scroll to 50%
303
+ simulateScroll(0, 2000, 1000);
304
+ vi.advanceTimersByTime(100);
305
+ expect(emitSpy).toHaveBeenCalledTimes(1);
306
+
307
+ // Scroll to 60% (should not re-trigger)
308
+ simulateScroll(200, 2000, 1000);
309
+ vi.advanceTimersByTime(100);
310
+ expect(emitSpy).toHaveBeenCalledTimes(1);
311
+
312
+ // Scroll back to 40% and then to 50% again (should not re-trigger)
313
+ simulateScroll(0, 2000, 2000); // 0%
314
+ vi.advanceTimersByTime(100);
315
+ simulateScroll(0, 2000, 1000); // 50%
316
+ vi.advanceTimersByTime(100);
317
+ expect(emitSpy).toHaveBeenCalledTimes(1);
318
+ });
319
+
320
+ it('should track max scroll percentage', async () => {
321
+ await initPlugin({ thresholds: [50, 75] });
322
+ vi.advanceTimersByTime(0);
323
+
324
+ // Scroll to 60%
325
+ simulateScroll(200, 2000, 1000); // (200 + 1000) / 2000 = 60%
326
+ vi.advanceTimersByTime(100);
327
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(60);
328
+
329
+ // Scroll to 80%
330
+ simulateScroll(600, 2000, 1000); // (600 + 1000) / 2000 = 80%
331
+ vi.advanceTimersByTime(100);
332
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(80);
333
+
334
+ // Scroll back to 50% (max should still be 80%)
335
+ simulateScroll(0, 2000, 1000); // 50%
336
+ vi.advanceTimersByTime(100);
337
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(80);
338
+ });
339
+
340
+ it('should handle custom thresholds', async () => {
341
+ const events: any[] = [];
342
+
343
+ await initPlugin({ thresholds: [10, 90] });
344
+ sdk.on('trigger:scrollDepth', (payload) => events.push(payload));
345
+ vi.advanceTimersByTime(0);
346
+
347
+ // Scroll to 50% (should trigger 10 only)
348
+ simulateScroll(0, 2000, 1000); // 50%
349
+ vi.advanceTimersByTime(100);
350
+ expect(events).toHaveLength(1);
351
+ expect(events[0].threshold).toBe(10);
352
+
353
+ // Scroll to 95%
354
+ simulateScroll(900, 2000, 1000); // (900 + 1000) / 2000 = 95%
355
+ vi.advanceTimersByTime(100);
356
+ expect(events).toHaveLength(2);
357
+ expect(events[1].threshold).toBe(90);
358
+ });
359
+ });
360
+
361
+ describe('throttling', () => {
362
+ it('should throttle scroll events (default 100ms)', async () => {
363
+ const emitSpy = vi.fn();
364
+
365
+ await initPlugin({ thresholds: [25, 50, 75] });
366
+ sdk.on('trigger:scrollDepth', emitSpy);
367
+ vi.advanceTimersByTime(0);
368
+
369
+ // First scroll triggers immediately
370
+ simulateScroll(100, 2000, 1000); // 55%
371
+ expect(emitSpy).toHaveBeenCalledTimes(2); // 25%, 50%
372
+
373
+ // Rapid subsequent scrolls should be throttled
374
+ simulateScroll(150, 2000, 1000); // 57.5%
375
+ simulateScroll(200, 2000, 1000); // 60%
376
+ simulateScroll(250, 2000, 1000); // 62.5%
377
+ simulateScroll(300, 2000, 1000); // 65%
378
+
379
+ // Still only 2 events (throttled)
380
+ expect(emitSpy).toHaveBeenCalledTimes(2);
381
+
382
+ // Advance past throttle - no new thresholds crossed yet
383
+ vi.advanceTimersByTime(100);
384
+ expect(emitSpy).toHaveBeenCalledTimes(2);
385
+
386
+ // Now scroll past next threshold
387
+ simulateScroll(600, 2000, 1000); // 80%
388
+ vi.advanceTimersByTime(100);
389
+ expect(emitSpy).toHaveBeenCalledTimes(3); // 75%
390
+ });
391
+
392
+ it('should respect custom throttle interval', async () => {
393
+ const emitSpy = vi.fn();
394
+
395
+ await initPlugin({ thresholds: [50], throttle: 200 });
396
+ sdk.on('trigger:scrollDepth', emitSpy);
397
+ vi.advanceTimersByTime(0);
398
+
399
+ simulateScroll(0, 2000, 1000); // 50%
400
+
401
+ // First scroll fires immediately
402
+ expect(emitSpy).toHaveBeenCalledTimes(1);
403
+ });
404
+ });
405
+
406
+ describe('resize handling', () => {
407
+ it('should recalculate scroll on resize', async () => {
408
+ const emitSpy = vi.fn();
409
+
410
+ await initPlugin({ thresholds: [50] });
411
+ sdk.on('trigger:scrollDepth', emitSpy);
412
+ vi.advanceTimersByTime(0);
413
+
414
+ // Initial: scrollTop=0, scrollHeight=2000, clientHeight=1000 → 50%
415
+ setScrollPosition(0, 2000, 1000);
416
+ const resizeHandler = resizeEventListeners.resize;
417
+ if (resizeHandler && typeof resizeHandler === 'function') {
418
+ resizeHandler(new Event('resize') as any);
419
+ }
420
+ vi.advanceTimersByTime(100);
421
+
422
+ expect(emitSpy).toHaveBeenCalledWith(
423
+ expect.objectContaining({
424
+ threshold: 50,
425
+ percent: 50,
426
+ })
427
+ );
428
+ });
429
+ });
430
+
431
+ describe('API methods', () => {
432
+ it('should return max scroll percentage', async () => {
433
+ await initPlugin();
434
+ vi.advanceTimersByTime(0);
435
+
436
+ // Initially 0
437
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(0);
438
+
439
+ // After scrolling, should update
440
+ simulateScroll(500, 2000, 1000); // 75%
441
+ vi.advanceTimersByTime(100);
442
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(75);
443
+ });
444
+
445
+ it('should return current scroll percentage', async () => {
446
+ await initPlugin();
447
+ vi.advanceTimersByTime(0);
448
+
449
+ setScrollPosition(300, 2000, 1000); // (300 + 1000) / 2000 = 65%
450
+ expect(sdk.scrollDepth.getCurrentPercent()).toBe(65);
451
+ });
452
+
453
+ it('should return crossed thresholds in sorted order', async () => {
454
+ await initPlugin({ thresholds: [75, 25, 50, 100] });
455
+ vi.advanceTimersByTime(0);
456
+
457
+ simulateScroll(500, 2000, 1000); // 75%
458
+ vi.advanceTimersByTime(100);
459
+
460
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50, 75]);
461
+ });
462
+
463
+ it('should reset tracking', async () => {
464
+ await initPlugin({ thresholds: [50, 75] });
465
+ vi.advanceTimersByTime(0);
466
+
467
+ // Scroll and trigger threshold
468
+ simulateScroll(500, 2000, 1000); // 75%
469
+ vi.advanceTimersByTime(100);
470
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(75);
471
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([50, 75]);
472
+
473
+ // Reset
474
+ sdk.scrollDepth.reset();
475
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(0);
476
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([]);
477
+
478
+ // Can re-trigger after reset
479
+ const emitSpy = vi.fn();
480
+ sdk.on('trigger:scrollDepth', emitSpy);
481
+ simulateScroll(500, 2000, 1000); // 75%
482
+ vi.advanceTimersByTime(100);
483
+ expect(emitSpy).toHaveBeenCalledTimes(2); // Both 50 and 75 fire again
484
+ });
485
+ });
486
+
487
+ describe('cleanup', () => {
488
+ it('should allow manual cleanup via returned function', async () => {
489
+ await initPlugin();
490
+ vi.advanceTimersByTime(0);
491
+
492
+ // Verify listeners were added
493
+ expect(scrollEventListeners.scroll).toBeDefined();
494
+ expect(resizeEventListeners.resize).toBeDefined();
495
+
496
+ // Plugin exposes a cleanup function that can be called manually
497
+ // In practice, this is handled automatically by sdk-kit on destroy
498
+ // For now, we just verify the listeners exist
499
+ expect(sdk.scrollDepth).toBeDefined();
500
+ expect(sdk.scrollDepth.getMaxPercent).toBeDefined();
501
+ });
502
+ });
503
+
504
+ describe('Pathfora compatibility tests', () => {
505
+ it('should match Pathfora test: scrollPercentageToDisplay 50', async () => {
506
+ const emitSpy = vi.fn();
507
+
508
+ // Pathfora config
509
+ await initPlugin({
510
+ thresholds: [50],
511
+ includeViewportHeight: false, // Pathfora method
512
+ });
513
+ sdk.on('trigger:scrollDepth', emitSpy);
514
+ vi.advanceTimersByTime(0);
515
+
516
+ // Body height: 4000px, scroll to full height
517
+ simulateScroll(4000, 4000, 1000); // 100% scrolled
518
+ vi.advanceTimersByTime(200); // Pathfora test uses 200ms delay
519
+
520
+ expect(emitSpy).toHaveBeenCalled();
521
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toContain(50);
522
+ });
523
+
524
+ it('should match Pathfora test: scrollPercentageToDisplay 30 with scroll to height/2', async () => {
525
+ const emitSpy = vi.fn();
526
+
527
+ // Pathfora config
528
+ await initPlugin({
529
+ thresholds: [30],
530
+ includeViewportHeight: false,
531
+ });
532
+ sdk.on('trigger:scrollDepth', emitSpy);
533
+ vi.advanceTimersByTime(0);
534
+
535
+ // Body height: 4000px, scroll to height/2 (2000px)
536
+ // scrollTop=2000, scrollHeight=4000, clientHeight=1000
537
+ // 2000 / (4000 - 1000) = 66.67%
538
+ simulateScroll(2000, 4000, 1000);
539
+ vi.advanceTimersByTime(100);
540
+
541
+ expect(emitSpy).toHaveBeenCalled();
542
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toContain(30);
543
+ });
544
+ });
545
+ });