@@ -14,6 +14,70 @@ class MockRequest {
1414// Replace global Request with our mock
1515global . Request = MockRequest as any
1616
17+ // Mock window with performance API
18+ function createMockWindow ( ) {
19+ const performanceEntries : PerformanceEntry [ ] = [ ]
20+ const observerCallbacks : Array < ( entries : PerformanceObserverEntryList ) => void > = [ ]
21+
22+ const mockWindow = {
23+ performance : {
24+ now : ( ) => Date . now ( ) ,
25+ getEntries : ( ) => performanceEntries ,
26+ getEntriesByName : ( name : string ) => performanceEntries . filter ( ( e : any ) => e . name === name ) ,
27+ clearResourceTimings : ( ) => {
28+ performanceEntries . length = 0
29+ } ,
30+ } ,
31+ PerformanceObserver : class {
32+ callback : ( entries : PerformanceObserverEntryList ) => void
33+ constructor ( callback : ( entries : PerformanceObserverEntryList ) => void ) {
34+ this . callback = callback
35+ }
36+ observe ( ) {
37+ observerCallbacks . push ( this . callback )
38+ }
39+ disconnect ( ) {
40+ const index = observerCallbacks . indexOf ( this . callback )
41+ if ( index > - 1 ) observerCallbacks . splice ( index , 1 )
42+ }
43+ } as any ,
44+ XMLHttpRequest : class {
45+ listeners : Map < string , Array < ( e : any ) => void > > = new Map ( )
46+ readyState = 0
47+ DONE = 4
48+ status = 200
49+ response = ''
50+ responseText = ''
51+
52+ open ( ) { }
53+ send ( ) { }
54+ setRequestHeader ( ) { }
55+ getAllResponseHeaders ( ) {
56+ return ''
57+ }
58+ addEventListener ( event : string , listener : ( e : any ) => void ) {
59+ if ( ! this . listeners . has ( event ) ) this . listeners . set ( event , [ ] )
60+ this . listeners . get ( event ) ! . push ( listener )
61+ }
62+ removeEventListener ( event : string , listener : ( e : any ) => void ) {
63+ const listeners = this . listeners . get ( event )
64+ if ( listeners ) {
65+ const index = listeners . indexOf ( listener )
66+ if ( index > - 1 ) listeners . splice ( index , 1 )
67+ }
68+ }
69+ getListenerCount ( event : string ) {
70+ return this . listeners . get ( event ) ?. length || 0
71+ }
72+ } as any ,
73+ fetch : async ( ) => new Response ( ) ,
74+ } as any
75+
76+ mockWindow . PerformanceObserver . supportedEntryTypes = [ 'navigation' , 'resource' ]
77+
78+ return { mockWindow, performanceEntries, observerCallbacks }
79+ }
80+
1781const blobUrlTestCases = [
1882 { url : 'blob:https://example.com/123' , expected : false } ,
1983 { url : new URL ( 'blob:https://example.com/123' ) , expected : false } ,
@@ -180,4 +244,141 @@ describe('network plugin', () => {
180244 } )
181245 } )
182246 } )
247+
248+ describe ( 'network observer lifecycle' , ( ) => {
249+ describe ( 'singleton initialization and cleanup' , ( ) => {
250+ it ( 'should initialize successfully on first call' , ( ) => {
251+ jest . isolateModules ( ( ) => {
252+ // eslint-disable-next-line @typescript-eslint/no-require-imports
253+ const { getRecordNetworkPlugin } = require ( '../../../../extensions/replay/external/network-plugin' )
254+ const { mockWindow, observerCallbacks } = createMockWindow ( )
255+ global . PerformanceObserver = mockWindow . PerformanceObserver
256+
257+ const plugin = getRecordNetworkPlugin ( )
258+ const cleanup = plugin . observer ( ( ) => { } , mockWindow , { } )
259+
260+ expect ( typeof cleanup ) . toBe ( 'function' )
261+ expect ( observerCallbacks . length ) . toBe ( 1 )
262+ } )
263+ } )
264+
265+ it ( 'should allow re-initialization after cleanup' , ( ) => {
266+ jest . isolateModules ( ( ) => {
267+ // eslint-disable-next-line @typescript-eslint/no-require-imports
268+ const { getRecordNetworkPlugin } = require ( '../../../../extensions/replay/external/network-plugin' )
269+ const { mockWindow, observerCallbacks } = createMockWindow ( )
270+ global . PerformanceObserver = mockWindow . PerformanceObserver
271+
272+ const plugin1 = getRecordNetworkPlugin ( )
273+ const cleanup1 = plugin1 . observer ( ( ) => { } , mockWindow , { } )
274+ expect ( observerCallbacks . length ) . toBe ( 1 )
275+
276+ cleanup1 ( )
277+ expect ( observerCallbacks . length ) . toBe ( 0 )
278+
279+ const plugin2 = getRecordNetworkPlugin ( )
280+ const cleanup2 = plugin2 . observer ( ( ) => { } , mockWindow , { } )
281+ expect ( observerCallbacks . length ) . toBe ( 1 )
282+
283+ cleanup2 ( )
284+ } )
285+ } )
286+
287+ it ( 'should handle multiple cleanup calls safely' , ( ) => {
288+ jest . isolateModules ( ( ) => {
289+ // eslint-disable-next-line @typescript-eslint/no-require-imports
290+ const { getRecordNetworkPlugin } = require ( '../../../../extensions/replay/external/network-plugin' )
291+ const { mockWindow, observerCallbacks } = createMockWindow ( )
292+ global . PerformanceObserver = mockWindow . PerformanceObserver
293+
294+ const plugin = getRecordNetworkPlugin ( )
295+ const cleanup = plugin . observer ( ( ) => { } , mockWindow , { } )
296+
297+ expect ( ( ) => {
298+ cleanup ( )
299+ cleanup ( )
300+ cleanup ( )
301+ } ) . not . toThrow ( )
302+
303+ expect ( observerCallbacks . length ) . toBe ( 0 )
304+ } )
305+ } )
306+ } )
307+
308+ describe ( 'XHR listener cleanup' , ( ) => {
309+ let mockWindow : any
310+ let xhr : any
311+
312+ beforeEach ( ( ) => {
313+ jest . isolateModules ( ( ) => {
314+ // eslint-disable-next-line @typescript-eslint/no-require-imports
315+ const { getRecordNetworkPlugin } = require ( '../../../../extensions/replay/external/network-plugin' )
316+ const mock = createMockWindow ( )
317+ mockWindow = mock . mockWindow
318+
319+ global . PerformanceObserver = mockWindow . PerformanceObserver
320+
321+ const plugin = getRecordNetworkPlugin ( { recordBody : true } )
322+ plugin . observer ( ( ) => { } , mockWindow , { recordBody : true } )
323+
324+ xhr = new mockWindow . XMLHttpRequest ( )
325+ } )
326+ } )
327+
328+ it ( 'should remove readystatechange listener on successful request' , ( ) => {
329+ xhr . open ( 'GET' , 'https://example.com' )
330+ xhr . send ( )
331+
332+ expect ( xhr . getListenerCount ( 'readystatechange' ) ) . toBeGreaterThan ( 0 )
333+
334+ xhr . readyState = xhr . DONE
335+ xhr . status = 200
336+ const listeners = xhr . listeners . get ( 'readystatechange' ) || [ ]
337+ listeners . forEach ( ( listener : any ) => listener ( ) )
338+
339+ expect ( xhr . getListenerCount ( 'readystatechange' ) ) . toBe ( 0 )
340+ } )
341+
342+ const failureEvents = [
343+ { event : 'error' , payload : new Error ( 'Network error' ) } ,
344+ { event : 'abort' , payload : { type : 'abort' } } ,
345+ { event : 'timeout' , payload : { type : 'timeout' } } ,
346+ ]
347+
348+ failureEvents . forEach ( ( { event, payload } ) => {
349+ it ( `should remove all listeners when XHR ${ event } s` , ( ) => {
350+ xhr . open ( 'GET' , 'https://example.com' )
351+ xhr . send ( )
352+
353+ const listeners = xhr . listeners . get ( event ) || [ ]
354+ listeners . forEach ( ( listener : any ) => listener ( payload ) )
355+
356+ expect ( xhr . getListenerCount ( 'readystatechange' ) ) . toBe ( 0 )
357+ expect ( xhr . getListenerCount ( 'error' ) ) . toBe ( 0 )
358+ expect ( xhr . getListenerCount ( 'abort' ) ) . toBe ( 0 )
359+ expect ( xhr . getListenerCount ( 'timeout' ) ) . toBe ( 0 )
360+ } )
361+ } )
362+
363+ it ( 'should not leak memory with multiple failed requests' , ( ) => {
364+ const xhrInstances = Array . from ( { length : 10 } , ( _ , i ) => {
365+ const testXhr = new mockWindow . XMLHttpRequest ( )
366+ testXhr . open ( 'GET' , `https://example.com/${ i } ` )
367+ testXhr . send ( )
368+
369+ const errorListeners = testXhr . listeners . get ( 'error' ) || [ ]
370+ errorListeners . forEach ( ( listener : any ) => listener ( new Error ( 'Network error' ) ) )
371+
372+ return testXhr
373+ } )
374+
375+ xhrInstances . forEach ( ( testXhr ) => {
376+ expect ( testXhr . getListenerCount ( 'readystatechange' ) ) . toBe ( 0 )
377+ expect ( testXhr . getListenerCount ( 'error' ) ) . toBe ( 0 )
378+ expect ( testXhr . getListenerCount ( 'abort' ) ) . toBe ( 0 )
379+ expect ( testXhr . getListenerCount ( 'timeout' ) ) . toBe ( 0 )
380+ } )
381+ } )
382+ } )
383+ } )
183384} )
0 commit comments