@@ -59,6 +59,10 @@ class MockReadableStreamDefaultReader {
5959 }
6060 return this . events [ this . currentEventIndex ++ ]
6161 }
62+
63+ releaseLock ( ) : void {
64+ // no-op for mock
65+ }
6266}
6367
6468const mockReader = new MockReadableStreamDefaultReader ( )
@@ -326,9 +330,7 @@ describe('main.ts', () => {
326330 await run ( )
327331
328332 expect ( core . setOutput ) . toHaveBeenCalledWith ( 'status' , status )
329-
330- // Reader issue triggers this, but only once
331- expect ( core . setFailed ) . toHaveBeenCalledTimes ( 1 )
333+ expect ( core . setFailed ) . not . toHaveBeenCalled ( )
332334 }
333335 )
334336 } )
@@ -373,9 +375,7 @@ describe('main.ts', () => {
373375 await run ( )
374376
375377 expect ( core . setOutput ) . not . toHaveBeenCalledWith ( 'status' , status )
376-
377- // Reader issue triggers this, but only once
378- expect ( core . setFailed ) . toHaveBeenCalledTimes ( 1 )
378+ expect ( core . setFailed ) . not . toHaveBeenCalled ( )
379379 }
380380 )
381381 } )
@@ -577,4 +577,154 @@ describe('main.ts', () => {
577577 )
578578 } )
579579 } )
580+
581+ describe ( 'Timeout' , ( ) => {
582+ it ( 'Should fail with timeout message when SSE reader hangs' , async ( ) => {
583+ // Use fake timers so we can advance the timeout instantly
584+ jest . useFakeTimers ( )
585+
586+ // Set timeout to 1 second
587+ core . getInput . mockImplementation ( ( name ) => {
588+ if ( name === 'apiKey' ) return mockApiKey
589+ if ( name === 'originUrl' ) return mockOriginUrl
590+ if ( name === 'suiteIds' ) return 'suite1,suite2'
591+ if ( name === 'failFast' ) return 'false'
592+ if ( name === 'block' ) return 'false'
593+ if ( name === 'timeout' ) return '1'
594+ return ''
595+ } )
596+
597+ // Track the abort signal so we can simulate AbortError when aborted
598+ let capturedSignal : AbortSignal | undefined
599+
600+ fetchMock . mockImplementation ( async ( url , init ) => {
601+ if ( url === `${ mockOriginUrl } /version` ) {
602+ return createMockResponse ( {
603+ ok : true ,
604+ json : async ( ) => ( { version : '1337' } )
605+ } )
606+ } else if ( url === `${ mockOriginUrl } /external/actions/trigger` ) {
607+ return createMockResponse ( {
608+ ok : true ,
609+ json : async ( ) => ( { run_id : mockRunId } )
610+ } )
611+ } else if (
612+ url === `${ mockOriginUrl } /external/actions/run/${ mockRunId } /events`
613+ ) {
614+ // Capture the abort signal from the request
615+ capturedSignal = ( init as RequestInit ) ?. signal ?? undefined
616+
617+ // Create a reader that blocks until aborted
618+ const hangingReader = {
619+ read : ( ) =>
620+ new Promise < { done : boolean ; value : Uint8Array } > (
621+ ( resolve , reject ) => {
622+ if ( capturedSignal ?. aborted ) {
623+ const err = new Error ( 'The operation was aborted' )
624+ err . name = 'AbortError'
625+ reject ( err )
626+ return
627+ }
628+ // Listen for abort to reject the promise
629+ capturedSignal ?. addEventListener ( 'abort' , ( ) => {
630+ const err = new Error ( 'The operation was aborted' )
631+ err . name = 'AbortError'
632+ reject ( err )
633+ } )
634+ // Never resolves on its own — simulates a hanging reader
635+ }
636+ ) ,
637+ releaseLock : ( ) => { }
638+ }
639+
640+ return createMockResponse ( {
641+ ok : true ,
642+ body : { getReader : ( ) => hangingReader }
643+ } )
644+ }
645+
646+ return createMockResponse ( {
647+ ok : false ,
648+ status : 404 ,
649+ text : async ( ) => 'Not found'
650+ } )
651+ } )
652+
653+ // Start run() — it will block on the hanging reader
654+ const runPromise = run ( )
655+
656+ // Advance timers past the 1-second timeout
657+ await jest . advanceTimersByTimeAsync ( 1500 )
658+
659+ await runPromise
660+
661+ expect ( core . setFailed ) . toHaveBeenCalledWith (
662+ 'Timed out after 1s waiting for test suite completion'
663+ )
664+
665+ jest . useRealTimers ( )
666+ } )
667+ } )
668+
669+ describe ( 'SSE buffer partial chunk parsing' , ( ) => {
670+ it ( 'Should correctly parse events split across multiple chunks' , async ( ) => {
671+ core . getInput . mockImplementation ( ( name ) => {
672+ if ( name === 'apiKey' ) return mockApiKey
673+ if ( name === 'originUrl' ) return mockOriginUrl
674+ if ( name === 'suiteIds' ) return 'suite1,suite2'
675+ if ( name === 'failFast' ) return 'false'
676+ if ( name === 'block' ) return 'false'
677+ return ''
678+ } )
679+
680+ fetchMock . mockImplementation ( async ( url ) => {
681+ if ( url === `${ mockOriginUrl } /version` ) {
682+ return createMockResponse ( {
683+ ok : true ,
684+ json : async ( ) => ( { version : '1337' } )
685+ } )
686+ } else if ( url === `${ mockOriginUrl } /external/actions/trigger` ) {
687+ return createMockResponse ( {
688+ ok : true ,
689+ json : async ( ) => ( { run_id : mockRunId } )
690+ } )
691+ } else if (
692+ url === `${ mockOriginUrl } /external/actions/run/${ mockRunId } /events`
693+ ) {
694+ const encoder = new TextEncoder ( )
695+ // Split the event mid-message across two chunks
696+ const splitReader = new MockReadableStreamDefaultReader ( )
697+ splitReader . setEvents ( [
698+ {
699+ done : false ,
700+ value : encoder . encode (
701+ 'event: test_suite_run.event\ndata: {"status":'
702+ )
703+ } ,
704+ {
705+ done : false ,
706+ value : encoder . encode ( ' "passed", "elapsed": 1.5}\n\n' )
707+ } ,
708+ { done : true , value : new Uint8Array ( ) }
709+ ] )
710+
711+ return createMockResponse ( {
712+ ok : true ,
713+ body : { getReader : ( ) => splitReader }
714+ } )
715+ }
716+
717+ return createMockResponse ( {
718+ ok : false ,
719+ status : 404 ,
720+ text : async ( ) => 'Not found'
721+ } )
722+ } )
723+
724+ await run ( )
725+
726+ expect ( core . setOutput ) . toHaveBeenCalledWith ( 'status' , 'passed' )
727+ expect ( core . setFailed ) . not . toHaveBeenCalled ( )
728+ } )
729+ } )
580730} )
0 commit comments