@@ -506,6 +506,220 @@ describe("paths.mjs exports", () => {
506506 expect ( elapsed ) . toBeGreaterThanOrEqual ( 40 )
507507 expect ( elapsed ) . toBeLessThan ( 150 )
508508 } )
509+
510+ describe ( "input validation" , ( ) => {
511+ it ( "should throw TypeError when fn is null" , async ( ) => {
512+ await expect ( retryOnTransientError ( null as unknown as ( ) => void ) ) . rejects . toThrow (
513+ TypeError ,
514+ )
515+ } )
516+
517+ it ( "should throw TypeError when fn is undefined" , async ( ) => {
518+ await expect ( retryOnTransientError ( undefined as unknown as ( ) => void ) ) . rejects . toThrow (
519+ TypeError ,
520+ )
521+ } )
522+
523+ it ( "should throw TypeError when fn is not a function" , async ( ) => {
524+ await expect (
525+ retryOnTransientError ( "not a function" as unknown as ( ) => void ) ,
526+ ) . rejects . toThrow ( TypeError )
527+ await expect ( retryOnTransientError ( 123 as unknown as ( ) => void ) ) . rejects . toThrow ( TypeError )
528+ await expect ( retryOnTransientError ( { } as unknown as ( ) => void ) ) . rejects . toThrow ( TypeError )
529+ } )
530+
531+ it ( "should handle retries: 0 (no retries, only initial attempt)" , async ( ) => {
532+ let callCount = 0
533+ await expect (
534+ retryOnTransientError (
535+ ( ) => {
536+ callCount ++
537+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
538+ throw err
539+ } ,
540+ { retries : 0 } ,
541+ ) ,
542+ ) . rejects . toThrow ( "EAGAIN" )
543+ expect ( callCount ) . toBe ( 1 ) // Only initial attempt, no retries
544+ } )
545+
546+ it ( "should throw undefined when retries is negative (loop never executes)" , async ( ) => {
547+ let callCount = 0
548+ // With retries: -1, loop condition (0 <= -1) is false, so loop never runs
549+ // lastError is undefined, so it throws undefined
550+ await expect (
551+ retryOnTransientError (
552+ ( ) => {
553+ callCount ++
554+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
555+ throw err
556+ } ,
557+ { retries : - 1 } ,
558+ ) ,
559+ ) . rejects . toBeUndefined ( )
560+ expect ( callCount ) . toBe ( 0 ) // Loop never runs
561+ } )
562+
563+ it ( "should throw undefined when retries is -5 (loop never executes)" , async ( ) => {
564+ let callCount = 0
565+ await expect (
566+ retryOnTransientError (
567+ ( ) => {
568+ callCount ++
569+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
570+ throw err
571+ } ,
572+ { retries : - 5 } ,
573+ ) ,
574+ ) . rejects . toBeUndefined ( )
575+ expect ( callCount ) . toBe ( 0 ) // Loop never runs
576+ } )
577+
578+ it ( "should handle initialDelayMs: 0 (no delay)" , async ( ) => {
579+ let callCount = 0
580+ const start = Date . now ( )
581+ await retryOnTransientError (
582+ ( ) => {
583+ callCount ++
584+ if ( callCount < 3 ) {
585+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
586+ throw err
587+ }
588+ return "success"
589+ } ,
590+ { initialDelayMs : 0 } ,
591+ )
592+ const elapsed = Date . now ( ) - start
593+ expect ( callCount ) . toBe ( 3 )
594+ // With 0ms delay, should complete very quickly
595+ expect ( elapsed ) . toBeLessThan ( 50 )
596+ } )
597+
598+ it ( "should handle negative initialDelayMs (treated as ~1ms delay by setTimeout)" , async ( ) => {
599+ let callCount = 0
600+ const start = Date . now ( )
601+ await retryOnTransientError (
602+ ( ) => {
603+ callCount ++
604+ if ( callCount < 2 ) {
605+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
606+ throw err
607+ }
608+ return "success"
609+ } ,
610+ { initialDelayMs : - 100 } ,
611+ )
612+ const elapsed = Date . now ( ) - start
613+ expect ( callCount ) . toBe ( 2 )
614+ // Negative delay is clamped to ~1ms by setTimeout, should complete quickly
615+ expect ( elapsed ) . toBeLessThan ( 50 )
616+ } )
617+
618+ it ( "should throw undefined when retries is NaN (loop never executes)" , async ( ) => {
619+ let callCount = 0
620+ // When retries is NaN, the loop condition (attempt <= retries) is always false
621+ // So the loop never runs and lastError (undefined) is thrown
622+ await expect (
623+ retryOnTransientError (
624+ ( ) => {
625+ callCount ++
626+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
627+ throw err
628+ } ,
629+ { retries : Number . NaN } ,
630+ ) ,
631+ ) . rejects . toBeUndefined ( )
632+ expect ( callCount ) . toBe ( 0 ) // Loop never runs
633+ } )
634+
635+ it ( "should handle non-numeric initialDelayMs (NaN becomes ~1ms delay)" , async ( ) => {
636+ let callCount = 0
637+ const start = Date . now ( )
638+ // When initialDelayMs is NaN, delay calculation produces NaN
639+ // setTimeout with NaN delay treats it as ~1ms
640+ await retryOnTransientError (
641+ ( ) => {
642+ callCount ++
643+ if ( callCount < 2 ) {
644+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
645+ throw err
646+ }
647+ return "success"
648+ } ,
649+ { initialDelayMs : Number . NaN } ,
650+ )
651+ const elapsed = Date . now ( ) - start
652+ expect ( callCount ) . toBe ( 2 )
653+ // NaN delay becomes ~1ms
654+ expect ( elapsed ) . toBeLessThan ( 50 )
655+ } )
656+
657+ it ( "should handle Infinity retries (up to a reasonable test limit)" , async ( ) => {
658+ let callCount = 0
659+ // Test with Infinity but succeed after a few attempts to avoid infinite loop
660+ await retryOnTransientError (
661+ ( ) => {
662+ callCount ++
663+ if ( callCount < 5 ) {
664+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
665+ throw err
666+ }
667+ return "success"
668+ } ,
669+ { retries : Number . POSITIVE_INFINITY , initialDelayMs : 1 } ,
670+ )
671+ expect ( callCount ) . toBe ( 5 )
672+ } )
673+
674+ it ( "should throw TypeError when options is null (cannot destructure)" , async ( ) => {
675+ // The function tries to destructure null, which throws TypeError
676+ await expect (
677+ retryOnTransientError (
678+ ( ) => "success" ,
679+ null as unknown as { retries ?: number ; initialDelayMs ?: number } ,
680+ ) ,
681+ ) . rejects . toThrow ( TypeError )
682+ } )
683+
684+ it ( "should handle options as undefined (uses defaults)" , async ( ) => {
685+ let callCount = 0
686+ const result = await retryOnTransientError ( ( ) => {
687+ callCount ++
688+ return "success"
689+ } , undefined )
690+ expect ( result ) . toBe ( "success" )
691+ expect ( callCount ) . toBe ( 1 )
692+ } )
693+
694+ it ( "should handle empty options object (uses defaults)" , async ( ) => {
695+ let callCount = 0
696+ await expect (
697+ retryOnTransientError ( ( ) => {
698+ callCount ++
699+ const err = Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
700+ throw err
701+ } , { } ) ,
702+ ) . rejects . toThrow ( "EAGAIN" )
703+ // Default retries is 3, so 4 total attempts
704+ expect ( callCount ) . toBe ( 4 )
705+ } )
706+
707+ it ( "should ignore extra unknown options properties" , async ( ) => {
708+ let callCount = 0
709+ const result = await retryOnTransientError (
710+ ( ) => {
711+ callCount ++
712+ return "success"
713+ } ,
714+ { retries : 1 , initialDelayMs : 10 , unknownOption : "ignored" } as {
715+ retries ?: number
716+ initialDelayMs ?: number
717+ } ,
718+ )
719+ expect ( result ) . toBe ( "success" )
720+ expect ( callCount ) . toBe ( 1 )
721+ } )
722+ } )
509723 } )
510724
511725 describe ( "parseFrontmatter" , ( ) => {
0 commit comments