11/// <reference lib="dom" />
22
33import { expect } from '@jest/globals'
4- import { shouldRecordBody } from '../../../../extensions/replay/external/network-plugin'
4+ import {
5+ shouldRecordBody ,
6+ getRecordNetworkPlugin ,
7+ _resetNetworkObserverSingleton ,
8+ } from '../../../../extensions/replay/external/network-plugin'
59
610// Mock Request class since jsdom might not provide it
711class MockRequest {
@@ -14,6 +18,70 @@ class MockRequest {
1418// Replace global Request with our mock
1519global . Request = MockRequest as any
1620
21+ // Mock window with performance API
22+ function createMockWindow ( ) {
23+ const performanceEntries : PerformanceEntry [ ] = [ ]
24+ const observerCallbacks : Array < ( entries : PerformanceObserverEntryList ) => void > = [ ]
25+
26+ const mockWindow = {
27+ performance : {
28+ now : ( ) => Date . now ( ) ,
29+ getEntries : ( ) => performanceEntries ,
30+ getEntriesByName : ( name : string ) => performanceEntries . filter ( ( e : any ) => e . name === name ) ,
31+ clearResourceTimings : ( ) => {
32+ performanceEntries . length = 0
33+ } ,
34+ } ,
35+ PerformanceObserver : class {
36+ callback : ( entries : PerformanceObserverEntryList ) => void
37+ constructor ( callback : ( entries : PerformanceObserverEntryList ) => void ) {
38+ this . callback = callback
39+ }
40+ observe ( ) {
41+ observerCallbacks . push ( this . callback )
42+ }
43+ disconnect ( ) {
44+ const index = observerCallbacks . indexOf ( this . callback )
45+ if ( index > - 1 ) observerCallbacks . splice ( index , 1 )
46+ }
47+ } as any ,
48+ XMLHttpRequest : class {
49+ listeners : Map < string , Array < ( e : any ) => void > > = new Map ( )
50+ readyState = 0
51+ DONE = 4
52+ status = 200
53+ response = ''
54+ responseText = ''
55+
56+ open ( ) { }
57+ send ( ) { }
58+ setRequestHeader ( ) { }
59+ getAllResponseHeaders ( ) {
60+ return ''
61+ }
62+ addEventListener ( event : string , listener : ( e : any ) => void ) {
63+ if ( ! this . listeners . has ( event ) ) this . listeners . set ( event , [ ] )
64+ this . listeners . get ( event ) ! . push ( listener )
65+ }
66+ removeEventListener ( event : string , listener : ( e : any ) => void ) {
67+ const listeners = this . listeners . get ( event )
68+ if ( listeners ) {
69+ const index = listeners . indexOf ( listener )
70+ if ( index > - 1 ) listeners . splice ( index , 1 )
71+ }
72+ }
73+ getListenerCount ( event : string ) {
74+ return this . listeners . get ( event ) ?. length || 0
75+ }
76+ } as any ,
77+ fetch : async ( ) => new Response ( ) ,
78+ } as any
79+
80+ mockWindow . PerformanceObserver . supportedEntryTypes = [ 'navigation' , 'resource' ]
81+
82+ return { mockWindow, performanceEntries, observerCallbacks }
83+ }
84+
1785const blobUrlTestCases = [
1886 { url : 'blob:https://example.com/123' , expected : false } ,
1987 { url : new URL ( 'blob:https://example.com/123' ) , expected : false } ,
@@ -180,4 +248,179 @@ describe('network plugin', () => {
180248 } )
181249 } )
182250 } )
251+
252+ describe ( 'network observer lifecycle' , ( ) => {
253+ describe ( 'singleton initialization and cleanup' , ( ) => {
254+ let mockWindow : any
255+ let observerCallbacks : Array < ( entries : PerformanceObserverEntryList ) => void >
256+ let originalPerformanceObserver : any
257+
258+ beforeEach ( ( ) => {
259+ // Reset singleton state between tests
260+ _resetNetworkObserverSingleton ( )
261+
262+ const mock = createMockWindow ( )
263+ mockWindow = mock . mockWindow
264+ observerCallbacks = mock . observerCallbacks
265+
266+ // Mock global PerformanceObserver
267+ originalPerformanceObserver = global . PerformanceObserver
268+ global . PerformanceObserver = mockWindow . PerformanceObserver
269+ } )
270+
271+ afterEach ( ( ) => {
272+ // Restore original
273+ global . PerformanceObserver = originalPerformanceObserver
274+ _resetNetworkObserverSingleton ( )
275+ } )
276+
277+ it ( 'should initialize successfully on first call' , ( ) => {
278+ const plugin = getRecordNetworkPlugin ( )
279+ const cleanup = plugin . observer ( ( ) => { } , mockWindow , { } )
280+
281+ expect ( cleanup ) . toBeDefined ( )
282+ expect ( typeof cleanup ) . toBe ( 'function' )
283+ expect ( observerCallbacks . length ) . toBeGreaterThan ( 0 )
284+ } )
285+
286+ it ( 'should allow re-initialization after cleanup' , ( ) => {
287+ const plugin1 = getRecordNetworkPlugin ( )
288+ const cleanup1 = plugin1 . observer ( ( ) => { } , mockWindow , { } )
289+
290+ // Cleanup first initialization
291+ cleanup1 ( )
292+ expect ( observerCallbacks . length ) . toBe ( 0 )
293+
294+ // Should be able to initialize again
295+ const plugin2 = getRecordNetworkPlugin ( )
296+ const cleanup2 = plugin2 . observer ( ( ) => { } , mockWindow , { } )
297+
298+ expect ( cleanup2 ) . toBeDefined ( )
299+ expect ( typeof cleanup2 ) . toBe ( 'function' )
300+ expect ( observerCallbacks . length ) . toBeGreaterThan ( 0 )
301+
302+ cleanup2 ( )
303+ } )
304+
305+ it ( 'should handle multiple cleanup calls safely' , ( ) => {
306+ const plugin = getRecordNetworkPlugin ( )
307+ const cleanup = plugin . observer ( ( ) => { } , mockWindow , { } )
308+
309+ // Multiple cleanup calls should not throw
310+ expect ( ( ) => {
311+ cleanup ( )
312+ cleanup ( )
313+ cleanup ( )
314+ } ) . not . toThrow ( )
315+
316+ expect ( observerCallbacks . length ) . toBe ( 0 )
317+ } )
318+
319+ it ( 'should create new observers after cleanup and re-init' , ( ) => {
320+ const callbacks : any [ ] = [ ]
321+
322+ const plugin1 = getRecordNetworkPlugin ( )
323+ const cleanup1 = plugin1 . observer ( ( data ) => callbacks . push ( { cycle : 1 , data } ) , mockWindow , { } )
324+ const observerCount1 = observerCallbacks . length
325+
326+ cleanup1 ( )
327+
328+ const plugin2 = getRecordNetworkPlugin ( )
329+ const cleanup2 = plugin2 . observer ( ( data ) => callbacks . push ( { cycle : 2 , data } ) , mockWindow , { } )
330+ const observerCount2 = observerCallbacks . length
331+
332+ // Should have created new observer
333+ expect ( observerCount2 ) . toBeGreaterThan ( 0 )
334+ expect ( observerCount2 ) . toBe ( observerCount1 )
335+
336+ cleanup2 ( )
337+ } )
338+ } )
339+
340+ describe ( 'XHR listener cleanup' , ( ) => {
341+ let mockWindow : any
342+ let xhr : any
343+ let originalPerformanceObserver : any
344+
345+ beforeEach ( ( ) => {
346+ // Reset singleton state between tests
347+ _resetNetworkObserverSingleton ( )
348+
349+ const mock = createMockWindow ( )
350+ mockWindow = mock . mockWindow
351+
352+ // Save original and mock global PerformanceObserver
353+ originalPerformanceObserver = global . PerformanceObserver
354+ global . PerformanceObserver = mockWindow . PerformanceObserver
355+
356+ // Initialize plugin with recordBody enabled to trigger XHR wrapping
357+ const plugin = getRecordNetworkPlugin ( { recordBody : true } )
358+ plugin . observer ( ( ) => { } , mockWindow , { recordBody : true } )
359+
360+ // Create XHR instance (will be wrapped)
361+ xhr = new mockWindow . XMLHttpRequest ( )
362+ } )
363+
364+ afterEach ( ( ) => {
365+ // Restore original
366+ global . PerformanceObserver = originalPerformanceObserver
367+ _resetNetworkObserverSingleton ( )
368+ } )
369+
370+ it ( 'should remove readystatechange listener on successful request' , ( ) => {
371+ xhr . open ( 'GET' , 'https://example.com' )
372+ xhr . send ( )
373+
374+ expect ( xhr . getListenerCount ( 'readystatechange' ) ) . toBeGreaterThan ( 0 )
375+
376+ xhr . readyState = xhr . DONE
377+ xhr . status = 200
378+ const listeners = xhr . listeners . get ( 'readystatechange' ) || [ ]
379+ listeners . forEach ( ( listener : any ) => listener ( ) )
380+
381+ expect ( xhr . getListenerCount ( 'readystatechange' ) ) . toBe ( 0 )
382+ } )
383+
384+ const failureEvents = [
385+ { event : 'error' , payload : new Error ( 'Network error' ) } ,
386+ { event : 'abort' , payload : { type : 'abort' } } ,
387+ { event : 'timeout' , payload : { type : 'timeout' } } ,
388+ ]
389+
390+ failureEvents . forEach ( ( { event, payload } ) => {
391+ it ( `should remove all listeners when XHR ${ event } s` , ( ) => {
392+ xhr . open ( 'GET' , 'https://example.com' )
393+ xhr . send ( )
394+
395+ const listeners = xhr . listeners . get ( event ) || [ ]
396+ listeners . forEach ( ( listener : any ) => listener ( payload ) )
397+
398+ expect ( xhr . getListenerCount ( 'readystatechange' ) ) . toBe ( 0 )
399+ expect ( xhr . getListenerCount ( 'error' ) ) . toBe ( 0 )
400+ expect ( xhr . getListenerCount ( 'abort' ) ) . toBe ( 0 )
401+ expect ( xhr . getListenerCount ( 'timeout' ) ) . toBe ( 0 )
402+ } )
403+ } )
404+
405+ it ( 'should not leak memory with multiple failed requests' , ( ) => {
406+ const xhrInstances = Array . from ( { length : 10 } , ( _ , i ) => {
407+ const testXhr = new mockWindow . XMLHttpRequest ( )
408+ testXhr . open ( 'GET' , `https://example.com/${ i } ` )
409+ testXhr . send ( )
410+
411+ const errorListeners = testXhr . listeners . get ( 'error' ) || [ ]
412+ errorListeners . forEach ( ( listener : any ) => listener ( new Error ( 'Network error' ) ) )
413+
414+ return testXhr
415+ } )
416+
417+ xhrInstances . forEach ( ( testXhr ) => {
418+ expect ( testXhr . getListenerCount ( 'readystatechange' ) ) . toBe ( 0 )
419+ expect ( testXhr . getListenerCount ( 'error' ) ) . toBe ( 0 )
420+ expect ( testXhr . getListenerCount ( 'abort' ) ) . toBe ( 0 )
421+ expect ( testXhr . getListenerCount ( 'timeout' ) ) . toBe ( 0 )
422+ } )
423+ } )
424+ } )
425+ } )
183426} )
0 commit comments