@prosdevlab/experience-sdk-plugins 0.1.4 → 0.3.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.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +6 -6
  2. package/CHANGELOG.md +150 -0
  3. package/README.md +141 -79
  4. package/dist/index.d.ts +813 -35
  5. package/dist/index.js +1910 -66
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/banner/banner.ts +63 -62
  9. package/src/exit-intent/exit-intent.test.ts +423 -0
  10. package/src/exit-intent/exit-intent.ts +371 -0
  11. package/src/exit-intent/index.ts +6 -0
  12. package/src/exit-intent/types.ts +59 -0
  13. package/src/index.ts +7 -0
  14. package/src/inline/index.ts +3 -0
  15. package/src/inline/inline.test.ts +620 -0
  16. package/src/inline/inline.ts +269 -0
  17. package/src/inline/insertion.ts +66 -0
  18. package/src/inline/types.ts +52 -0
  19. package/src/integration.test.ts +421 -0
  20. package/src/modal/form-rendering.ts +262 -0
  21. package/src/modal/form-styles.ts +212 -0
  22. package/src/modal/form-validation.test.ts +413 -0
  23. package/src/modal/form-validation.ts +126 -0
  24. package/src/modal/index.ts +3 -0
  25. package/src/modal/modal-styles.ts +204 -0
  26. package/src/modal/modal.browser.test.ts +164 -0
  27. package/src/modal/modal.test.ts +1294 -0
  28. package/src/modal/modal.ts +685 -0
  29. package/src/modal/types.ts +114 -0
  30. package/src/page-visits/index.ts +6 -0
  31. package/src/page-visits/page-visits.test.ts +562 -0
  32. package/src/page-visits/page-visits.ts +314 -0
  33. package/src/page-visits/types.ts +119 -0
  34. package/src/scroll-depth/index.ts +6 -0
  35. package/src/scroll-depth/scroll-depth.test.ts +580 -0
  36. package/src/scroll-depth/scroll-depth.ts +398 -0
  37. package/src/scroll-depth/types.ts +122 -0
  38. package/src/time-delay/index.ts +6 -0
  39. package/src/time-delay/time-delay.test.ts +477 -0
  40. package/src/time-delay/time-delay.ts +296 -0
  41. package/src/time-delay/types.ts +89 -0
  42. package/src/types.ts +20 -36
  43. package/src/utils/sanitize.ts +5 -2
@@ -0,0 +1,580 @@
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('reset()', () => {
505
+ it('should clear triggered thresholds and max scroll', async () => {
506
+ const emitSpy = vi.fn();
507
+
508
+ await initPlugin({ thresholds: [25, 50, 75] });
509
+ sdk.on('trigger:scrollDepth', emitSpy);
510
+ vi.advanceTimersByTime(0);
511
+
512
+ // Scroll to 50%
513
+ simulateScroll(1000, 3000, 1000);
514
+ vi.advanceTimersByTime(200);
515
+
516
+ // Should have triggered 25% and 50%
517
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50]);
518
+ expect(sdk.scrollDepth.getMaxPercent()).toBeGreaterThan(0);
519
+ expect(emitSpy).toHaveBeenCalledTimes(2);
520
+
521
+ // Reset
522
+ sdk.scrollDepth.reset();
523
+
524
+ // Should clear state
525
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([]);
526
+ expect(sdk.scrollDepth.getMaxPercent()).toBe(0);
527
+
528
+ // Scroll again to 50% should trigger again
529
+ emitSpy.mockClear();
530
+ simulateScroll(1000, 3000, 1000);
531
+ vi.advanceTimersByTime(200);
532
+
533
+ // Should trigger both 25% and 50% again
534
+ expect(emitSpy).toHaveBeenCalledTimes(2);
535
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50]);
536
+ });
537
+ });
538
+
539
+ describe('Pathfora compatibility tests', () => {
540
+ it('should match Pathfora test: scrollPercentageToDisplay 50', async () => {
541
+ const emitSpy = vi.fn();
542
+
543
+ // Pathfora config
544
+ await initPlugin({
545
+ thresholds: [50],
546
+ includeViewportHeight: false, // Pathfora method
547
+ });
548
+ sdk.on('trigger:scrollDepth', emitSpy);
549
+ vi.advanceTimersByTime(0);
550
+
551
+ // Body height: 4000px, scroll to full height
552
+ simulateScroll(4000, 4000, 1000); // 100% scrolled
553
+ vi.advanceTimersByTime(200); // Pathfora test uses 200ms delay
554
+
555
+ expect(emitSpy).toHaveBeenCalled();
556
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toContain(50);
557
+ });
558
+
559
+ it('should match Pathfora test: scrollPercentageToDisplay 30 with scroll to height/2', async () => {
560
+ const emitSpy = vi.fn();
561
+
562
+ // Pathfora config
563
+ await initPlugin({
564
+ thresholds: [30],
565
+ includeViewportHeight: false,
566
+ });
567
+ sdk.on('trigger:scrollDepth', emitSpy);
568
+ vi.advanceTimersByTime(0);
569
+
570
+ // Body height: 4000px, scroll to height/2 (2000px)
571
+ // scrollTop=2000, scrollHeight=4000, clientHeight=1000
572
+ // 2000 / (4000 - 1000) = 66.67%
573
+ simulateScroll(2000, 4000, 1000);
574
+ vi.advanceTimersByTime(100);
575
+
576
+ expect(emitSpy).toHaveBeenCalled();
577
+ expect(sdk.scrollDepth.getThresholdsCrossed()).toContain(30);
578
+ });
579
+ });
580
+ });