package main import ( "encoding/json" "net" "os" "path/filepath" "testing" "time" ) func TestRegistryUpdate(t *testing.T) { reg := NewRegistry() info := SessionInfo{ PID: 1234, SessionID: "sess-1", Cwd: "/home/user/project", State: "Working", } reg.Update(info) list := reg.List() if len(list) != 1 { t.Fatalf("list len = %d, want 1", len(list)) } if list[0].SessionID != "sess-1" { t.Errorf("session_id = %q, want %q", list[0].SessionID, "sess-1") } } func TestRegistryWaitingSince(t *testing.T) { reg := NewRegistry() // Session starts Working reg.Update(SessionInfo{ PID: 1234, SessionID: "sess-1", State: "Working", }) list := reg.List() if list[0].WaitingSince != nil { t.Error("WaitingSince should be nil when Working") } // Session transitions to NeedsInput reg.Update(SessionInfo{ PID: 1234, SessionID: "sess-1", State: "Needs Input", }) list = reg.List() if list[0].WaitingSince == nil { t.Fatal("WaitingSince should be set when NeedsInput") } waitStart := *list[0].WaitingSince // Session goes back to Working -> WaitingSince reset reg.Update(SessionInfo{ PID: 1234, SessionID: "sess-1", State: "Working", }) list = reg.List() if list[0].WaitingSince != nil { t.Errorf("WaitingSince should be nil after returning to Working, got %v", list[0].WaitingSince) } _ = waitStart } func TestRegistryRemoveStale(t *testing.T) { reg := NewRegistry() reg.Update(SessionInfo{SessionID: "sess-1", State: "Working"}) reg.Update(SessionInfo{SessionID: "sess-2", State: "Working"}) // Only sess-1 is still active active := map[string]bool{"sess-1": true} reg.RemoveStale(active) list := reg.List() if len(list) != 1 { t.Fatalf("list len = %d, want 1", len(list)) } if list[0].SessionID != "sess-1" { t.Errorf("session_id = %q, want %q", list[0].SessionID, "sess-1") } } func TestLabelStoreSetGet(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "labels.json") ls, err := NewLabelStore(path) if err != nil { t.Fatalf("new: %v", err) } if err := ls.Set("sess-1", "review MR"); err != nil { t.Fatalf("set: %v", err) } got := ls.Get("sess-1") if got != "review MR" { t.Errorf("get = %q, want %q", got, "review MR") } // Non-existent key returns empty if got := ls.Get("unknown"); got != "" { t.Errorf("get unknown = %q, want empty", got) } } func TestLabelStorePersistence(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "labels.json") ls, err := NewLabelStore(path) if err != nil { t.Fatalf("new: %v", err) } if err := ls.Set("sess-1", "review MR"); err != nil { t.Fatalf("set: %v", err) } // Create a new LabelStore from the same file ls2, err := NewLabelStore(path) if err != nil { t.Fatalf("new2: %v", err) } got := ls2.Get("sess-1") if got != "review MR" { t.Errorf("persisted get = %q, want %q", got, "review MR") } } func TestLabelStoreLoadMissing(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "nonexistent", "labels.json") ls, err := NewLabelStore(path) if err != nil { t.Fatalf("should not error on missing file: %v", err) } if got := ls.Get("anything"); got != "" { t.Errorf("get = %q, want empty", got) } } func TestRegistryUpdateTimestamp(t *testing.T) { reg := NewRegistry() before := time.Now() reg.Update(SessionInfo{ SessionID: "sess-1", State: "Needs Input", }) after := time.Now() list := reg.List() if list[0].WaitingSince == nil { t.Fatal("WaitingSince should be set") } ws := *list[0].WaitingSince if ws.Before(before) || ws.After(after) { t.Errorf("WaitingSince %v not between %v and %v", ws, before, after) } } // sendRequest dials the daemon socket, sends a Request, and decodes the Response. func sendRequest(t *testing.T, sockPath string, req Request) Response { t.Helper() conn, err := net.Dial("unix", sockPath) if err != nil { t.Fatalf("dial: %v", err) } defer conn.Close() if err := json.NewEncoder(conn).Encode(req); err != nil { t.Fatalf("encode: %v", err) } var resp Response if err := json.NewDecoder(conn).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } return resp } // newTestDaemon creates a daemon using temp dirs (no real /proc or claude dir). func newTestDaemon(t *testing.T) *Daemon { t.Helper() dir := t.TempDir() sockPath := filepath.Join(dir, "vmux.sock") procDir := filepath.Join(dir, "proc") // empty, no processes claudeDir := filepath.Join(dir, "claude") labelsPath := filepath.Join(dir, "labels.json") os.MkdirAll(procDir, 0o755) os.MkdirAll(claudeDir, 0o755) labels, err := NewLabelStore(labelsPath) if err != nil { t.Fatalf("labels: %v", err) } return NewDaemon(sockPath, procDir, claudeDir, labels) } func TestDaemonStartStop(t *testing.T) { d := newTestDaemon(t) if err := d.Start(); err != nil { t.Fatalf("start: %v", err) } // Send stop via socket resp := sendRequest(t, d.sockPath, Request{Action: "stop"}) if !resp.OK { t.Errorf("stop resp.OK = false, error = %q", resp.Error) } // Daemon should stop within a short time done := make(chan struct{}) go func() { d.Wait() close(done) }() select { case <-done: // ok case <-time.After(2 * time.Second): t.Fatal("daemon did not stop within 2s") } } func TestDaemonListOverSocket(t *testing.T) { d := newTestDaemon(t) if err := d.Start(); err != nil { t.Fatalf("start: %v", err) } defer d.Stop() // Populate registry after Start (initial scan clears unknown sessions) d.registry.Update(SessionInfo{ PID: 42, SessionID: "test-sess", State: "Working", Cwd: "/tmp/test", }) resp := sendRequest(t, d.sockPath, Request{Action: "list"}) if !resp.OK { t.Fatalf("list resp.OK = false, error = %q", resp.Error) } if len(resp.Sessions) != 1 { t.Fatalf("sessions len = %d, want 1", len(resp.Sessions)) } if resp.Sessions[0].SessionID != "test-sess" { t.Errorf("session_id = %q, want %q", resp.Sessions[0].SessionID, "test-sess") } } func TestDaemonLabelOverSocket(t *testing.T) { d := newTestDaemon(t) if err := d.Start(); err != nil { t.Fatalf("start: %v", err) } defer d.Stop() // Populate registry after Start (initial scan clears unknown sessions) d.registry.Update(SessionInfo{ PID: 42, SessionID: "test-sess", State: "Working", Cwd: "/tmp/test", }) // Set label args, _ := json.Marshal(LabelArgs{SessionID: "test-sess", Label: "review MR"}) resp := sendRequest(t, d.sockPath, Request{Action: "label", Args: args}) if !resp.OK { t.Fatalf("label resp.OK = false, error = %q", resp.Error) } // Verify label appears in list resp = sendRequest(t, d.sockPath, Request{Action: "list"}) if !resp.OK { t.Fatalf("list resp.OK = false, error = %q", resp.Error) } if len(resp.Sessions) != 1 { t.Fatalf("sessions len = %d, want 1", len(resp.Sessions)) } if resp.Sessions[0].Label != "review MR" { t.Errorf("label = %q, want %q", resp.Sessions[0].Label, "review MR") } } func TestDaemonUnknownAction(t *testing.T) { d := newTestDaemon(t) if err := d.Start(); err != nil { t.Fatalf("start: %v", err) } defer d.Stop() resp := sendRequest(t, d.sockPath, Request{Action: "bogus"}) if resp.OK { t.Error("expected OK=false for unknown action") } if resp.Error == "" { t.Error("expected error message for unknown action") } }