claude-failover/internal/watcher/session_watcher_test.go
Ubuntu c87145ea0b feat(watcher): Phase 2.1 — SessionWatcher goroutine
- internal/watcher: detecte fin de tache via signal file, prompt ❯, idle timeout
- state: ForEachWorking, SetStalled, SetActiveAccount, ActiveAccount
- config: WatcherConfig, DispatcherConfig, JanitorConfig, NotificationsConfig + defaults
- 5 tests unitaires, go test ./... -race OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:27:51 +00:00

183 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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