Files
vmux/daemon_test.go
Pierre Martin 4b142a79b5 feat(02-01): add Unix socket server, poll loop, and stop handler
- Daemon struct with Start/Stop/Wait lifecycle
- Unix socket server handling list/label/stop actions
- Poll loop scanning /proc every 5s
- Stale socket cleanup before listen
- Connection dispatch with JSON encoding
- Tests with -race: StartStop, ListOverSocket, LabelOverSocket, UnknownAction
2026-03-23 17:45:57 +01:00

326 lines
7.2 KiB
Go

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")
}
}