claude-failover/internal/watcher/session_watcher_test.go

185 lines
4.6 KiB
Go
Raw Normal View History

package watcher
import (
"log"
"os"
"path/filepath"
"testing"
"time"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
)
// mockTmux is a minimal in-memory tmux.Client for tests.
type mockTmux struct {
sessions map[string]bool
paneOutput map[string]string
sentKeys []string
}
func newMockTmux() *mockTmux {
return &mockTmux{
sessions: make(map[string]bool),
paneOutput: make(map[string]string),
}
}
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
func (m *mockTmux) KillSession(_ string) error { return nil }
func (m *mockTmux) SendKeys(_, keys string) error { m.sentKeys = append(m.sentKeys, keys); return nil }
func (m *mockTmux) SendEnter(_ string) error { m.sentKeys = append(m.sentKeys, "<ENTER>"); return nil }
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
func newTestWatcher(tc *mockTmux, s *state.State, signalDir string) *SessionWatcher {
return &SessionWatcher{
tmux: tc,
state: s,
done: make(chan string, 32),
interval: time.Second,
idleTimeout: 60 * time.Minute,
signalDir: signalDir,
logger: log.Default(),
}
}
// TestSignalFileTriggersDone verifies that agent-done-<session> causes idle + done signal.
func TestSignalFileTriggersDone(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-a"] = true
s := state.New("")
s.SetWorking("sess-a", "task-1")
w := newTestWatcher(tc, s, dir)
sig := filepath.Join(dir, "agent-done-sess-a")
if err := os.WriteFile(sig, []byte("done"), 0644); err != nil {
t.Fatal(err)
}
w.poll()
select {
case got := <-w.done:
if got != "sess-a" {
t.Errorf("expected sess-a on done, got %q", got)
}
default:
t.Fatal("expected done signal, got none")
}
if st := s.GetSession("sess-a"); st == nil || st.State != "idle" {
t.Errorf("expected sess-a idle, got %v", st)
}
if _, err := os.Stat(sig); !os.IsNotExist(err) {
t.Error("expected signal file to be deleted")
}
}
// TestPromptDetectionTriggersDone verifies that in pane output signals completion.
func TestPromptDetectionTriggersDone(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-b"] = true
tc.paneOutput["sess-b"] = "some output\n "
s := state.New("")
s.SetWorking("sess-b", "task-2")
w := newTestWatcher(tc, s, dir)
w.poll()
select {
case got := <-w.done:
if got != "sess-b" {
t.Errorf("expected sess-b, got %q", got)
}
default:
t.Fatal("expected done signal from prompt detection")
}
if st := s.GetSession("sess-b"); st == nil || st.State != "idle" {
t.Errorf("expected sess-b idle, got %v", st)
}
}
// TestSpinnerSuppressesCompletion verifies that an active spinner prevents false completion.
func TestSpinnerSuppressesCompletion(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-c"] = true
tc.paneOutput["sess-c"] = "doing work 5s · \n "
s := state.New("")
s.SetWorking("sess-c", "task-3")
w := newTestWatcher(tc, s, dir)
w.poll()
select {
case name := <-w.done:
t.Errorf("unexpected done signal for %q (spinner should suppress)", name)
default:
// Correct: no signal while spinner is active.
}
if st := s.GetSession("sess-c"); st == nil || st.State != "working" {
t.Errorf("expected sess-c still working, got %v", st)
}
}
// TestIdleTimeoutTriggersDone verifies that a session exceeding idleTimeout is completed.
func TestIdleTimeoutTriggersDone(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-d"] = true
tc.paneOutput["sess-d"] = "still running..."
s := state.New("")
s.SetWorking("sess-d", "task-4")
w := &SessionWatcher{
tmux: tc,
state: s,
done: make(chan string, 32),
interval: time.Second,
idleTimeout: 1 * time.Millisecond,
signalDir: dir,
logger: log.Default(),
}
time.Sleep(5 * time.Millisecond)
w.poll()
select {
case got := <-w.done:
if got != "sess-d" {
t.Errorf("expected sess-d, got %q", got)
}
default:
t.Fatal("expected done signal from idle timeout")
}
}
// TestHasSpinnerPatterns verifies spinner pattern detection.
func TestHasSpinnerPatterns(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"5s · running", true},
{"12s ⠋ working", true},
{" prompt only", false},
{"no spinner here", false},
}
for _, c := range cases {
if got := hasSpinner(c.input); got != c.want {
t.Errorf("hasSpinner(%q) = %v, want %v", c.input, got, c.want)
}
}
}