@@ -68,6 +68,13 @@ function createWsSendCommandMock(
6868 handler ?: ( op : string , args : unknown ) => Promise < unknown > | unknown
6969) {
7070 return vi . fn ( ) . mockImplementation ( async ( op : string , args : unknown ) => {
71+ if ( handler ) {
72+ const handled = await handler ( op , args ) ;
73+ if ( handled !== undefined ) {
74+ return handled ;
75+ }
76+ }
77+
7178 if ( op === "activation.claim" ) {
7279 return {
7380 active : true ,
@@ -80,10 +87,6 @@ function createWsSendCommandMock(
8087 return { ok : true } ;
8188 }
8289
83- if ( handler ) {
84- return await handler ( op , args ) ;
85- }
86-
8790 return undefined ;
8891 } ) ;
8992}
@@ -562,6 +565,96 @@ describe("AppProviders lifecycle recovery", () => {
562565 ) . toHaveLength ( 0 ) ;
563566 } ) ;
564567
568+ it ( "does not gate activation when websocket reconnect fails" , async ( ) => {
569+ const store = createStore ( ) ;
570+ wsState . client ! . connect = vi . fn ( ) . mockRejectedValue ( new Error ( "connect failed" ) ) ;
571+
572+ renderProviders ( store ) ;
573+
574+ await vi . waitFor ( ( ) => {
575+ expect ( wsState . client ?. connect ) . toHaveBeenCalled ( ) ;
576+ } ) ;
577+
578+ await vi . waitFor ( ( ) => {
579+ expect ( store . get ( activationStatusAtom ) ) . not . toBe ( "gated" ) ;
580+ expect ( store . get ( activationReasonAtom ) ) . toBeNull ( ) ;
581+ } ) ;
582+ } ) ;
583+
584+ it ( "does not gate activation when activation.claim fails" , async ( ) => {
585+ const store = createStore ( ) ;
586+ wsState . client ! . sendCommand = createWsSendCommandMock ( async ( op : string ) => {
587+ if ( op === "activation.claim" ) {
588+ throw new Error ( "claim failed" ) ;
589+ }
590+
591+ return undefined ;
592+ } ) ;
593+
594+ renderProviders ( store ) ;
595+
596+ await vi . waitFor ( ( ) => {
597+ expect ( wsState . client ?. connect ) . toHaveBeenCalled ( ) ;
598+ } ) ;
599+
600+ act ( ( ) => {
601+ wsState . client ?. statusHandler ?.( "connected" ) ;
602+ } ) ;
603+
604+ await vi . waitFor ( ( ) => {
605+ expect ( store . get ( activationStatusAtom ) ) . not . toBe ( "gated" ) ;
606+ expect ( store . get ( activationReasonAtom ) ) . toBeNull ( ) ;
607+ } ) ;
608+ } ) ;
609+
610+ it ( "retries activation.claim after a transient failure while connected" , async ( ) => {
611+ const store = createStore ( ) ;
612+ let claimAttempts = 0 ;
613+ vi . useFakeTimers ( ) ;
614+ wsState . client ! . sendCommand = createWsSendCommandMock ( async ( op : string ) => {
615+ if ( op === "activation.claim" ) {
616+ claimAttempts += 1 ;
617+ if ( claimAttempts === 1 ) {
618+ throw new Error ( "claim failed" ) ;
619+ }
620+
621+ return {
622+ active : true ,
623+ generation : 2 ,
624+ recoveryMode : "grace_recover" ,
625+ } ;
626+ }
627+
628+ return undefined ;
629+ } ) ;
630+
631+ renderProviders ( store ) ;
632+
633+ await vi . waitFor ( ( ) => {
634+ expect ( wsState . client ?. connect ) . toHaveBeenCalled ( ) ;
635+ } ) ;
636+
637+ act ( ( ) => {
638+ wsState . client ?. statusHandler ?.( "connected" ) ;
639+ } ) ;
640+
641+ await vi . waitFor ( ( ) => {
642+ expect ( claimAttempts ) . toBe ( 1 ) ;
643+ expect ( store . get ( activationStatusAtom ) ) . toBe ( "idle" ) ;
644+ } ) ;
645+
646+ await act ( async ( ) => {
647+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
648+ } ) ;
649+
650+ await vi . waitFor ( ( ) => {
651+ expect ( claimAttempts ) . toBe ( 2 ) ;
652+ expect ( store . get ( activationStatusAtom ) ) . toBe ( "active" ) ;
653+ expect ( store . get ( activationGenerationAtom ) ) . toBe ( 2 ) ;
654+ expect ( store . get ( activationReasonAtom ) ) . toBeNull ( ) ;
655+ } ) ;
656+ } ) ;
657+
565658 it ( "disconnects and gates when activation.revoked is received" , async ( ) => {
566659 const store = createStore ( ) ;
567660 seedWorkspaces ( store , [ "ws-1" ] , "ws-1" ) ;
0 commit comments