|
| 1 | +package systray |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "os/exec" |
| 6 | + "strings" |
| 7 | + "sync" |
| 8 | + "testing" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/kevinelliott/agentmanager/pkg/agent" |
| 12 | + "github.com/kevinelliott/agentmanager/pkg/ipc" |
| 13 | +) |
| 14 | + |
| 15 | +// mkTestApp builds a minimal *App sufficient for testing pure IPC/menu |
| 16 | +// methods. It deliberately leaves out store/detector/catalog/installer — |
| 17 | +// those handlers need separate integration setup. |
| 18 | +func mkTestApp(agents ...agent.Installation) *App { |
| 19 | + ctx, cancel := context.WithCancel(context.Background()) |
| 20 | + a := &App{ |
| 21 | + ctx: ctx, |
| 22 | + cancel: cancel, |
| 23 | + done: make(chan struct{}), |
| 24 | + shutdownCh: make(chan struct{}), |
| 25 | + startTime: time.Now().Add(-42 * time.Second), |
| 26 | + version: "test", |
| 27 | + agents: append([]agent.Installation(nil), agents...), |
| 28 | + } |
| 29 | + return a |
| 30 | +} |
| 31 | + |
| 32 | +func mkInstallation(agentID, method, installedVer, latestVer string) agent.Installation { |
| 33 | + inst := agent.Installation{ |
| 34 | + AgentID: agentID, |
| 35 | + AgentName: agentID, |
| 36 | + Method: agent.InstallMethod(method), |
| 37 | + ExecutablePath: "/usr/local/bin/" + agentID, |
| 38 | + InstalledVersion: mustParseVersion(installedVer), |
| 39 | + } |
| 40 | + if latestVer != "" { |
| 41 | + v := mustParseVersion(latestVer) |
| 42 | + inst.LatestVersion = &v |
| 43 | + } |
| 44 | + return inst |
| 45 | +} |
| 46 | + |
| 47 | +func mustParseVersion(v string) agent.Version { |
| 48 | + out, _ := agent.ParseVersion(v) |
| 49 | + return out |
| 50 | +} |
| 51 | + |
| 52 | +func TestHandleListAgents_Empty(t *testing.T) { |
| 53 | + a := mkTestApp() |
| 54 | + req, _ := ipc.NewMessage(ipc.MessageTypeListAgents, nil) |
| 55 | + |
| 56 | + resp, err := a.handleListAgents(context.Background(), req) |
| 57 | + if err != nil { |
| 58 | + t.Fatalf("unexpected err: %v", err) |
| 59 | + } |
| 60 | + if resp.Type != ipc.MessageTypeSuccess { |
| 61 | + t.Errorf("Type = %q, want Success", resp.Type) |
| 62 | + } |
| 63 | + |
| 64 | + var out ipc.ListAgentsResponse |
| 65 | + if err := resp.DecodePayload(&out); err != nil { |
| 66 | + t.Fatalf("decode: %v", err) |
| 67 | + } |
| 68 | + if out.Total != 0 || len(out.Agents) != 0 { |
| 69 | + t.Errorf("expected empty response, got Total=%d Agents=%d", out.Total, len(out.Agents)) |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +func TestHandleListAgents_Populated(t *testing.T) { |
| 74 | + a := mkTestApp( |
| 75 | + mkInstallation("aider", "brew", "0.86.1", ""), |
| 76 | + mkInstallation("codex", "npm", "1.0.0", "1.0.1"), |
| 77 | + ) |
| 78 | + |
| 79 | + req, _ := ipc.NewMessage(ipc.MessageTypeListAgents, nil) |
| 80 | + resp, _ := a.handleListAgents(context.Background(), req) |
| 81 | + |
| 82 | + var out ipc.ListAgentsResponse |
| 83 | + _ = resp.DecodePayload(&out) |
| 84 | + |
| 85 | + if out.Total != 2 { |
| 86 | + t.Errorf("Total = %d, want 2", out.Total) |
| 87 | + } |
| 88 | + |
| 89 | + // Verify the returned slice is a copy — mutating it must not affect the App. |
| 90 | + out.Agents[0].AgentName = "tampered" |
| 91 | + a.agentsMu.RLock() |
| 92 | + original := a.agents[0].AgentName |
| 93 | + a.agentsMu.RUnlock() |
| 94 | + if original == "tampered" { |
| 95 | + t.Error("handleListAgents returned a shared slice — App state was mutated") |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +func TestHandleGetAgent_Found(t *testing.T) { |
| 100 | + inst := mkInstallation("aider", "brew", "0.86.1", "") |
| 101 | + a := mkTestApp(inst) |
| 102 | + |
| 103 | + req, _ := ipc.NewMessage(ipc.MessageTypeGetAgent, ipc.GetAgentRequest{Key: inst.Key()}) |
| 104 | + resp, _ := a.handleGetAgent(context.Background(), req) |
| 105 | + |
| 106 | + if resp.Type != ipc.MessageTypeSuccess { |
| 107 | + t.Fatalf("Type = %q, want Success", resp.Type) |
| 108 | + } |
| 109 | + |
| 110 | + var out ipc.GetAgentResponse |
| 111 | + _ = resp.DecodePayload(&out) |
| 112 | + if out.Agent == nil || out.Agent.AgentID != "aider" { |
| 113 | + t.Errorf("did not get aider back: %+v", out) |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +func TestHandleGetAgent_NotFound(t *testing.T) { |
| 118 | + a := mkTestApp(mkInstallation("aider", "brew", "0.86.1", "")) |
| 119 | + |
| 120 | + req, _ := ipc.NewMessage(ipc.MessageTypeGetAgent, ipc.GetAgentRequest{Key: "missing:key:x"}) |
| 121 | + resp, _ := a.handleGetAgent(context.Background(), req) |
| 122 | + |
| 123 | + var out ipc.GetAgentResponse |
| 124 | + _ = resp.DecodePayload(&out) |
| 125 | + if out.Agent != nil { |
| 126 | + t.Errorf("expected nil agent for missing key, got %+v", out.Agent) |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +func TestHandleGetAgent_InvalidPayload(t *testing.T) { |
| 131 | + a := mkTestApp() |
| 132 | + |
| 133 | + // Hand-craft a message with a non-decodable payload. |
| 134 | + req := &ipc.Message{Type: ipc.MessageTypeGetAgent, Payload: []byte(`not-json`)} |
| 135 | + resp, err := a.handleGetAgent(context.Background(), req) |
| 136 | + if err != nil { |
| 137 | + t.Fatalf("handler should not return a Go error: %v", err) |
| 138 | + } |
| 139 | + if resp.Type != ipc.MessageTypeError { |
| 140 | + t.Errorf("Type = %q, want Error", resp.Type) |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +func TestHandleGetStatus(t *testing.T) { |
| 145 | + a := mkTestApp( |
| 146 | + mkInstallation("a", "npm", "1.0.0", "1.0.1"), // has update |
| 147 | + mkInstallation("b", "brew", "2.0.0", "2.0.0"), // no update (equal) |
| 148 | + mkInstallation("c", "brew", "3.0.0", ""), // unknown latest |
| 149 | + mkInstallation("d", "npm", "0.1.0", "0.2.0"), // has update |
| 150 | + ) |
| 151 | + a.lastRefresh = time.Now().Add(-5 * time.Minute) |
| 152 | + a.lastCheck = time.Now().Add(-1 * time.Minute) |
| 153 | + |
| 154 | + req, _ := ipc.NewMessage(ipc.MessageTypeGetStatus, nil) |
| 155 | + resp, _ := a.handleGetStatus(context.Background(), req) |
| 156 | + |
| 157 | + if resp.Type != ipc.MessageTypeSuccess { |
| 158 | + t.Fatalf("Type = %q, want Success", resp.Type) |
| 159 | + } |
| 160 | + var out ipc.StatusResponse |
| 161 | + _ = resp.DecodePayload(&out) |
| 162 | + |
| 163 | + if !out.Running { |
| 164 | + t.Error("Running should be true") |
| 165 | + } |
| 166 | + if out.AgentCount != 4 { |
| 167 | + t.Errorf("AgentCount = %d, want 4", out.AgentCount) |
| 168 | + } |
| 169 | + if out.UpdatesAvailable != 2 { |
| 170 | + t.Errorf("UpdatesAvailable = %d, want 2", out.UpdatesAvailable) |
| 171 | + } |
| 172 | + if out.Uptime < 40 { |
| 173 | + t.Errorf("Uptime = %d, want >=40 (startTime was 42s ago)", out.Uptime) |
| 174 | + } |
| 175 | + if out.LastCatalogRefresh.IsZero() || out.LastUpdateCheck.IsZero() { |
| 176 | + t.Error("timestamps should be non-zero") |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +func TestHandleIPCMessage_Dispatch(t *testing.T) { |
| 181 | + a := mkTestApp() |
| 182 | + |
| 183 | + cases := []struct { |
| 184 | + name string |
| 185 | + msg ipc.MessageType |
| 186 | + want ipc.MessageType |
| 187 | + }{ |
| 188 | + {"list routes to success", ipc.MessageTypeListAgents, ipc.MessageTypeSuccess}, |
| 189 | + {"status routes to success", ipc.MessageTypeGetStatus, ipc.MessageTypeSuccess}, |
| 190 | + {"unknown routes to error", ipc.MessageType("nope"), ipc.MessageTypeError}, |
| 191 | + } |
| 192 | + for _, tc := range cases { |
| 193 | + t.Run(tc.name, func(t *testing.T) { |
| 194 | + req, _ := ipc.NewMessage(tc.msg, nil) |
| 195 | + resp, _ := a.handleIPCMessage(context.Background(), req) |
| 196 | + if resp.Type != tc.want { |
| 197 | + t.Errorf("Type = %q, want %q", resp.Type, tc.want) |
| 198 | + } |
| 199 | + }) |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +func TestFormatAgentMenuTitle(t *testing.T) { |
| 204 | + a := mkTestApp() |
| 205 | + |
| 206 | + // Up-to-date (no LatestVersion) |
| 207 | + title := a.formatAgentMenuTitle(mkInstallation("aider", "brew", "0.86.1", "")) |
| 208 | + if !strings.HasPrefix(title, "●") { |
| 209 | + t.Errorf("up-to-date prefix = %q, want ●", title) |
| 210 | + } |
| 211 | + for _, want := range []string{"aider", "(brew)", "0.86.1"} { |
| 212 | + if !strings.Contains(title, want) { |
| 213 | + t.Errorf("missing %q in %q", want, title) |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + // Has update — equal versions mean no update; use strictly newer to force |
| 218 | + inst := mkInstallation("codex", "npm", "1.0.0", "1.2.0") |
| 219 | + title = a.formatAgentMenuTitle(inst) |
| 220 | + if !strings.HasPrefix(title, "⬆") { |
| 221 | + t.Errorf("with-update prefix = %q, want ⬆", title) |
| 222 | + } |
| 223 | + for _, want := range []string{"codex", "(npm)", "1.0.0", "1.2.0"} { |
| 224 | + if !strings.Contains(title, want) { |
| 225 | + t.Errorf("missing %q in %q", want, title) |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + // Empty method → no parenthetical segment |
| 230 | + blank2 := agent.Installation{AgentID: "y", AgentName: "y", InstalledVersion: mustParseVersion("1.0.0")} |
| 231 | + title = a.formatAgentMenuTitle(blank2) |
| 232 | + if strings.Contains(title, "()") { |
| 233 | + t.Errorf("empty method should not produce empty parens, got %q", title) |
| 234 | + } |
| 235 | +} |
| 236 | + |
| 237 | +func TestRequestShutdown_Idempotent(t *testing.T) { |
| 238 | + a := mkTestApp() |
| 239 | + |
| 240 | + // First call closes shutdownCh; subsequent calls must be no-ops. |
| 241 | + a.requestShutdown() |
| 242 | + a.requestShutdown() |
| 243 | + a.requestShutdown() |
| 244 | + |
| 245 | + select { |
| 246 | + case <-a.shutdownCh: |
| 247 | + // expected: channel closed, receive returns immediately |
| 248 | + case <-time.After(50 * time.Millisecond): |
| 249 | + t.Fatal("shutdownCh was not closed after requestShutdown") |
| 250 | + } |
| 251 | +} |
| 252 | + |
| 253 | +func TestRequestShutdown_ConcurrentSafe(t *testing.T) { |
| 254 | + a := mkTestApp() |
| 255 | + |
| 256 | + var wg sync.WaitGroup |
| 257 | + for range 32 { |
| 258 | + wg.Add(1) |
| 259 | + go func() { |
| 260 | + defer wg.Done() |
| 261 | + a.requestShutdown() |
| 262 | + }() |
| 263 | + } |
| 264 | + wg.Wait() |
| 265 | + |
| 266 | + select { |
| 267 | + case <-a.shutdownCh: |
| 268 | + case <-time.After(100 * time.Millisecond): |
| 269 | + t.Fatal("shutdownCh was not closed after concurrent requestShutdown") |
| 270 | + } |
| 271 | +} |
| 272 | + |
| 273 | +func TestDialogTracking(t *testing.T) { |
| 274 | + a := mkTestApp() |
| 275 | + |
| 276 | + // Track a couple of inert commands (we never call .Run()). |
| 277 | + cmd1 := exec.Command("true") |
| 278 | + cmd2 := exec.Command("true") |
| 279 | + |
| 280 | + a.trackDialog(cmd1) |
| 281 | + a.trackDialog(cmd2) |
| 282 | + |
| 283 | + a.dialogProcsMu.Lock() |
| 284 | + if len(a.dialogProcs) != 2 { |
| 285 | + t.Errorf("dialogProcs len = %d, want 2", len(a.dialogProcs)) |
| 286 | + } |
| 287 | + a.dialogProcsMu.Unlock() |
| 288 | + |
| 289 | + // Untrack one → length drops to 1 and remaining is cmd2. |
| 290 | + a.untrackDialog(cmd1) |
| 291 | + |
| 292 | + a.dialogProcsMu.Lock() |
| 293 | + if len(a.dialogProcs) != 1 || a.dialogProcs[0] != cmd2 { |
| 294 | + t.Errorf("after untrack: dialogProcs = %v", a.dialogProcs) |
| 295 | + } |
| 296 | + a.dialogProcsMu.Unlock() |
| 297 | + |
| 298 | + // Untrack something not tracked is a no-op (exercise the loop miss). |
| 299 | + a.untrackDialog(cmd1) |
| 300 | + a.dialogProcsMu.Lock() |
| 301 | + if len(a.dialogProcs) != 1 { |
| 302 | + t.Errorf("untrack of missing cmd changed length: %d", len(a.dialogProcs)) |
| 303 | + } |
| 304 | + a.dialogProcsMu.Unlock() |
| 305 | +} |
0 commit comments