//go:build !windows && linux package infra import ( "encoding/json" "os" "path/filepath" "testing" "time" ) // writeJSON marshals v and writes it to path, failing the test on error. func writeJSON(t *testing.T, path string, v any) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %q: %v", filepath.Dir(path), err) } b, err := json.Marshal(v) if err != nil { t.Fatalf("marshal: %v", err) } if err := os.WriteFile(path, b, 0o644); err != nil { t.Fatalf("write %q: %v", path, err) } } // touch sets the mtime of path to the given unix epoch seconds. func touch(t *testing.T, path string, epoch int64) { t.Helper() mt := time.Unix(epoch, 0) if err := os.Chtimes(path, mt, mt); err != nil { t.Fatalf("chtimes %q: %v", path, err) } } func TestListResumableClaudesFrom(t *testing.T) { t.Run("excluye sesion viva, incluye muertas con goal ordenadas por LastActive", func(t *testing.T) { dir := t.TempDir() sessionsDir := filepath.Join(dir, "sessions") goalsDir := filepath.Join(dir, "goals") // A LIVE session: real running PID (this test process) + its real // /proc starttime as procStart, so procIsAlive returns true. livePID := os.Getpid() liveStart, ok := procStartTime(livePID) if !ok { t.Fatalf("could not read procStartTime for self pid %d", livePID) } const liveSession = "11111111-aaaa-bbbb-cccc-000000000001" writeJSON(t, filepath.Join(sessionsDir, "9001.json"), sessionFile{ PID: livePID, SessionID: liveSession, Cwd: "/tmp/live", ProcStart: liveStart, Status: "busy", }) // A goal for the live session: must be EXCLUDED (already in fleet). liveGoal := filepath.Join(goalsDir, liveSession+".json") writeJSON(t, liveGoal, goalFile{Goal: "trabajo en curso", Emojis: "🔥", Rename: "vivo"}) touch(t, liveGoal, 5000) // A DEAD session with a goal: must be INCLUDED. No sessions/ entry, // so it can never be live. const deadOld = "22222222-aaaa-bbbb-cccc-000000000002" oldGoal := filepath.Join(goalsDir, deadOld+".json") writeJSON(t, oldGoal, goalFile{Goal: "objetivo antiguo", Emojis: "🛠️", Rename: "viejo"}) touch(t, oldGoal, 1000) // Another DEAD session with a goal, more recent: must come FIRST. const deadNew = "33333333-aaaa-bbbb-cccc-000000000003" newGoal := filepath.Join(goalsDir, deadNew+".json") writeJSON(t, newGoal, goalFile{Goal: "objetivo reciente", Rename: "nuevo"}) touch(t, newGoal, 4000) // A DEAD session WITHOUT a goal string: must be OMITTED. const deadEmpty = "44444444-aaaa-bbbb-cccc-000000000004" emptyGoal := filepath.Join(goalsDir, deadEmpty+".json") writeJSON(t, emptyGoal, goalFile{Goal: " ", Emojis: "💤"}) touch(t, emptyGoal, 6000) got, err := ListResumableClaudesFrom(dir) if err != nil { t.Fatalf("ListResumableClaudesFrom: %v", err) } if len(got) != 2 { t.Fatalf("got %d resumable, want 2: %+v", len(got), got) } // Order by LastActive desc: deadNew (4000) before deadOld (1000). if got[0].SessionID != deadNew { t.Errorf("got[0].SessionID = %q, want %q", got[0].SessionID, deadNew) } if got[1].SessionID != deadOld { t.Errorf("got[1].SessionID = %q, want %q", got[1].SessionID, deadOld) } // Live session must not appear. for _, r := range got { if r.SessionID == liveSession { t.Errorf("live session %q must be excluded", liveSession) } if r.SessionID == deadEmpty { t.Errorf("session without goal %q must be omitted", deadEmpty) } } // Field mapping for the most-recent record. if got[0].Goal != "objetivo reciente" { t.Errorf("got[0].Goal = %q", got[0].Goal) } if got[0].Name != "nuevo" { t.Errorf("got[0].Name = %q, want \"nuevo\"", got[0].Name) } if got[0].LastActive != 4000 { t.Errorf("got[0].LastActive = %d, want 4000", got[0].LastActive) } if got[1].Emojis != "🛠️" { t.Errorf("got[1].Emojis = %q", got[1].Emojis) } }) t.Run("dir de goals inexistente retorna slice vacio sin error", func(t *testing.T) { dir := t.TempDir() // no goals/ subdir got, err := ListResumableClaudesFrom(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(got) != 0 { t.Errorf("got %d, want 0", len(got)) } }) t.Run("cap a 40 resultados mas recientes", func(t *testing.T) { dir := t.TempDir() goalsDir := filepath.Join(dir, "goals") // 50 dead sessions with goals, mtimes 1..50. for i := 1; i <= 50; i++ { id := uuidLike(i) p := filepath.Join(goalsDir, id+".json") writeJSON(t, p, goalFile{Goal: "objetivo", Rename: id}) touch(t, p, int64(i)) } got, err := ListResumableClaudesFrom(dir) if err != nil { t.Fatalf("ListResumableClaudesFrom: %v", err) } if len(got) != 40 { t.Fatalf("got %d, want 40 (capped)", len(got)) } // Most recent first: LastActive should be 50 then descending. if got[0].LastActive != 50 { t.Errorf("got[0].LastActive = %d, want 50", got[0].LastActive) } if got[39].LastActive != 11 { t.Errorf("got[39].LastActive = %d, want 11", got[39].LastActive) } }) } // uuidLike builds a deterministic, unique filename stem for index i. func uuidLike(i int) string { const hex = "0123456789abcdef" b := []byte("00000000-0000-0000-0000-000000000000") // Fill the last 3 chars with i (i <= 50 fits in 2 hex digits, keep simple). b[len(b)-1] = hex[i%16] b[len(b)-2] = hex[(i/16)%16] b[len(b)-3] = hex[(i/256)%16] return string(b) }