175 lines
5.2 KiB
Go
175 lines
5.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 }
|