Files
pi-dashboard/main_test.go

206 lines
6.2 KiB
Go

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 }
// shellQuote must produce a POSIX-safe single-quoted argument.
func TestShellQuote(t *testing.T) {
if got := shellQuote("fix pi mcp subagent.json"); got != "'fix pi mcp subagent.json'" {
t.Fatalf("spaces: %s", got)
}
if got := shellQuote("it's a.json"); got != `'it'\''s a.json'` {
t.Fatalf("quote: %s", got)
}
}
// buildColumns marks the correct header for each sort mode.
func TestSortColumnMarker(t *testing.T) {
cols := buildColumns(sortStarted)
if cols[4].Title == "Started" || cols[4].Title == "" {
t.Fatalf("expected Started column to be marked, got %q", cols[4].Title)
}
if !strings.Contains(cols[4].Title, "▼") {
t.Fatalf("expected ▼ marker, got %q", cols[4].Title)
}
// other columns untouched
if cols[2].Title != "Folder" {
t.Fatalf("Folder column should be unmarked, got %q", cols[2].Title)
}
// activity mode has no column
for _, c := range buildColumns(sortActivity) {
if strings.Contains(c.Title, "▼") {
t.Fatalf("activity mode should not mark any column, got %q", c.Title)
}
}
}