@@ -626,6 +626,28 @@ describe("createHotPollCoordinator", () => {
626626 } ) ;
627627 } ) ;
628628
629+ it ( "calls pushError when cycle throws" , async ( ) => {
630+ const onHotData = vi . fn ( ) ;
631+ // Make getClient() throw (now inside the try block) to trigger the catch path
632+ mockGetClient . mockImplementation ( ( ) => { throw new Error ( "auth crash" ) ; } ) ;
633+
634+ rebuildHotSets ( {
635+ ...emptyData ,
636+ workflowRuns : [ makeWorkflowRun ( { id : 1 , status : "in_progress" , conclusion : null , repoFullName : "o/r" } ) ] ,
637+ } ) ;
638+
639+ const { pushError } = await import ( "../../src/app/lib/errors" ) ;
640+ ( pushError as ReturnType < typeof vi . fn > ) . mockClear ( ) ;
641+
642+ await createRoot ( async ( dispose ) => {
643+ createHotPollCoordinator ( ( ) => 10 , onHotData ) ;
644+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
645+ expect ( pushError ) . toHaveBeenCalledWith ( "hot-poll" , "auth crash" , true ) ;
646+ expect ( onHotData ) . not . toHaveBeenCalled ( ) ;
647+ dispose ( ) ;
648+ } ) ;
649+ } ) ;
650+
629651 it ( "does not schedule when interval is 0" , async ( ) => {
630652 const onHotData = vi . fn ( ) ;
631653 mockGetClient . mockReturnValue ( makeOctokit ( ) ) ;
@@ -782,6 +804,52 @@ describe("fetchHotData eviction edge cases", () => {
782804 mockGetClient . mockReset ( ) ;
783805 } ) ;
784806
807+ it ( "evicts one PR while retaining the other in a two-PR hot set" , async ( ) => {
808+ let callCount = 0 ;
809+ const graphqlFn = vi . fn ( ( ) => {
810+ callCount ++ ;
811+ if ( callCount === 1 ) {
812+ // First fetch: PR 1 resolved (success), PR 2 still pending
813+ return Promise . resolve ( {
814+ nodes : [
815+ { databaseId : 1 , state : "OPEN" , mergeStateStatus : "CLEAN" , reviewDecision : null , commits : { nodes : [ { commit : { statusCheckRollup : { state : "SUCCESS" } } } ] } } ,
816+ { databaseId : 2 , state : "OPEN" , mergeStateStatus : "CLEAN" , reviewDecision : null , commits : { nodes : [ { commit : { statusCheckRollup : { state : "PENDING" } } } ] } } ,
817+ ] ,
818+ rateLimit : { remaining : 4999 , resetAt : "2026-01-01T00:00:00Z" } ,
819+ } ) ;
820+ }
821+ // Second fetch: only PR 2 should be queried
822+ return Promise . resolve ( {
823+ nodes : [
824+ { databaseId : 2 , state : "OPEN" , mergeStateStatus : "CLEAN" , reviewDecision : null , commits : { nodes : [ { commit : { statusCheckRollup : { state : "SUCCESS" } } } ] } } ,
825+ ] ,
826+ rateLimit : { remaining : 4999 , resetAt : "2026-01-01T00:00:00Z" } ,
827+ } ) ;
828+ } ) ;
829+ const octokit = makeOctokit ( undefined , graphqlFn ) ;
830+ mockGetClient . mockReturnValue ( octokit ) ;
831+
832+ rebuildHotSets ( {
833+ ...emptyData ,
834+ pullRequests : [
835+ makePullRequest ( { id : 1 , checkStatus : "pending" , nodeId : "PR_one" } ) ,
836+ makePullRequest ( { id : 2 , checkStatus : "pending" , nodeId : "PR_two" } ) ,
837+ ] ,
838+ } ) ;
839+
840+ // First fetch — PR 1 resolves, PR 2 stays pending
841+ const first = await fetchHotData ( ) ;
842+ expect ( first . prUpdates . size ) . toBe ( 2 ) ;
843+
844+ // Second fetch — only PR 2 should be queried (PR 1 was evicted)
845+ const second = await fetchHotData ( ) ;
846+ expect ( second . prUpdates . size ) . toBe ( 1 ) ;
847+ expect ( second . prUpdates . has ( 2 ) ) . toBe ( true ) ;
848+ // Verify graphql was called with only PR_two's nodeId
849+ const secondCallArgs = graphqlFn . mock . calls [ 1 ] as unknown as [ string , { ids : string [ ] } ] ;
850+ expect ( secondCallArgs [ 1 ] . ids ) . toEqual ( [ "PR_two" ] ) ;
851+ } ) ;
852+
785853 it ( "evicts PRs when state is MERGED even with pending checkStatus" , async ( ) => {
786854 const graphqlFn = vi . fn ( ( ) => Promise . resolve ( {
787855 nodes : [ {
0 commit comments