@@ -53,7 +53,7 @@ describe('NinetailedInsightsPlugin', () => {
5353 } ) ;
5454 intersect ( element , true ) ;
5555 }
56- jest . runAllTimers ( ) ;
56+ jest . advanceTimersByTime ( 2_100 ) ;
5757 jest . useRealTimers ( ) ;
5858 await waitFor ( ( ) => {
5959 expect ( insightsApiClientSendEventBatchesMock ) . toHaveBeenCalledTimes ( 1 ) ;
@@ -93,7 +93,7 @@ describe('NinetailedInsightsPlugin', () => {
9393 } ) ;
9494 intersect ( element , true ) ;
9595 }
96- jest . runAllTimers ( ) ;
96+ jest . advanceTimersByTime ( 2_100 ) ;
9797 jest . useRealTimers ( ) ;
9898 await waitFor ( ( ) => {
9999 expect ( insightsApiClientSendEventBatchesMock ) . not . toHaveBeenCalled ( ) ;
@@ -115,7 +115,7 @@ describe('NinetailedInsightsPlugin', () => {
115115 variantIndex : 0 ,
116116 } ) ;
117117 intersect ( element , true ) ;
118- jest . runAllTimers ( ) ;
118+ jest . advanceTimersByTime ( 2_100 ) ;
119119 jest . useRealTimers ( ) ;
120120 expect ( insightsApiClientSendEventBatchesMock ) . toHaveBeenCalledTimes ( 0 ) ;
121121 await ninetailed . identify ( 'test-2' ) ;
@@ -185,12 +185,59 @@ describe('NinetailedInsightsPlugin', () => {
185185 } ) ;
186186 }
187187 intersect ( element , true ) ;
188- jest . runAllTimers ( ) ;
188+
189+ // Run a bounded number of heartbeats to avoid an unbounded timer loop.
190+ for ( let i = 0 ; i < 25 ; i ++ ) {
191+ jest . runOnlyPendingTimers ( ) ;
192+ }
193+ intersect ( element , false ) ;
194+ jest . runOnlyPendingTimers ( ) ;
189195 jest . useRealTimers ( ) ;
190196 await waitFor ( ( ) => {
191197 expect ( insightsApiClientSendEventBatchesMock ) . toHaveBeenCalledTimes ( 1 ) ;
192198 } ) ;
193199 } ) ;
200+ it ( 'should dedupe onHasSeenElement by viewId even when payloads differ' , async ( ) => {
201+ const insightsPlugin = new NinetailedInsightsPlugin ( ) ;
202+ const dispatchMock = jest . fn ( ) ;
203+ await insightsPlugin . initialize ( {
204+ instance : { dispatch : dispatchMock } as unknown as never ,
205+ } ) ;
206+
207+ const basePayload = {
208+ meta : { rid : 'rid-1' , ts : new Date ( ) . toISOString ( ) } ,
209+ _ : { originalAction : 'has_seen_element' } ,
210+ element : document . createElement ( 'div' ) ,
211+ variant : { id : 'variant-id-1' } ,
212+ variantIndex : 0 ,
213+ seenFor : 2000 ,
214+ viewId : 'view-id-1' ,
215+ viewDurationMs : 2000 ,
216+ } ;
217+
218+ insightsPlugin . onHasSeenElement ( {
219+ payload : basePayload ,
220+ instance : { } as never ,
221+ abort : jest . fn ( ) ,
222+ } ) ;
223+
224+ insightsPlugin . onHasSeenElement ( {
225+ payload : {
226+ ...basePayload ,
227+ variant : { id : 'variant-id-2' } ,
228+ variantIndex : 1 ,
229+ } ,
230+ instance : { } as never ,
231+ abort : jest . fn ( ) ,
232+ } ) ;
233+
234+ expect ( dispatchMock ) . toHaveBeenCalledTimes ( 1 ) ;
235+ expect ( dispatchMock ) . toHaveBeenCalledWith (
236+ expect . objectContaining ( {
237+ viewId : 'view-id-1' ,
238+ } )
239+ ) ;
240+ } ) ;
194241 it ( 'should not track the same element with the same payload' , async ( ) => {
195242 const insightsPlugin = new NinetailedInsightsPlugin ( ) ;
196243 const ninetailed = setupNinetailedInstance ( [ insightsPlugin ] ) ;
@@ -209,7 +256,7 @@ describe('NinetailedInsightsPlugin', () => {
209256 } ) ;
210257 }
211258 intersect ( element , true ) ;
212- jest . runAllTimers ( ) ;
259+ jest . advanceTimersByTime ( 2_100 ) ;
213260 jest . useRealTimers ( ) ;
214261 await waitFor ( ( ) => {
215262 expect ( insightsApiClientSendEventBatchesMock ) . toHaveBeenCalledTimes ( 0 ) ;
@@ -236,13 +283,127 @@ describe('NinetailedInsightsPlugin', () => {
236283 // Intersect twice to simulate multiple views
237284 intersect ( element , true ) ;
238285 intersect ( element , true ) ;
239- jest . runAllTimers ( ) ;
286+ jest . advanceTimersByTime ( 2_100 ) ;
240287 jest . useRealTimers ( ) ;
241288 await waitFor ( ( ) => {
242289 // Should not flush because only one unique event
243290 expect ( insightsApiClientSendEventBatchesMock ) . toHaveBeenCalledTimes ( 0 ) ;
244291 } ) ;
245292 } ) ;
293+ it ( 'should generate a new viewId and reset viewDurationMs across re-entries' , async ( ) => {
294+ const insightsPlugin = new NinetailedInsightsPlugin ( ) ;
295+ const ninetailed = setupNinetailedInstance ( [ insightsPlugin ] ) ;
296+ insightsPlugin . setCredentials ( {
297+ clientId : 'test' ,
298+ environment : 'development' ,
299+ } ) ;
300+ await ninetailed . identify ( 'test' ) ;
301+ jest . useFakeTimers ( ) ;
302+ const element = document . body . appendChild ( document . createElement ( 'div' ) ) ;
303+ ninetailed . observeElement ( {
304+ element,
305+ variant : { id : 'variant-id-1' } ,
306+ variantIndex : 0 ,
307+ } ) ;
308+
309+ intersect ( element , true ) ;
310+ jest . advanceTimersByTime ( 2_100 ) ;
311+ intersect ( element , false ) ;
312+ jest . advanceTimersByTime ( 250 ) ;
313+ intersect ( element , true ) ;
314+ jest . advanceTimersByTime ( 2_100 ) ;
315+ intersect ( element , false ) ;
316+ jest . useRealTimers ( ) ;
317+
318+ await waitFor ( ( ) => {
319+ const pluginEvents = (
320+ insightsPlugin as unknown as { events : Record < string , unknown > [ ] }
321+ ) . events ;
322+ const componentEvents = pluginEvents . filter (
323+ ( event : { type ?: string } ) => event . type === 'component'
324+ ) ;
325+
326+ expect ( componentEvents . length ) . toBeGreaterThan ( 1 ) ;
327+ } ) ;
328+
329+ const pluginEvents = (
330+ insightsPlugin as unknown as { events : Record < string , unknown > [ ] }
331+ ) . events ;
332+ const componentEvents = pluginEvents . filter (
333+ ( event : { type ?: string } ) => event . type === 'component'
334+ ) ;
335+ const viewIds = new Set (
336+ componentEvents . map ( ( event ) => event [ 'viewId' ] as string )
337+ ) ;
338+ const durations = componentEvents . map (
339+ ( event ) => event [ 'viewDurationMs' ] as number
340+ ) ;
341+
342+ expect ( viewIds . size ) . toBe ( 2 ) ;
343+ expect ( Math . max ( ...durations ) ) . toBeLessThan ( 4_000 ) ;
344+ } ) ;
345+ it ( 'should flush component view heartbeats with sendBeacon on page hide' , async ( ) => {
346+ const insightsPlugin = new NinetailedInsightsPlugin ( ) ;
347+ const ninetailed = setupNinetailedInstance ( [ insightsPlugin ] ) ;
348+ insightsPlugin . setCredentials ( {
349+ clientId : 'test' ,
350+ environment : 'development' ,
351+ } ) ;
352+ await ninetailed . identify ( 'test' ) ;
353+ jest . useFakeTimers ( ) ;
354+ const element = document . body . appendChild ( document . createElement ( 'div' ) ) ;
355+ ninetailed . observeElement ( {
356+ element,
357+ variant : { id : 'variant-id-1' } ,
358+ variantIndex : 0 ,
359+ } ) ;
360+
361+ intersect ( element , true ) ;
362+ jest . advanceTimersByTime ( 2_100 ) ;
363+ Object . defineProperty ( document , 'visibilityState' , {
364+ configurable : true ,
365+ value : 'hidden' ,
366+ } ) ;
367+ document . dispatchEvent ( new Event ( 'visibilitychange' ) ) ;
368+ jest . useRealTimers ( ) ;
369+
370+ await waitFor ( ( ) => {
371+ const beaconCalls = insightsApiClientSendEventBatchesMock . mock . calls
372+ . filter ( ( [ , options ] ) => options ?. useBeacon === true )
373+ . filter (
374+ ( [ batches ] ) =>
375+ ( batches as { events : Record < string , unknown > [ ] } [ ] ) . length > 0
376+ ) ;
377+
378+ expect ( beaconCalls . length ) . toBeGreaterThan ( 0 ) ;
379+ } ) ;
380+
381+ const beaconCalls = insightsApiClientSendEventBatchesMock . mock . calls . filter (
382+ ( [ , options ] ) => options ?. useBeacon === true
383+ ) ;
384+ const lastBeaconCall = beaconCalls [ beaconCalls . length - 1 ] ;
385+
386+ expect ( lastBeaconCall [ 1 ] ) . toEqual (
387+ expect . objectContaining ( {
388+ useBeacon : true ,
389+ } )
390+ ) ;
391+ expect (
392+ ( lastBeaconCall [ 0 ] [ 0 ] as { events : Record < string , unknown > [ ] } ) . events
393+ ) . toEqual (
394+ expect . arrayContaining ( [
395+ expect . objectContaining ( {
396+ type : 'component' ,
397+ viewId : expect . any ( String ) ,
398+ viewDurationMs : expect . any ( Number ) ,
399+ } ) ,
400+ ] )
401+ ) ;
402+ Object . defineProperty ( document , 'visibilityState' , {
403+ configurable : true ,
404+ value : 'visible' ,
405+ } ) ;
406+ } ) ;
246407 it ( 'should send component click events once the queue can be flushed' , async ( ) => {
247408 const insightsPlugin = new NinetailedInsightsPlugin ( ) ;
248409 const ninetailed = setupNinetailedInstance ( [ insightsPlugin ] ) ;
@@ -360,13 +521,13 @@ describe('NinetailedInsightsPlugin', () => {
360521 componentId : expect . any ( String ) ,
361522 variantIndex : expect . any ( Number ) ,
362523 hoverDurationMs : expect . any ( Number ) ,
363- componentHoverId : expect . any ( String ) ,
524+ hoverId : expect . any ( String ) ,
364525 } )
365526 ) ;
366527 } ) ;
367528 } ) ;
368529
369- it ( 'should generate a unique componentHoverId for each hover interaction' , async ( ) => {
530+ it ( 'should generate a unique hoverId for each hover interaction' , async ( ) => {
370531 const element = document . body . appendChild ( document . createElement ( 'div' ) ) ;
371532 ninetailed . observeElement (
372533 {
@@ -402,12 +563,10 @@ describe('NinetailedInsightsPlugin', () => {
402563 ) ;
403564
404565 expect ( hoverEvents . length ) . toBeGreaterThan ( 2 ) ;
405- const uniqueComponentHoverIds = new Set (
406- hoverEvents . map (
407- ( hoverEvent ) => hoverEvent [ 'componentHoverId' ] as string
408- )
566+ const uniqueHoverIds = new Set (
567+ hoverEvents . map ( ( hoverEvent ) => hoverEvent [ 'hoverId' ] as string )
409568 ) ;
410- expect ( uniqueComponentHoverIds . size ) . toBe ( 2 ) ;
569+ expect ( uniqueHoverIds . size ) . toBe ( 2 ) ;
411570 } ) ;
412571
413572 it ( 'should map component hover events to the correct component metadata when multiple elements are observed' , async ( ) => {
@@ -467,7 +626,7 @@ describe('NinetailedInsightsPlugin', () => {
467626 type : 'component_hover' ,
468627 componentId : expect . any ( String ) ,
469628 hoverDurationMs : expect . any ( Number ) ,
470- componentHoverId : expect . any ( String ) ,
629+ hoverId : expect . any ( String ) ,
471630 } ;
472631 expect ( hoverEvents ) . toEqual (
473632 expect . arrayContaining ( [
@@ -568,7 +727,7 @@ describe('NinetailedInsightsPlugin', () => {
568727 expect . objectContaining ( {
569728 type : 'component_hover' ,
570729 hoverDurationMs : expect . any ( Number ) ,
571- componentHoverId : expect . any ( String ) ,
730+ hoverId : expect . any ( String ) ,
572731 } ) ,
573732 ] )
574733 ) ;
0 commit comments