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