- 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>
183 lines
4.5 KiB
Go
183 lines
4.5 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|