@@ -25,6 +25,10 @@ const MEMORY_THRESHOLDS = {
2525 cacheMemory : 100 , // MB for cache alone
2626 leakTolerance : 15 , // MB acceptable leak after GC (increased for test stability)
2727} as const ;
28+ const LEAK_SAMPLE_COUNT = 5 ;
29+ const hasExposedGC = typeof global . gc === 'function' ;
30+ const gcOnly = hasExposedGC ? it : it . skip ;
31+ let hasWarnedMissingGc = false ;
2832
2933/**
3034 * Test fixture paths
@@ -92,10 +96,57 @@ function forceGC(): void {
9296 if ( global . gc ) {
9397 global . gc ( ) ;
9498 } else {
95- console . warn ( 'Garbage collection not available. Run tests with --expose-gc flag.' ) ;
99+ if ( ! hasWarnedMissingGc ) {
100+ hasWarnedMissingGc = true ;
101+ console . warn ( 'Garbage collection not available. Run tests with --expose-gc flag.' ) ;
102+ }
96103 }
97104}
98105
106+ /**
107+ * Compute median of numeric samples.
108+ *
109+ * @param values - Numeric sample values
110+ * @returns Median value
111+ */
112+ function median ( values : number [ ] ) : number {
113+ const sorted = [ ...values ] . sort ( ( a , b ) => a - b ) ;
114+ const middle = Math . floor ( sorted . length / 2 ) ;
115+
116+ return sorted . length % 2 === 0 ? ( sorted [ middle - 1 ] + sorted [ middle ] ) / 2 : sorted [ middle ] ;
117+ }
118+
119+ /**
120+ * Capture repeated heap deltas for a memory-sensitive operation.
121+ *
122+ * @param operation - Workload to profile between baseline and post-GC snapshots
123+ * @param sampleCount - Number of repeated samples to collect
124+ * @returns Sample deltas and median delta (MB)
125+ */
126+ function sampleHeapDeltas (
127+ operation : ( ) => void ,
128+ sampleCount = LEAK_SAMPLE_COUNT ,
129+ ) : { samples : number [ ] ; median : number } {
130+ const samples : number [ ] = [ ] ;
131+
132+ for ( let i = 0 ; i < sampleCount ; i ++ ) {
133+ forceGC ( ) ;
134+ const baseline = captureMemorySnapshot ( ) ;
135+
136+ operation ( ) ;
137+
138+ forceGC ( ) ;
139+ const afterOperation = captureMemorySnapshot ( ) ;
140+
141+ samples . push ( calculateMemoryDelta ( baseline , afterOperation ) ) ;
142+ }
143+
144+ return {
145+ samples,
146+ median : median ( samples ) ,
147+ } ;
148+ }
149+
99150/**
100151 * Load PM JSON fixture
101152 *
@@ -341,95 +392,65 @@ describe('Memory Profiling', () => {
341392 } ) ;
342393
343394 describe ( 'Memory Leak Detection' , ( ) => {
344- it ( 'should release memory after 10 render cycles' , ( ) => {
395+ gcOnly ( 'should release memory after 10 render cycles' , ( ) => {
345396 const baseDoc = loadPMJsonFixture ( FIXTURES . basic ) ;
346397 const doc = expandDocumentToPages ( baseDoc , 50 ) ;
347398
348- forceGC ( ) ;
349- const baseline = captureMemorySnapshot ( ) ;
350-
351- // Perform 10 render cycles
352- for ( let cycle = 0 ; cycle < 10 ; cycle ++ ) {
353- // Create blocks and simulate layout
354- const { blocks } = toFlowBlocks ( doc ) ;
355-
356- // Simulate render operations
357- const renderData = blocks . map ( ( block ) => ( {
358- block,
359- rendered : true ,
360- } ) ) ;
361-
362- // Data goes out of scope at end of iteration
363- }
364-
365- // Force GC after cycles
366- forceGC ( ) ;
367- const afterCycles = captureMemorySnapshot ( ) ;
368-
369- const memoryLeak = calculateMemoryDelta ( baseline , afterCycles ) ;
399+ const { samples, median : memoryLeak } = sampleHeapDeltas ( ( ) => {
400+ // Perform 10 render cycles
401+ for ( let cycle = 0 ; cycle < 10 ; cycle ++ ) {
402+ // Create blocks and simulate layout allocations
403+ void toFlowBlocks ( doc ) . blocks . map ( ( block ) => ( {
404+ block,
405+ rendered : true ,
406+ } ) ) ;
407+ }
408+ } ) ;
370409
371410 console . log ( 'Memory Leak Test (10 cycles):' ) ;
372- console . log ( ` Baseline: ${ formatBytes ( baseline . heapUsed ) } ` ) ;
373- console . log ( ` After 10 cycles + GC: ${ formatBytes ( afterCycles . heapUsed ) } ` ) ;
374- console . log ( ` Leak: ${ memoryLeak . toFixed ( 1 ) } MB` ) ;
411+ console . log ( ` Samples: ${ samples . map ( ( value ) => `${ value . toFixed ( 1 ) } MB` ) . join ( ', ' ) } ` ) ;
412+ console . log ( ` Median leak: ${ memoryLeak . toFixed ( 1 ) } MB` ) ;
375413 console . log ( ` Tolerance: ${ MEMORY_THRESHOLDS . leakTolerance } MB` ) ;
376414
377415 // Allow small amount of retained memory
378416 expect ( memoryLeak ) . toBeLessThan ( MEMORY_THRESHOLDS . leakTolerance ) ;
379417 } ) ;
380418
381- it ( 'should not retain references after document unload' , ( ) => {
382- forceGC ( ) ;
383- const baseline = captureMemorySnapshot ( ) ;
384-
385- // Load, process, then release in scope
386- {
387- const baseDoc = loadPMJsonFixture ( FIXTURES . basic ) ;
388- const largeDoc = expandDocumentToPages ( baseDoc , 100 ) ;
389- const { blocks } = toFlowBlocks ( largeDoc ) ;
390-
391- // Simulate full layout
392- const layoutData = blocks . map ( ( b ) => ( { block : b , layout : { } } ) ) ;
393-
394- // All data should be released when scope exits
395- }
396-
397- // Force GC
398- forceGC ( ) ;
399- const afterUnload = captureMemorySnapshot ( ) ;
419+ gcOnly ( 'should not retain references after document unload' , ( ) => {
420+ const { samples, median : retained } = sampleHeapDeltas ( ( ) => {
421+ // Load, process, then release in scope
422+ {
423+ const baseDoc = loadPMJsonFixture ( FIXTURES . basic ) ;
424+ const largeDoc = expandDocumentToPages ( baseDoc , 100 ) ;
425+ const { blocks } = toFlowBlocks ( largeDoc ) ;
400426
401- const retained = calculateMemoryDelta ( baseline , afterUnload ) ;
427+ // Simulate full layout allocations
428+ void blocks . map ( ( block ) => ( { block, layout : { } } ) ) ;
429+ }
430+ } ) ;
402431
403432 console . log ( 'Document Unload Test:' ) ;
404- console . log ( ` Baseline: ${ formatBytes ( baseline . heapUsed ) } ` ) ;
405- console . log ( ` After unload + GC: ${ formatBytes ( afterUnload . heapUsed ) } ` ) ;
406- console . log ( ` Retained: ${ retained . toFixed ( 1 ) } MB` ) ;
433+ console . log ( ` Samples: ${ samples . map ( ( value ) => `${ value . toFixed ( 1 ) } MB` ) . join ( ', ' ) } ` ) ;
434+ console . log ( ` Median retained: ${ retained . toFixed ( 1 ) } MB` ) ;
407435
408436 expect ( retained ) . toBeLessThan ( MEMORY_THRESHOLDS . leakTolerance ) ;
409437 } ) ;
410438
411- it ( 'should handle rapid load/unload cycles without accumulation' , ( ) => {
439+ gcOnly ( 'should handle rapid load/unload cycles without accumulation' , ( ) => {
412440 const baseDoc = loadPMJsonFixture ( FIXTURES . basic ) ;
413441 const doc = expandDocumentToPages ( baseDoc , 20 ) ;
414442
415- forceGC ( ) ;
416- const baseline = captureMemorySnapshot ( ) ;
417-
418- // Perform 50 rapid load/unload cycles
419- for ( let i = 0 ; i < 50 ; i ++ ) {
420- const { blocks } = toFlowBlocks ( doc ) ;
421- // Immediately release
422- }
423-
424- forceGC ( ) ;
425- const afterCycles = captureMemorySnapshot ( ) ;
426-
427- const accumulated = calculateMemoryDelta ( baseline , afterCycles ) ;
443+ const { samples, median : accumulated } = sampleHeapDeltas ( ( ) => {
444+ // Perform 50 rapid load/unload cycles
445+ for ( let i = 0 ; i < 50 ; i ++ ) {
446+ void toFlowBlocks ( doc ) . blocks ;
447+ // Immediately release
448+ }
449+ } ) ;
428450
429451 console . log ( 'Rapid Load/Unload Test (50 cycles):' ) ;
430- console . log ( ` Baseline: ${ formatBytes ( baseline . heapUsed ) } ` ) ;
431- console . log ( ` After cycles + GC: ${ formatBytes ( afterCycles . heapUsed ) } ` ) ;
432- console . log ( ` Accumulated: ${ accumulated . toFixed ( 1 ) } MB` ) ;
452+ console . log ( ` Samples: ${ samples . map ( ( value ) => `${ value . toFixed ( 1 ) } MB` ) . join ( ', ' ) } ` ) ;
453+ console . log ( ` Median accumulated: ${ accumulated . toFixed ( 1 ) } MB` ) ;
433454
434455 // Should not accumulate significant memory
435456 expect ( accumulated ) . toBeLessThan ( MEMORY_THRESHOLDS . leakTolerance ) ;
0 commit comments