package localcontrol import ( "context" "encoding/json" "errors" "os" "path/filepath" "sync" "testing" "time" "iop/apps/agent/internal/clientprocess" "iop/packages/go/agentconfig" "iop/packages/go/agentstate" iop "iop/proto/gen/iop" "google.golang.org/protobuf/proto" ) func TestClientOperationMatrix(t *testing.T) { controller := newRecordingClientController() operations := newTestClientOperations(t, controller) tests := []struct { operation string kind string }{ {operation: OperationClientStart, kind: "flutter"}, {operation: OperationClientStart, kind: "unity"}, {operation: OperationClientStop, kind: "flutter"}, {operation: OperationClientStop, kind: "unity"}, {operation: OperationClientFocus, kind: "flutter"}, } expectedCalls := make(map[string]int) for index, test := range tests { request := clientOperationRequest( "message-"+test.operation+"-"+test.kind, "command-"+test.operation+"-"+test.kind, test.operation, test.kind, "", ) response := operations.Handle(context.Background(), true, request) if response.GetKind() != iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE || !response.GetResponse().GetMutation().GetAccepted() || response.GetResponse().GetMutation().GetSubjectId() != test.kind { t.Fatalf("case %d response = %#v", index, response) } expectedCalls[test.operation]++ if calls := controller.callsFor(test.operation); calls != expectedCalls[test.operation] { t.Fatalf( "%s calls = %d, want %d", test.operation, calls, expectedCalls[test.operation], ) } } replayRequest := clientOperationRequest( "message-replay-first", "command-replay", OperationClientStart, "flutter", "", ) first := operations.Handle(context.Background(), true, replayRequest) replayed := cloneEnvelope(replayRequest) replayed.MessageId = "message-replay-second" second := operations.Handle(context.Background(), true, replayed) if !proto.Equal(first, second) { t.Fatalf("idempotent replay changed response:\nfirst=%v\nsecond=%v", first, second) } if calls := controller.callsFor(OperationClientStart); calls != 3 { t.Fatalf("client.start total calls = %d, want 3", calls) } } func TestUnityDetailStartsOrFocusesFlutter(t *testing.T) { controller := newRecordingClientController() operations := newTestClientOperations(t, controller) first := operations.Handle( context.Background(), true, clientOperationRequest( "detail-start-message", "detail-start-command", OperationClientDetail, "unity", "open-run-detail", ), ) if mutation := first.GetResponse().GetMutation(); mutation.GetSubjectId() != "flutter" || mutation.GetSummary() != "start" { t.Fatalf("first detail response = %#v", first) } second := operations.Handle( context.Background(), true, clientOperationRequest( "detail-focus-message", "detail-focus-command", OperationClientDetail, "unity", "open-run-detail", ), ) if mutation := second.GetResponse().GetMutation(); mutation.GetSubjectId() != "flutter" || mutation.GetSummary() != "focus" { t.Fatalf("second detail response = %#v", second) } if calls := controller.callsFor(OperationClientDetail); calls != 2 { t.Fatalf("detail calls = %d, want 2", calls) } if calls := controller.callsFor(OperationClientStart); calls != 0 { t.Fatalf("direct Flutter start port calls = %d, want 0", calls) } } func TestRejectedClientCommandHasZeroProcessCalls(t *testing.T) { controller := newRecordingClientController() operations := newTestClientOperations(t, controller) tests := []struct { name string authorized bool request *iop.AgentLocalEnvelope code string }{ { name: "unauthorized", authorized: false, request: clientOperationRequest( "denied-message", "denied-command", OperationClientStart, "flutter", "", ), code: ErrorPermissionDenied, }, { name: "unknown kind", authorized: true, request: clientOperationRequest( "unknown-message", "unknown-command", OperationClientStart, "electron", "", ), code: ErrorMalformedFrame, }, { name: "unity focus bypass", authorized: true, request: clientOperationRequest( "unity-focus-message", "unity-focus-command", OperationClientFocus, "unity", "", ), code: ErrorMalformedFrame, }, { name: "detail without capability", authorized: true, request: clientOperationRequest( "detail-message", "detail-command", OperationClientDetail, "unity", "", ), code: ErrorMalformedFrame, }, } for _, test := range tests { response := operations.Handle( context.Background(), test.authorized, test.request, ) if response.GetError().GetCode() != test.code { t.Fatalf( "%s error = %q, want %q", test.name, response.GetError().GetCode(), test.code, ) } } if calls := controller.totalCalls(); calls != 0 { t.Fatalf("rejected process calls = %d, want 0", calls) } } func TestClientCommandIDConflictHasZeroAdditionalProcessCalls(t *testing.T) { controller := newRecordingClientController() operations := newTestClientOperations(t, controller) first := clientOperationRequest( "first-message", "same-command", OperationClientStart, "flutter", "", ) if response := operations.Handle( context.Background(), true, first, ); response.GetError() != nil { t.Fatalf("first response = %#v", response) } conflict := clientOperationRequest( "conflict-message", "same-command", OperationClientStart, "unity", "", ) response := operations.Handle(context.Background(), true, conflict) if response.GetError().GetCode() != ErrorCommandIDConflict { t.Fatalf("conflict response = %#v", response) } if calls := controller.totalCalls(); calls != 1 { t.Fatalf("conflict process calls = %d, want 1", calls) } } func TestAcceptedIncompleteClientCommandReusesCompletedManagerReceipt(t *testing.T) { statePath := filepath.Join(t.TempDir(), "state.json") store, err := agentstate.NewStore(statePath) if err != nil { t.Fatalf("NewStore: %v", err) } backend := &countingProcessBackend{} manager, err := clientprocess.NewManager( context.Background(), map[string]agentconfig.ClientProcessSpec{ string(clientprocess.ClientFlutter): { Executable: "/bin/false", WorkingDirectory: "/tmp", FocusArgs: []string{"focus"}, }, }, store, clientprocess.WithProcessBackend(backend), ) if err != nil { t.Fatalf("NewManager: %v", err) } defer manager.Close(context.Background()) // Start Flutter first so it's live if _, err := manager.Start(context.Background(), clientprocess.ClientFlutter, "seed-start"); err != nil { t.Fatalf("Start flutter: %v", err) } initialFocusCalls := backend.focusCalls ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16) if err != nil { t.Fatalf("NewLedger: %v", err) } operations, err := NewClientOperations(manager, ledger) if err != nil { t.Fatalf("NewClientOperations: %v", err) } req := clientOperationRequest("msg-1", "cmd-focus-1", OperationClientFocus, "flutter", "") res1 := operations.Handle(context.Background(), true, req) if res1.GetError() != nil { t.Fatalf("first Handle error: %v", res1.GetError()) } if backend.focusCalls != initialFocusCalls+1 { t.Fatalf("focus calls = %d, want %d", backend.focusCalls, initialFocusCalls+1) } // Simulate ledger restart where command was accepted but not finished in ledger. // Recreating ledger reads existing store state where Manager stored completed receipt. ledger2, err := NewLedger(context.Background(), store, "daemon-ops", 16) if err != nil { t.Fatalf("NewLedger 2: %v", err) } operations2, err := NewClientOperations(manager, ledger2) if err != nil { t.Fatalf("NewClientOperations 2: %v", err) } reqReplay := clientOperationRequest("msg-2", "cmd-focus-1", OperationClientFocus, "flutter", "") res2 := operations2.Handle(context.Background(), true, reqReplay) if res2.GetError() != nil { t.Fatalf("replay Handle error: %v", res2.GetError()) } if backend.focusCalls != initialFocusCalls+1 { t.Fatalf("focus calls after replay = %d, want %d (zero additional calls)", backend.focusCalls, initialFocusCalls+1) } } func TestAcceptedIncompleteClientCommandReusesExactManagerReceiptAfterStateChange(t *testing.T) { base, err := agentstate.NewStore(filepath.Join(t.TempDir(), "state.json")) if err != nil { t.Fatalf("NewStore: %v", err) } store := &ledgerFinishFailingStore{Store: base, failKey: "local-control/ledger"} backend := &countingProcessBackend{} manager, err := clientprocess.NewManager( context.Background(), map[string]agentconfig.ClientProcessSpec{ string(clientprocess.ClientFlutter): { Executable: "/bin/false", WorkingDirectory: "/tmp", FocusArgs: []string{"focus"}, }, }, store, clientprocess.WithProcessBackend(backend), ) if err != nil { t.Fatalf("NewManager: %v", err) } defer manager.Close(context.Background()) // Live, connected Flutter so focus and a later disconnect are valid. if _, err := manager.Start(context.Background(), clientprocess.ClientFlutter, "seed-start"); err != nil { t.Fatalf("Start: %v", err) } if _, err := manager.SetConnected(context.Background(), clientprocess.ClientFlutter, true); err != nil { t.Fatalf("SetConnected true: %v", err) } ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16) if err != nil { t.Fatalf("NewLedger: %v", err) } operations, err := NewClientOperations(manager, ledger) if err != nil { t.Fatalf("NewClientOperations: %v", err) } // Fail only the first final ledger CAS so the command is durably accepted // but incomplete while the manager retains the completed focus receipt. store.armFinishFailure() first := operations.Handle( context.Background(), true, clientOperationRequest("msg-1", "cmd-focus-x", OperationClientFocus, "flutter", ""), ) if first.GetError() == nil { t.Fatalf("expected injected finish failure to surface, got %#v", first) } if backend.focusCalls != 1 { t.Fatalf("focus calls after first handle = %d, want 1", backend.focusCalls) } // Intervening state change: disconnect so the current slot projection // (starting) differs from the immutable focus snapshot (connected). if _, err := manager.SetConnected(context.Background(), clientprocess.ClientFlutter, false); err != nil { t.Fatalf("SetConnected false: %v", err) } // Recover through a new ledger instance; the accepted-incomplete command // re-dispatches into manager receipt recovery. ledger2, err := NewLedger(context.Background(), store, "daemon-ops", 16) if err != nil { t.Fatalf("NewLedger 2: %v", err) } operations2, err := NewClientOperations(manager, ledger2) if err != nil { t.Fatalf("NewClientOperations 2: %v", err) } second := operations2.Handle( context.Background(), true, clientOperationRequest("msg-2", "cmd-focus-x", OperationClientFocus, "flutter", ""), ) if second.GetError() != nil { t.Fatalf("replay error: %v", second.GetError()) } if backend.focusCalls != 1 { t.Fatalf("focus calls after replay = %d, want 1 (zero additional mutation)", backend.focusCalls) } mutation := second.GetResponse().GetMutation() if mutation.GetSubjectId() != "flutter" || mutation.GetSummary() != "focus" { t.Fatalf("replay mutation = %#v", mutation) } // Exact original result: the state reflects the connected snapshot captured // at completion, not the current disconnected projection. if mutation.GetState() != string(clientprocess.StateConnected) { t.Fatalf( "replay state = %q, want %q (immutable receipt snapshot)", mutation.GetState(), clientprocess.StateConnected, ) } } // ledgerFinishFailingStore fails exactly one ledger CompareAndSwap once armed. // The first ledger CAS after arming is the BeginCommand acceptance; the second // is the FinishCommand completion, which is the one this store rejects. type ledgerFinishFailingStore struct { *agentstate.Store failKey string mu sync.Mutex armed bool count int } func (s *ledgerFinishFailingStore) armFinishFailure() { s.mu.Lock() s.armed = true s.count = 0 s.mu.Unlock() } func (s *ledgerFinishFailingStore) CompareAndSwapIntegrationRecord( ctx context.Context, key string, revision string, payload []byte, ) (string, error) { s.mu.Lock() fail := false if s.armed && key == s.failKey { s.count++ if s.count == 2 { fail = true s.armed = false } } s.mu.Unlock() if fail { return "", errors.New("injected finish CAS failure") } return s.Store.CompareAndSwapIntegrationRecord(ctx, key, revision, payload) } func TestAcceptedIncompleteClientFocusFailsClosedWhenOutcomePending(t *testing.T) { statePath := filepath.Join(t.TempDir(), "state.json") store, err := agentstate.NewStore(statePath) if err != nil { t.Fatalf("NewStore: %v", err) } // Seed store with a live client and a pending focus receipt for cmd-pending identity := clientprocess.ProcessIdentity{PID: 500, StartToken: "fake-token"} rec := clientprocess.Record{ SchemaVersion: clientprocess.RecordSchemaVersion, Kind: clientprocess.ClientFlutter, State: clientprocess.StateConnected, Connected: true, Identity: &identity, Commands: map[string]clientprocess.CommandReceipt{ "cmd-pending": { CommandID: "cmd-pending", Action: "focus", Status: clientprocess.CommandReceiptPending, }, }, } // Write record manually via raw store integration record payload, err := jsonMarshalRecord(rec) if err != nil { t.Fatalf("marshal: %v", err) } if _, err := store.CompareAndSwapIntegrationRecord( context.Background(), "client-process/flutter", "", payload, ); err != nil { t.Fatalf("seed store: %v", err) } backend := &countingProcessBackend{liveIdentity: identity} manager, err := clientprocess.NewManager( context.Background(), map[string]agentconfig.ClientProcessSpec{ string(clientprocess.ClientFlutter): { Executable: "/bin/false", WorkingDirectory: "/tmp", FocusArgs: []string{"focus"}, }, }, store, clientprocess.WithProcessBackend(backend), ) if err != nil { t.Fatalf("NewManager: %v", err) } defer manager.Close(context.Background()) ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16) if err != nil { t.Fatalf("NewLedger: %v", err) } operations, err := NewClientOperations(manager, ledger) if err != nil { t.Fatalf("NewClientOperations: %v", err) } req := clientOperationRequest("msg-p", "cmd-pending", OperationClientFocus, "flutter", "") res := operations.Handle(context.Background(), true, req) if res.GetError() == nil { t.Fatalf("expected error for pending outcome, got success") } if res.GetError().GetCode() != ErrorInvalidState || !res.GetError().GetRetryable() { t.Fatalf("error = %#v, want retryable ErrorInvalidState", res.GetError()) } if backend.focusCalls != 0 { t.Fatalf("backend focus calls = %d, want 0", backend.focusCalls) } } type countingProcessBackend struct { mu sync.Mutex focusCalls int liveIdentity clientprocess.ProcessIdentity processes map[clientprocess.ProcessIdentity]*countingOwnedProcess } func (b *countingProcessBackend) Start( context.Context, clientprocess.ClientKind, agentconfig.ClientProcessSpec, ) (clientprocess.OwnedProcess, error) { b.mu.Lock() defer b.mu.Unlock() if b.processes == nil { b.processes = make(map[clientprocess.ProcessIdentity]*countingOwnedProcess) } id := clientprocess.ProcessIdentity{PID: 300 + len(b.processes), StartToken: "fake"} proc := &countingOwnedProcess{ identity: id, done: make(chan struct{}), } b.processes[id] = proc return proc, nil } type countingOwnedProcess struct { identity clientprocess.ProcessIdentity done chan struct{} stopOnce sync.Once } func (p *countingOwnedProcess) stop() { p.stopOnce.Do(func() { close(p.done) }) } func (p *countingOwnedProcess) Identity() clientprocess.ProcessIdentity { return p.identity } func (p *countingOwnedProcess) Wait() error { <-p.done return nil } func (p *countingOwnedProcess) Abort() error { p.stop() return nil } func (b *countingProcessBackend) Inspect( _ context.Context, id clientprocess.ProcessIdentity, ) (clientprocess.IdentityObservation, error) { b.mu.Lock() defer b.mu.Unlock() if proc, ok := b.processes[id]; ok { select { case <-proc.done: return clientprocess.IdentityObservation{State: clientprocess.IdentityExited}, nil default: return clientprocess.IdentityObservation{State: clientprocess.IdentityLive, Identity: id}, nil } } return clientprocess.IdentityObservation{ State: clientprocess.IdentityLive, Identity: id, }, nil } func (b *countingProcessBackend) Signal( _ context.Context, id clientprocess.ProcessIdentity, _ os.Signal, ) error { b.mu.Lock() defer b.mu.Unlock() if proc, ok := b.processes[id]; ok { proc.stop() } return nil } func (b *countingProcessBackend) Kill( _ context.Context, id clientprocess.ProcessIdentity, ) error { b.mu.Lock() defer b.mu.Unlock() if proc, ok := b.processes[id]; ok { proc.stop() } return nil } func (b *countingProcessBackend) Focus( context.Context, clientprocess.ClientKind, agentconfig.ClientProcessSpec, ) error { b.mu.Lock() defer b.mu.Unlock() b.focusCalls++ return nil } func jsonMarshalRecord(rec clientprocess.Record) ([]byte, error) { rec.UpdatedAt = time.Now().UTC() return json.Marshal(rec) } type recordingClientController struct { mu sync.Mutex calls map[string]int flutter bool } func newRecordingClientController() *recordingClientController { return &recordingClientController{calls: make(map[string]int)} } func (controller *recordingClientController) Start( _ context.Context, kind clientprocess.ClientKind, _ string, ) (clientprocess.Result, error) { controller.mu.Lock() defer controller.mu.Unlock() controller.calls[OperationClientStart]++ if kind == clientprocess.ClientFlutter { controller.flutter = true } return clientResult(kind, "start"), nil } func (controller *recordingClientController) Stop( _ context.Context, kind clientprocess.ClientKind, _ string, ) (clientprocess.Result, error) { controller.mu.Lock() defer controller.mu.Unlock() controller.calls[OperationClientStop]++ if kind == clientprocess.ClientFlutter { controller.flutter = false } result := clientResult(kind, "stop") result.Record.State = clientprocess.StateStopped return result, nil } func (controller *recordingClientController) Focus( _ context.Context, kind clientprocess.ClientKind, _ string, ) (clientprocess.Result, error) { controller.mu.Lock() defer controller.mu.Unlock() controller.calls[OperationClientFocus]++ return clientResult(kind, "focus"), nil } func (controller *recordingClientController) StartOrFocusFlutter( _ context.Context, _ string, ) (clientprocess.Result, error) { controller.mu.Lock() defer controller.mu.Unlock() controller.calls[OperationClientDetail]++ action := "focus" if !controller.flutter { action = "start" controller.flutter = true } return clientResult(clientprocess.ClientFlutter, action), nil } func (controller *recordingClientController) callsFor(operation string) int { controller.mu.Lock() defer controller.mu.Unlock() return controller.calls[operation] } func (controller *recordingClientController) totalCalls() int { controller.mu.Lock() defer controller.mu.Unlock() total := 0 for _, calls := range controller.calls { total += calls } return total } func clientResult( kind clientprocess.ClientKind, action string, ) clientprocess.Result { return clientprocess.Result{ Record: clientprocess.Record{ SchemaVersion: clientprocess.RecordSchemaVersion, Kind: kind, State: clientprocess.StateConnected, Connected: true, }, Changed: true, Action: action, } } func newTestClientOperations( t *testing.T, controller ClientProcessController, ) *ClientOperations { t.Helper() store, err := agentstate.NewStore( filepath.Join(t.TempDir(), "state.json"), ) if err != nil { t.Fatalf("NewStore: %v", err) } ledger, err := NewLedger( context.Background(), store, "daemon-client-operations", 16, ) if err != nil { t.Fatalf("NewLedger: %v", err) } operations, err := NewClientOperations(controller, ledger) if err != nil { t.Fatalf("NewClientOperations: %v", err) } return operations } func clientOperationRequest( messageID string, commandID string, operation string, kind string, capability string, ) *iop.AgentLocalEnvelope { return &iop.AgentLocalEnvelope{ ProtocolVersion: ProtocolVersion, Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_REQUEST, MessageId: messageID, Operation: operation, Payload: &iop.AgentLocalEnvelope_Request{ Request: &iop.AgentLocalRequest{ CommandId: commandID, Payload: &iop.AgentLocalRequest_Client{ Client: &iop.AgentLocalClientRequest{ ClientKind: kind, Capability: capability, }, }, }, }, } }