- Notifier + FocusTimer fields in Daemon, initialized in NewDaemon - processHookEvent notifies on Working -> Needs Input only (D-01) - Focus mode suppresses notifications when active (D-05) - FocusArgs in protocol, "focus" handler in daemon - CLI "vmux focus <minutes>" command - Tests for all notification transitions and focus handler
567 lines
13 KiB
Go
567 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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)
|
|
}
|
|
|
|
d := NewDaemon(sockPath, procDir, claudeDir, labels)
|
|
d.hookPort = 0 // disable hook server by default in tests
|
|
return d
|
|
}
|
|
|
|
func TestHookServerStartsWithDaemon(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
|
|
// Assign a dynamic port for the hook server
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
ln.Close()
|
|
|
|
d.hookPort = port
|
|
|
|
if err := d.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer d.Stop()
|
|
|
|
// POST to /hook should return 200
|
|
url := fmt.Sprintf("http://127.0.0.1:%d/hook", port)
|
|
resp, err := http.Post(url, "application/json", nil)
|
|
if err != nil {
|
|
t.Fatalf("POST /hook: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
// Empty body -> 400 (bad JSON), but the server is alive
|
|
// A valid POST should return 200
|
|
body := `{"session_id":"test","hook_event_name":"Stop","cwd":"/tmp"}`
|
|
resp2, err := http.Post(url, "application/json", io.NopCloser(
|
|
io.Reader(strings.NewReader(body)),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("POST /hook valid: %v", err)
|
|
}
|
|
defer resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp2.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHookServerStopsWithDaemon(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
ln.Close()
|
|
|
|
d.hookPort = port
|
|
|
|
if err := d.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
|
|
d.Stop()
|
|
|
|
// Give a moment for shutdown
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Port should no longer be listening
|
|
url := fmt.Sprintf("http://127.0.0.1:%d/hook", port)
|
|
_, err = http.Post(url, "application/json", nil)
|
|
if err == nil {
|
|
t.Error("expected connection error after Stop, but POST succeeded")
|
|
}
|
|
}
|
|
|
|
func TestHookServerPortBusy(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
|
|
// Occupy a port
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
|
|
d.hookPort = port
|
|
|
|
// Start should succeed despite port being busy (graceful degradation)
|
|
if err := d.Start(); err != nil {
|
|
t.Fatalf("start should succeed even with port busy: %v", err)
|
|
}
|
|
defer d.Stop()
|
|
}
|
|
|
|
func TestPollSlowdown(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
|
|
// No hook received -> default 5s
|
|
got := d.currentPollInterval()
|
|
if got != 5*time.Second {
|
|
t.Errorf("default poll = %v, want 5s", got)
|
|
}
|
|
|
|
// Simulate recent hook
|
|
d.mu.Lock()
|
|
d.lastHookTime = time.Now()
|
|
d.mu.Unlock()
|
|
|
|
got = d.currentPollInterval()
|
|
if got != 20*time.Second {
|
|
t.Errorf("after hook poll = %v, want 20s", got)
|
|
}
|
|
|
|
// Simulate hook > 60s ago
|
|
d.mu.Lock()
|
|
d.lastHookTime = time.Now().Add(-61 * time.Second)
|
|
d.mu.Unlock()
|
|
|
|
got = d.currentPollInterval()
|
|
if got != 5*time.Second {
|
|
t.Errorf("after 60s poll = %v, want 5s", got)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// spyNotifier records calls to Notify for test assertions.
|
|
type spyNotifier struct {
|
|
calls []struct{ title, body string }
|
|
}
|
|
|
|
func (s *spyNotifier) Notify(title, body string) error {
|
|
s.calls = append(s.calls, struct{ title, body string }{title, body})
|
|
return nil
|
|
}
|
|
|
|
func TestNotification_WorkingToNeedsInput(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
spy := &spyNotifier{}
|
|
d.notifier = spy
|
|
|
|
// Seed session as Working
|
|
d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj")
|
|
|
|
// Transition to Needs Input
|
|
d.processHookEvent(HookEvent{
|
|
SessionID: "sess-1",
|
|
HookEventName: "Stop",
|
|
Cwd: "/tmp/proj",
|
|
})
|
|
|
|
if len(spy.calls) != 1 {
|
|
t.Fatalf("notify calls = %d, want 1", len(spy.calls))
|
|
}
|
|
if spy.calls[0].title != "vmux: proj" {
|
|
t.Errorf("title = %q, want %q", spy.calls[0].title, "vmux: proj")
|
|
}
|
|
}
|
|
|
|
func TestNotification_IdleToNeedsInput(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
spy := &spyNotifier{}
|
|
d.notifier = spy
|
|
|
|
// Seed session as Idle (not Working)
|
|
d.registry.UpdateFromHook("sess-1", "Idle", "", "/tmp/proj")
|
|
|
|
// Transition to Needs Input from Idle
|
|
d.processHookEvent(HookEvent{
|
|
SessionID: "sess-1",
|
|
HookEventName: "Stop",
|
|
Cwd: "/tmp/proj",
|
|
})
|
|
|
|
if len(spy.calls) != 0 {
|
|
t.Errorf("notify calls = %d, want 0 (Idle -> Needs Input should not notify)", len(spy.calls))
|
|
}
|
|
}
|
|
|
|
func TestNotification_FocusActive(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
spy := &spyNotifier{}
|
|
d.notifier = spy
|
|
d.focus.Set(30 * time.Minute)
|
|
|
|
// Seed session as Working
|
|
d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj")
|
|
|
|
// Transition to Needs Input (but focus active)
|
|
d.processHookEvent(HookEvent{
|
|
SessionID: "sess-1",
|
|
HookEventName: "Stop",
|
|
Cwd: "/tmp/proj",
|
|
})
|
|
|
|
if len(spy.calls) != 0 {
|
|
t.Errorf("notify calls = %d, want 0 (focus active should suppress)", len(spy.calls))
|
|
}
|
|
}
|
|
|
|
func TestNotification_FocusExpired(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
spy := &spyNotifier{}
|
|
d.notifier = spy
|
|
d.focus.Set(0) // expired immediately
|
|
|
|
// Seed session as Working
|
|
d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj")
|
|
|
|
// Transition to Needs Input (focus expired)
|
|
d.processHookEvent(HookEvent{
|
|
SessionID: "sess-1",
|
|
HookEventName: "Stop",
|
|
Cwd: "/tmp/proj",
|
|
})
|
|
|
|
if len(spy.calls) != 1 {
|
|
t.Fatalf("notify calls = %d, want 1 (focus expired should notify)", len(spy.calls))
|
|
}
|
|
}
|
|
|
|
func TestFocusHandler(t *testing.T) {
|
|
d := newTestDaemon(t)
|
|
|
|
if err := d.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer d.Stop()
|
|
|
|
args, _ := json.Marshal(FocusArgs{Minutes: 30})
|
|
resp := sendRequest(t, d.sockPath, Request{Action: "focus", Args: args})
|
|
if !resp.OK {
|
|
t.Fatalf("focus resp.OK = false, error = %q", resp.Error)
|
|
}
|
|
if !d.focus.IsActive() {
|
|
t.Error("focus should be active after handler")
|
|
}
|
|
if resp.FocusRemaining <= 0 {
|
|
t.Errorf("FocusRemaining = %v, want > 0", resp.FocusRemaining)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|