package dispatcher import ( "log" "os" "path/filepath" "testing" "forge.secuaas.ovh/olivier/claude-failover/internal/config" "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 } // TestParseFrontmatter verifies YAML frontmatter extraction. func TestParseFrontmatter(t *testing.T) { input := "---\ntitle: Fix bug\npriority: critical\n---\nDo the fix." fm, body := parseFrontmatter([]byte(input)) if fm.Title != "Fix bug" { t.Errorf("expected title 'Fix bug', got %q", fm.Title) } if fm.Priority != "critical" { t.Errorf("expected priority critical, got %q", fm.Priority) } if body != "Do the fix." { t.Errorf("expected body 'Do the fix.', got %q", body) } } // TestParseFrontmatterNoHeader handles files without a YAML header. func TestParseFrontmatterNoHeader(t *testing.T) { input := "Just plain content." fm, body := parseFrontmatter([]byte(input)) if fm.Title != "" { t.Errorf("expected empty title, got %q", fm.Title) } if body != "Just plain content." { t.Errorf("expected full body, got %q", body) } } // TestModelForPriority maps priority strings to model names. func TestModelForPriority(t *testing.T) { cases := []struct{ priority, want string }{ {"critical", "opus"}, {"CRITICAL", "opus"}, {"high", "sonnet"}, {"default", "sonnet"}, {"", "sonnet"}, } for _, c := range cases { if got := modelForPriority(c.priority); got != c.want { t.Errorf("modelForPriority(%q) = %q, want %q", c.priority, got, c.want) } } } // TestFindFreeSessionSkipsFailed verifies that recently-failed sessions are skipped. func TestFindFreeSessionSkipsFailed(t *testing.T) { tc := newMockTmux() tc.sessions["sess-1"] = true tc.sessions["sess-2"] = true s := state.New("") s.SetIdle("sess-1") s.SetFailed("sess-2") d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ Dedicated: []config.DedicatedSession{{Name: "sess-1"}, {Name: "sess-2"}}, Autonomous: config.AutonomousConfig{Max: 0}, }, }, logger: log.Default(), } got := d.findFreeSession() if got != "sess-1" { t.Errorf("expected sess-1, got %q", got) } } // TestFindFreeSessionMissingTmux skips sessions not in tmux. func TestFindFreeSessionMissingTmux(t *testing.T) { tc := newMockTmux() // sess-1 missing from tmux, sess-2 present and idle. tc.sessions["sess-2"] = true s := state.New("") s.SetIdle("sess-1") s.SetIdle("sess-2") d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ Dedicated: []config.DedicatedSession{{Name: "sess-1"}, {Name: "sess-2"}}, Autonomous: config.AutonomousConfig{Max: 0}, }, }, logger: log.Default(), } got := d.findFreeSession() if got != "sess-2" { t.Errorf("expected sess-2, got %q", got) } } // TestDispatchProject creates a task file, dispatches it, and checks state + rename. func TestDispatchProject(t *testing.T) { dir := t.TempDir() inbox := filepath.Join(dir, ".agent-queue", "inbox") os.MkdirAll(inbox, 0755) taskContent := "---\ntitle: My Task\npriority: high\n---\nDo the work." taskPath := filepath.Join(inbox, "task-001.md") os.WriteFile(taskPath, []byte(taskContent), 0644) tc := newMockTmux() tc.sessions["free-sess"] = true // Return ❯ prompt on first CapturePaneTail call (Claude is ready). tc.paneOutput["free-sess"] = "❯ " s := state.New("") s.SetIdle("free-sess") d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ Dedicated: []config.DedicatedSession{{Name: "free-sess", Project: dir}}, Autonomous: config.AutonomousConfig{Max: 0}, }, }, logger: log.Default(), } d.dispatchProject(inbox) if st := s.GetSession("free-sess"); st == nil || st.State != "working" { t.Errorf("expected session working after dispatch, got %v", st) } // Original file renamed to .dispatched. if _, err := os.Stat(taskPath + ".dispatched"); os.IsNotExist(err) { t.Error("expected .dispatched marker") } if _, err := os.Stat(taskPath); !os.IsNotExist(err) { t.Error("expected original task file to be renamed") } } // TestDispatchProjectNoFreeSession leaves the task untouched when no session is available. func TestDispatchProjectNoFreeSession(t *testing.T) { dir := t.TempDir() inbox := filepath.Join(dir, ".agent-queue", "inbox") os.MkdirAll(inbox, 0755) taskPath := filepath.Join(inbox, "task-002.md") os.WriteFile(taskPath, []byte("content"), 0644) tc := newMockTmux() // no sessions s := state.New("") d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ Autonomous: config.AutonomousConfig{Max: 0}, }, }, logger: log.Default(), } d.dispatchProject(inbox) // File must remain unchanged. if _, err := os.Stat(taskPath); os.IsNotExist(err) { t.Error("task file should remain when no session is free") } }