Skip to content

Commit 4d90557

Browse files
kevinelliottclaude
andauthored
test(systray): cover IPC handlers, menu formatter, shutdown (#26)
* test(systray): cover IPC handlers, menu formatter, shutdown, dialog tracking Raises internal/systray coverage from 0.2% to 3.2% by testing the parts of App that don't require a running systray event loop: - handleListAgents: empty, populated, and slice-copy isolation (the response mustn't share backing storage with App.agents) - handleGetAgent: found, not-found, invalid payload error path - handleGetStatus: counts, update detection, uptime, timestamp fields - handleIPCMessage: dispatch table for list, status, unknown types - formatAgentMenuTitle: ● vs ⬆ prefix, method parenthetical, empty method fallback - requestShutdown: idempotent single-close, concurrent safety under 32 parallel callers - track/untrackDialog: add, remove, and miss-is-noop The remaining ~97% is GUI-glue code (onReady, handleMenuClicks, updateMenu, dialog windows) that requires a running systray library and platform event loop to exercise meaningfully. Covering those is out of scope for unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(systray): gofmt handlers_test.go Align trailing comments in mkInstallation arg list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9b3983a commit 4d90557

1 file changed

Lines changed: 305 additions & 0 deletions

File tree

internal/systray/handlers_test.go

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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

Comments
 (0)