@@ -47,9 +47,11 @@ describe('autocapture', () => {
4747 teardown ?.( ) ;
4848 } ) ;
4949
50- function setup ( options = { } ) {
50+ function setup ( options : Record < string , unknown > = { } ) {
5151 teardown = setupAutocapture (
52- { forms : true , clicks : true , ...options } ,
52+ {
53+ forms : true , clicks : true , scroll : false , ...options ,
54+ } ,
5355 enqueue ,
5456 ( ) => consent ,
5557 ) ;
@@ -543,6 +545,288 @@ describe('autocapture', () => {
543545 } ) ;
544546 } ) ;
545547
548+ // ---------- Scroll depth tracking ----------
549+
550+ describe ( 'scroll depth tracking' , ( ) => {
551+ let rafCallbacks : Array < ( ) => void > ;
552+ let originalRAF : typeof requestAnimationFrame ;
553+ let originalCAF : typeof cancelAnimationFrame ;
554+
555+ beforeEach ( ( ) => {
556+ rafCallbacks = [ ] ;
557+ originalRAF = window . requestAnimationFrame ;
558+ originalCAF = window . cancelAnimationFrame ;
559+
560+ // Mock rAF: collect callbacks, flush manually
561+ let nextId = 1 ;
562+ window . requestAnimationFrame = jest . fn ( ( cb : FrameRequestCallback ) => {
563+ const id = nextId ++ ;
564+ rafCallbacks . push ( ( ) => cb ( 0 ) ) ;
565+ return id ;
566+ } ) ;
567+ window . cancelAnimationFrame = jest . fn ( ) ;
568+ } ) ;
569+
570+ afterEach ( ( ) => {
571+ window . requestAnimationFrame = originalRAF ;
572+ window . cancelAnimationFrame = originalCAF ;
573+ } ) ;
574+
575+ function flushRAF ( ) {
576+ const cbs = [ ...rafCallbacks ] ;
577+ rafCallbacks = [ ] ;
578+ cbs . forEach ( ( cb ) => cb ( ) ) ;
579+ }
580+
581+ /**
582+ * Configure jsdom's document dimensions and scroll position.
583+ * jsdom doesn't support layout, so we stub the relevant properties.
584+ */
585+ function setScrollGeometry (
586+ scrollHeight : number ,
587+ clientHeight : number ,
588+ scrollY : number ,
589+ ) {
590+ Object . defineProperty ( document . documentElement , 'scrollHeight' , {
591+ value : scrollHeight , configurable : true ,
592+ } ) ;
593+ Object . defineProperty ( document . documentElement , 'clientHeight' , {
594+ value : clientHeight , configurable : true ,
595+ } ) ;
596+ Object . defineProperty ( window , 'innerHeight' , {
597+ value : clientHeight , configurable : true ,
598+ } ) ;
599+ Object . defineProperty ( window , 'scrollY' , {
600+ value : scrollY , configurable : true , writable : true ,
601+ } ) ;
602+ }
603+
604+ describe ( 'scrollable pages' , ( ) => {
605+ beforeEach ( ( ) => {
606+ // 2000px tall page in a 500px viewport → scrollable
607+ setScrollGeometry ( 2000 , 500 , 0 ) ;
608+ } ) ;
609+
610+ it ( 'fires scroll_depth at each milestone exactly once' , ( ) => {
611+ setup ( { scroll : true } ) ;
612+
613+ // Scroll to 25% → scrollY = (2000-500) * 0.25 = 375
614+ ( window as Record < string , unknown > ) . scrollY = 375 ;
615+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
616+ flushRAF ( ) ;
617+
618+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 25 } ) ;
619+ expect ( enqueue ) . toHaveBeenCalledTimes ( 1 ) ;
620+
621+ // Scroll to 55% → should fire 50 (25 already fired)
622+ ( window as Record < string , unknown > ) . scrollY = 825 ;
623+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
624+ flushRAF ( ) ;
625+
626+ expect ( enqueue ) . toHaveBeenCalledTimes ( 2 ) ;
627+ expect ( enqueue ) . toHaveBeenLastCalledWith ( 'scroll_depth' , { depth : 50 } ) ;
628+ } ) ;
629+
630+ it ( 'fires multiple milestones in a single scroll if jumped past' , ( ) => {
631+ setup ( { scroll : true } ) ;
632+
633+ // Jump straight to 80% → should fire 25, 50, 75
634+ ( window as Record < string , unknown > ) . scrollY = 1200 ;
635+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
636+ flushRAF ( ) ;
637+
638+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 25 } ) ;
639+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 50 } ) ;
640+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 75 } ) ;
641+ expect ( enqueue ) . toHaveBeenCalledTimes ( 3 ) ;
642+ } ) ;
643+
644+ it ( 'does not re-fire milestones already reached' , ( ) => {
645+ setup ( { scroll : true } ) ;
646+
647+ // Scroll to 60%
648+ ( window as Record < string , unknown > ) . scrollY = 900 ;
649+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
650+ flushRAF ( ) ;
651+
652+ const countAfterFirst = enqueue . mock . calls . length ;
653+
654+ // Scroll back up to 30%, then to 60% again
655+ ( window as Record < string , unknown > ) . scrollY = 450 ;
656+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
657+ flushRAF ( ) ;
658+
659+ ( window as Record < string , unknown > ) . scrollY = 900 ;
660+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
661+ flushRAF ( ) ;
662+
663+ // No new milestones should have fired
664+ expect ( enqueue ) . toHaveBeenCalledTimes ( countAfterFirst ) ;
665+ } ) ;
666+
667+ it ( 'fires 90 and 100 milestones' , ( ) => {
668+ setup ( { scroll : true } ) ;
669+
670+ // Scroll to 100%
671+ ( window as Record < string , unknown > ) . scrollY = 1500 ;
672+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
673+ flushRAF ( ) ;
674+
675+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 25 } ) ;
676+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 50 } ) ;
677+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 75 } ) ;
678+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 90 } ) ;
679+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 100 } ) ;
680+ expect ( enqueue ) . toHaveBeenCalledTimes ( 5 ) ;
681+ } ) ;
682+
683+ it ( 'does not include aboveFold property on scrollable pages' , ( ) => {
684+ setup ( { scroll : true } ) ;
685+
686+ ( window as Record < string , unknown > ) . scrollY = 375 ;
687+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
688+ flushRAF ( ) ;
689+
690+ expect ( enqueue . mock . calls [ 0 ] [ 1 ] ) . toEqual ( { depth : 25 } ) ;
691+ expect ( enqueue . mock . calls [ 0 ] [ 1 ] ) . not . toHaveProperty ( 'aboveFold' ) ;
692+ } ) ;
693+
694+ it ( 'does not fire at consent none' , ( ) => {
695+ consent = 'none' ;
696+ setup ( { scroll : true } ) ;
697+
698+ ( window as Record < string , unknown > ) . scrollY = 1500 ;
699+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
700+ flushRAF ( ) ;
701+
702+ expect ( enqueue ) . not . toHaveBeenCalled ( ) ;
703+ } ) ;
704+
705+ it ( 'throttles via requestAnimationFrame' , ( ) => {
706+ setup ( { scroll : true } ) ;
707+
708+ // Fire multiple scroll events without flushing rAF
709+ ( window as Record < string , unknown > ) . scrollY = 375 ;
710+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
711+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
712+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
713+
714+ // Only one rAF should have been scheduled
715+ expect ( window . requestAnimationFrame ) . toHaveBeenCalledTimes ( 1 ) ;
716+
717+ flushRAF ( ) ;
718+
719+ expect ( enqueue ) . toHaveBeenCalledTimes ( 1 ) ;
720+ } ) ;
721+
722+ it ( 'checks initial scroll position on setup' , ( ) => {
723+ // Page already scrolled to 30% before setup
724+ ( window as Record < string , unknown > ) . scrollY = 450 ;
725+ setup ( { scroll : true } ) ;
726+
727+ // Should fire 25% immediately (no scroll event needed)
728+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 25 } ) ;
729+ } ) ;
730+ } ) ;
731+
732+ describe ( 'above-the-fold pages' , ( ) => {
733+ beforeEach ( ( ) => {
734+ // 400px content in a 600px viewport → no scroll
735+ setScrollGeometry ( 400 , 600 , 0 ) ;
736+ jest . useFakeTimers ( ) ;
737+ } ) ;
738+
739+ afterEach ( ( ) => {
740+ jest . useRealTimers ( ) ;
741+ } ) ;
742+
743+ it ( 'fires scroll_depth 100 with aboveFold after dwell time' , ( ) => {
744+ setup ( { scroll : true } ) ;
745+
746+ // Should NOT fire immediately
747+ expect ( enqueue ) . not . toHaveBeenCalled ( ) ;
748+
749+ // Advance past dwell time
750+ jest . advanceTimersByTime ( 2000 ) ;
751+
752+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , {
753+ depth : 100 ,
754+ aboveFold : true ,
755+ } ) ;
756+ expect ( enqueue ) . toHaveBeenCalledTimes ( 1 ) ;
757+ } ) ;
758+
759+ it ( 'does not fire before dwell time elapses' , ( ) => {
760+ setup ( { scroll : true } ) ;
761+
762+ jest . advanceTimersByTime ( 1999 ) ;
763+ expect ( enqueue ) . not . toHaveBeenCalled ( ) ;
764+ } ) ;
765+
766+ it ( 'does not fire if consent is none when dwell timer triggers' , ( ) => {
767+ setup ( { scroll : true } ) ;
768+
769+ consent = 'none' ;
770+ jest . advanceTimersByTime ( 2000 ) ;
771+
772+ expect ( enqueue ) . not . toHaveBeenCalled ( ) ;
773+ } ) ;
774+
775+ it ( 'cancels dwell timer on teardown' , ( ) => {
776+ setup ( { scroll : true } ) ;
777+
778+ teardown ( ) ;
779+ jest . advanceTimersByTime ( 2000 ) ;
780+
781+ expect ( enqueue ) . not . toHaveBeenCalled ( ) ;
782+ } ) ;
783+ } ) ;
784+
785+ describe ( 'configuration' , ( ) => {
786+ beforeEach ( ( ) => {
787+ setScrollGeometry ( 2000 , 500 , 0 ) ;
788+ } ) ;
789+
790+ it ( 'does not track scroll when scroll option is false' , ( ) => {
791+ setup ( { scroll : false } ) ;
792+
793+ ( window as Record < string , unknown > ) . scrollY = 1500 ;
794+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
795+ flushRAF ( ) ;
796+
797+ expect ( enqueue ) . not . toHaveBeenCalled ( ) ;
798+ } ) ;
799+
800+ it ( 'enables scroll tracking by default' , ( ) => {
801+ // Call setupAutocapture directly to verify production defaults
802+ teardown = setupAutocapture ( { } , enqueue , ( ) => consent ) ;
803+
804+ ( window as Record < string , unknown > ) . scrollY = 375 ;
805+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
806+ flushRAF ( ) ;
807+
808+ expect ( enqueue ) . toHaveBeenCalledWith ( 'scroll_depth' , { depth : 25 } ) ;
809+ } ) ;
810+ } ) ;
811+
812+ describe ( 'teardown' , ( ) => {
813+ beforeEach ( ( ) => {
814+ setScrollGeometry ( 2000 , 500 , 0 ) ;
815+ } ) ;
816+
817+ it ( 'removes scroll listener on teardown' , ( ) => {
818+ setup ( { scroll : true } ) ;
819+ teardown ( ) ;
820+
821+ ( window as Record < string , unknown > ) . scrollY = 1500 ;
822+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
823+ flushRAF ( ) ;
824+
825+ expect ( enqueue ) . not . toHaveBeenCalled ( ) ;
826+ } ) ;
827+ } ) ;
828+ } ) ;
829+
546830 // ---------- Email hashing ----------
547831
548832 describe ( 'email hashing' , ( ) => {
0 commit comments