@@ -2,8 +2,14 @@ import type { Workspace } from "@coder-studio/core";
22import { act , render } from "@testing-library/react" ;
33import { createStore , Provider } from "jotai" ;
44import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
5+ import {
6+ activationGenerationAtom ,
7+ activationReasonAtom ,
8+ activationStatusAtom ,
9+ } from "../atoms/activation" ;
510import { authenticatedAtom } from "../atoms/app-ui" ;
611import { authEnabledAtom , connectionStatusAtom } from "../atoms/connection" ;
12+ import { sessionsAtom } from "../atoms/sessions" ;
713import {
814 activeWorkspaceIdAtom ,
915 workspaceOrderAtom ,
@@ -12,9 +18,11 @@ import {
1218} from "../atoms/workspaces" ;
1319import { terminalPreferencesAtom } from "../features/terminal-panel/preferences" ;
1420import {
21+ fileTreeAtomFamily ,
1522 fileTreeStaleAtomFamily ,
1623 gitBranchListAtomFamily ,
1724 gitStateAtomFamily ,
25+ loadedDirsAtomFamily ,
1826 worktreeListAtomFamily ,
1927} from "../features/workspace/atoms" ;
2028import { AppProviders , resetAppProvidersSingletonsForTests } from "./providers" ;
@@ -56,6 +64,30 @@ function renderProviders(store = createStore()) {
5664 return { store, ...rendered } ;
5765}
5866
67+ function createWsSendCommandMock (
68+ handler ?: ( op : string , args : unknown ) => Promise < unknown > | unknown
69+ ) {
70+ return vi . fn ( ) . mockImplementation ( async ( op : string , args : unknown ) => {
71+ if ( op === "activation.claim" ) {
72+ return {
73+ active : true ,
74+ generation : 1 ,
75+ recoveryMode : "fresh" ,
76+ } ;
77+ }
78+
79+ if ( op === "activation.heartbeat" || op === "activation.release" ) {
80+ return { ok : true } ;
81+ }
82+
83+ if ( handler ) {
84+ return await handler ( op , args ) ;
85+ }
86+
87+ return undefined ;
88+ } ) ;
89+ }
90+
5991function setVisibilityState ( value : "visible" | "hidden" ) {
6092 Object . defineProperty ( document , "visibilityState" , {
6193 configurable : true ,
@@ -128,7 +160,7 @@ describe("AppProviders lifecycle recovery", () => {
128160 } ) ,
129161 getStatus : vi . fn ( ( ) => "disconnected" ) ,
130162 recoverConnection : vi . fn ( ) ,
131- sendCommand : vi . fn ( ) . mockResolvedValue ( undefined ) ,
163+ sendCommand : createWsSendCommandMock ( ) ,
132164 } ;
133165 } ) ;
134166
@@ -452,6 +484,151 @@ describe("AppProviders lifecycle recovery", () => {
452484 } ) ;
453485 } ) ;
454486
487+ it ( "claims activation when the websocket becomes connected" , async ( ) => {
488+ const store = createStore ( ) ;
489+ setVisibilityState ( "visible" ) ;
490+
491+ renderProviders ( store ) ;
492+
493+ await vi . waitFor ( ( ) => {
494+ expect ( wsState . client ?. connect ) . toHaveBeenCalled ( ) ;
495+ } ) ;
496+
497+ act ( ( ) => {
498+ wsState . client ?. statusHandler ?.( "connected" ) ;
499+ } ) ;
500+
501+ await vi . waitFor ( ( ) => {
502+ const claimCalls =
503+ wsState . client ?. sendCommand ?. mock . calls . filter ( ( [ op ] ) => op === "activation.claim" ) ?? [ ] ;
504+ expect ( claimCalls . length ) . toBeGreaterThan ( 0 ) ;
505+ expect ( claimCalls [ 0 ] ?. [ 1 ] ) . toEqual (
506+ expect . objectContaining ( {
507+ clientInstanceId : expect . any ( String ) ,
508+ } )
509+ ) ;
510+ expect ( store . get ( activationStatusAtom ) ) . toBe ( "active" ) ;
511+ expect ( store . get ( activationGenerationAtom ) ) . toBe ( 1 ) ;
512+ expect ( store . get ( activationReasonAtom ) ) . toBeNull ( ) ;
513+ } ) ;
514+ } ) ;
515+
516+ it ( "disconnects and gates when activation.revoked is received" , async ( ) => {
517+ const store = createStore ( ) ;
518+ seedWorkspaces ( store , [ "ws-1" ] , "ws-1" ) ;
519+ act ( ( ) => {
520+ store . set ( activationStatusAtom , "active" ) ;
521+ store . set ( activationGenerationAtom , 1 ) ;
522+ store . set ( activationReasonAtom , null ) ;
523+ store . set ( gitStateAtomFamily ( "ws-1" ) , {
524+ branch : "feature/test" ,
525+ ahead : 1 ,
526+ behind : 0 ,
527+ modified : [ ] ,
528+ staged : [ ] ,
529+ untracked : [ ] ,
530+ deleted : [ ] ,
531+ } ) ;
532+ store . set ( gitBranchListAtomFamily ( "ws-1" ) , {
533+ current : "feature/test" ,
534+ branches : [ ] ,
535+ loading : false ,
536+ } ) ;
537+ store . set ( fileTreeAtomFamily ( "ws-1" ) , new Map ( [ [ "." , [ ] ] ] ) ) ;
538+ store . set ( loadedDirsAtomFamily ( "ws-1" ) , new Set ( [ "src" ] ) ) ;
539+ store . set ( worktreeListAtomFamily ( "ws-1" ) , {
540+ items : [ ] ,
541+ loading : false ,
542+ lastLoadedAt : Date . now ( ) ,
543+ } ) ;
544+ store . set ( fileTreeStaleAtomFamily ( "ws-1" ) , true ) ;
545+ store . set ( sessionsAtom , {
546+ "session-1" : {
547+ id : "session-1" ,
548+ workspaceId : "ws-1" ,
549+ terminalId : "terminal-1" ,
550+ providerId : "codex" ,
551+ state : "running" ,
552+ capability : "full" ,
553+ startedAt : Date . now ( ) ,
554+ lastActiveAt : Date . now ( ) ,
555+ } ,
556+ } ) ;
557+ } ) ;
558+
559+ renderProviders ( store ) ;
560+
561+ await vi . waitFor ( ( ) => {
562+ expect ( wsState . client ?. connect ) . toHaveBeenCalled ( ) ;
563+ } ) ;
564+
565+ act ( ( ) => {
566+ wsState . client ?. eventHandler ?.(
567+ "activation.revoked" ,
568+ { reason : "displaced" , generation : 2 } ,
569+ 1
570+ ) ;
571+ } ) ;
572+
573+ await vi . waitFor ( ( ) => {
574+ expect ( wsState . client ?. disconnect ) . toHaveBeenCalledWith ( "single_active_displaced" ) ;
575+ expect ( store . get ( activationStatusAtom ) ) . toBe ( "gated" ) ;
576+ expect ( store . get ( activationReasonAtom ) ) . toBe ( "displaced" ) ;
577+ expect ( store . get ( activationGenerationAtom ) ) . toBe ( 2 ) ;
578+ expect ( store . get ( workspacesLoadStateAtom ) ) . toBe ( "idle" ) ;
579+ expect ( store . get ( workspaceOrderAtom ) ) . toEqual ( [ ] ) ;
580+ expect ( store . get ( workspacesAtom ) ) . toEqual ( { } ) ;
581+ expect ( store . get ( activeWorkspaceIdAtom ) ) . toBeNull ( ) ;
582+ expect ( store . get ( fileTreeAtomFamily ( "ws-1" ) ) ) . toBeNull ( ) ;
583+ expect ( Array . from ( store . get ( loadedDirsAtomFamily ( "ws-1" ) ) ) ) . toEqual ( [ ] ) ;
584+ expect ( store . get ( gitStateAtomFamily ( "ws-1" ) ) ) . toBeNull ( ) ;
585+ expect ( store . get ( gitBranchListAtomFamily ( "ws-1" ) ) . current ) . toBe ( "" ) ;
586+ expect ( store . get ( worktreeListAtomFamily ( "ws-1" ) ) . items ) . toEqual ( [ ] ) ;
587+ expect ( store . get ( fileTreeStaleAtomFamily ( "ws-1" ) ) ) . toBe ( false ) ;
588+ expect ( store . get ( sessionsAtom ) ) . toEqual ( { } ) ;
589+ } ) ;
590+ } ) ;
591+
592+ it ( "does not auto-claim again while activation remains gated" , async ( ) => {
593+ const store = createStore ( ) ;
594+
595+ renderProviders ( store ) ;
596+
597+ await vi . waitFor ( ( ) => {
598+ expect ( wsState . client ?. connect ) . toHaveBeenCalled ( ) ;
599+ } ) ;
600+
601+ act ( ( ) => {
602+ store . set ( activationStatusAtom , "gated" ) ;
603+ wsState . client ?. statusHandler ?.( "connected" ) ;
604+ } ) ;
605+
606+ const claimCalls =
607+ wsState . client ?. sendCommand ?. mock . calls . filter ( ( [ op ] ) => op === "activation.claim" ) ?? [ ] ;
608+
609+ expect ( claimCalls ) . toHaveLength ( 0 ) ;
610+ } ) ;
611+
612+ it ( "does not auto-recover the websocket from foreground signals while gated" , async ( ) => {
613+ const store = createStore ( ) ;
614+ setVisibilityState ( "visible" ) ;
615+
616+ renderProviders ( store ) ;
617+
618+ await vi . waitFor ( ( ) => {
619+ expect ( wsState . client ?. connect ) . toHaveBeenCalled ( ) ;
620+ } ) ;
621+
622+ act ( ( ) => {
623+ store . set ( activationStatusAtom , "gated" ) ;
624+ window . dispatchEvent ( new Event ( "focus" ) ) ;
625+ window . dispatchEvent ( new Event ( "online" ) ) ;
626+ window . dispatchEvent ( new Event ( "pageshow" ) ) ;
627+ } ) ;
628+
629+ expect ( wsState . client ?. recoverConnection ) . not . toHaveBeenCalled ( ) ;
630+ } ) ;
631+
455632 it ( "hydrates terminal copy-on-select preferences from settings.get once connected" , async ( ) => {
456633 const store = createStore ( ) ;
457634 setVisibilityState ( "visible" ) ;
0 commit comments