package main import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" ) // Simulates the remote `cat *.json` output: several pretty-printed, // multi-line JSON objects concatenated together. func TestReadRemoteStream(t *testing.T) { var sb strings.Builder for _, name := range []string{"alpha", "beta"} { s := AgentState{ Session: name, Machine: "test", Cwd: "/home/sam/proj/" + name, AgentStatus: "idle", SessionState: "open", LastSeenAt: strptr(time.Now().UTC().Format(time.RFC3339)), } b, _ := json.MarshalIndent(s, "", " ") sb.Write(b) sb.WriteString("\n") } dec := json.NewDecoder(strings.NewReader(sb.String())) var states []AgentState for { var s AgentState if err := dec.Decode(&s); err != nil { break } states = append(states, s) } if len(states) != 2 { t.Fatalf("expected 2 states, got %d", len(states)) } if states[1].Session != "beta" { t.Fatalf("expected beta second, got %s", states[1].Session) } } // A legacy state file (no heartbeat) should use file mtime as liveness. func TestLegacyMtimeFallback(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "legacy.json") legacy := `{"session":"legacy","status":"running"}` os.WriteFile(path, []byte(legacy), 0644) s, err := readStateFile(path) if err != nil { t.Fatal(err) } if s.LastSeenAt == nil || *s.LastSeenAt == "" { t.Fatal("expected mtime fallback to fill LastSeenAt") } if s.IsClosed(time.Hour) { t.Fatal("fresh file should not be closed") } } // Stale heartbeat => closed, regardless of graceful state. func TestStaleness(t *testing.T) { old := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339) fresh := time.Now().UTC().Format(time.RFC3339) stale := AgentState{SessionState: "open", LastSeenAt: &old} if !stale.IsClosed(45 * time.Second) { t.Fatal("old heartbeat should be closed") } if !stale.WasKilled(45 * time.Second) { t.Fatal("no closed_at + stale => killed") } graceful := AgentState{SessionState: "closed", ClosedAt: &old} if !graceful.IsClosed(45 * time.Second) { t.Fatal("closed state should be closed") } if graceful.WasKilled(45 * time.Second) { t.Fatal("graceful close should not be killed") } alive := AgentState{SessionState: "open", LastSeenAt: &fresh} if alive.IsClosed(45 * time.Second) { t.Fatal("fresh heartbeat should be live") } } // Sorting: live first, running before idle, then by activity. func TestSortSmart(t *testing.T) { now := time.Now().UTC() mk := func(name, status string, age time.Duration) AgentState { t := now.Add(-age).Format(time.RFC3339) return AgentState{Session: name, AgentStatus: status, SessionState: "open", LastSeenAt: &t} } agents := []AgentState{ mk("old-idle", "idle", 30*time.Minute), mk("fresh-running", "running", time.Minute), mk("old-closed", "idle", 10*time.Hour), mk("fresh-idle", "idle", 2*time.Minute), } sortAgents(agents, sortSmart, 45*time.Second) got := []string{} for _, a := range agents { got = append(got, a.Session) } want := []string{"fresh-running", "fresh-idle", "old-idle", "old-closed"} for i := range want { if got[i] != want[i] { t.Fatalf("sort mismatch at %d: got %v want %v", i, got, want) } } } // x toggles hiding of closed sessions both ways. func TestHideClosedToggle(t *testing.T) { now := time.Now().UTC().Format(time.RFC3339) live := AgentState{Session: "live", AgentStatus: "idle", SessionState: "open", LastSeenAt: &now, PID: 1} dead := AgentState{Session: "dead", AgentStatus: "idle", SessionState: "closed", LastSeenAt: &now, PID: 2} m := initialModel(defaultConfig()) m.agents = []AgentState{live, dead} m.updateTable() if len(m.view) != 2 { t.Fatalf("expected 2 shown initially, got %d", len(m.view)) } // press x → hide closed m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) mm := m2.(model) if !mm.hideClosed { t.Fatal("x should enable hideClosed") } if len(mm.view) != 1 || mm.view[0].Session != "live" { t.Fatalf("expected only live session after hide, got %v", mm.view) } // press x again → unhide m3, _ := mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) mmm := m3.(model) if mmm.hideClosed { t.Fatal("x should toggle hideClosed back off") } if len(mmm.view) != 2 { t.Fatalf("expected 2 shown after unhide, got %d", len(mmm.view)) } } // A legacy (pre-heartbeat) live session must not be marked lost within the // legacy window, even though it has no real heartbeat. func TestLegacyWindow(t *testing.T) { // mtime-based heartbeat, 5 minutes old: legacy window is 30m → live fiveMin := time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339) legacyLive := AgentState{Session: "old-ext-session", Status: "running", LastSeenAt: &fiveMin} if legacyLive.IsClosed(45 * time.Second) { t.Fatal("legacy session with 5m-old mtime should be live (30m window)") } // 2 hours old → closed twoHours := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339) legacyDead := AgentState{Session: "old-dead", Status: "running", LastSeenAt: &twoHours} if !legacyDead.IsClosed(45 * time.Second) { t.Fatal("legacy session with 2h-old mtime should be closed") } } func strptr(s string) *string { return &s }