diff --git a/internal/serve/api/feed_history_extra_test.go b/internal/serve/api/feed_history_extra_test.go new file mode 100644 index 0000000..7a78866 --- /dev/null +++ b/internal/serve/api/feed_history_extra_test.go @@ -0,0 +1,323 @@ +package api + +import ( + "strconv" + "testing" + "time" +) + +// TestExtractTS_RFC3339 covers the RFC3339 (seconds-precision) branch. +func TestExtractTS_RFC3339(t *testing.T) { + want := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC) + got := extractTS(map[string]any{ + "ctm_timestamp": want.Format(time.RFC3339), + }) + if !got.Equal(want) { + t.Errorf("extractTS RFC3339 = %v, want %v", got, want) + } +} + +// TestExtractTS_RFC3339Nano covers the nano-precision fallback branch. +func TestExtractTS_RFC3339Nano(t *testing.T) { + want := time.Date(2026, 4, 21, 12, 0, 0, 123456789, time.UTC) + got := extractTS(map[string]any{ + "ctm_timestamp": want.Format(time.RFC3339Nano), + }) + if !got.Equal(want) { + t.Errorf("extractTS RFC3339Nano = %v, want %v", got, want) + } +} + +// TestExtractTS_Missing covers the "no ctm_timestamp" → zero time branch. +func TestExtractTS_Missing(t *testing.T) { + got := extractTS(map[string]any{"other_field": "abc"}) + if !got.IsZero() { + t.Errorf("extractTS missing = %v, want zero", got) + } +} + +// TestExtractTS_WrongType covers the type-assertion-failure branch +// (ctm_timestamp present but not a string). +func TestExtractTS_WrongType(t *testing.T) { + got := extractTS(map[string]any{"ctm_timestamp": 12345}) + if !got.IsZero() { + t.Errorf("extractTS wrong-type = %v, want zero", got) + } +} + +// TestExtractTS_BadFormat covers the "string but unparseable" branch: +// neither RFC3339 nor RFC3339Nano accepts → zero time. +func TestExtractTS_BadFormat(t *testing.T) { + got := extractTS(map[string]any{"ctm_timestamp": "not-a-timestamp"}) + if !got.IsZero() { + t.Errorf("extractTS bad-format = %v, want zero", got) + } +} + +// TestNestedBool covers all branches of nestedBool: missing top key, +// non-map intermediate, missing leaf, leaf-not-bool, leaf-true. +func TestNestedBool(t *testing.T) { + m := map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "isit": true, + }, + "scalar": "x", + }, + } + + if !nestedBool(m, "a", "b", "isit") { + t.Errorf("nestedBool(a.b.isit) = false, want true") + } + // missing top key + if nestedBool(m, "missing", "x") { + t.Errorf("nestedBool(missing.x) = true, want false") + } + // intermediate is not a map + if nestedBool(m, "a", "scalar", "deeper") { + t.Errorf("nestedBool(a.scalar.deeper) = true, want false") + } + // leaf missing + if nestedBool(m, "a", "b", "missing") { + t.Errorf("nestedBool(a.b.missing) = true, want false") + } + // leaf present but wrong type + m2 := map[string]any{"flag": "true-as-string"} + if nestedBool(m2, "flag") { + t.Errorf("nestedBool wrong-type = true, want false") + } + // no path: returns whether root coerces to bool — root is map → false + if nestedBool(m) { + t.Errorf("nestedBool empty path on map = true, want false") + } +} + +// TestSummariseHistoryInput_NoToolInput exercises the "tool_input +// missing or wrong type" early return. +func TestSummariseHistoryInput_NoToolInput(t *testing.T) { + if got := summariseHistoryInput(map[string]any{}, "Bash"); got != "" { + t.Errorf("summariseHistoryInput no input = %q, want \"\"", got) + } + if got := summariseHistoryInput(map[string]any{"tool_input": "not-a-map"}, "Bash"); got != "" { + t.Errorf("summariseHistoryInput wrong-type = %q, want \"\"", got) + } +} + +// TestSummariseHistoryInput_KnownToolPath returns the well-known +// primary input field via truncateToolInputField. +func TestSummariseHistoryInput_KnownToolPath(t *testing.T) { + raw := map[string]any{ + "tool_input": map[string]any{ + "command": "echo hello", + }, + } + if got := summariseHistoryInput(raw, "Bash"); got != "echo hello" { + t.Errorf("summariseHistoryInput Bash = %q, want \"echo hello\"", got) + } +} + +// TestSummariseHistoryInput_FallbackJSON exercises the json.Marshal +// fallback when the tool isn't well-known. +func TestSummariseHistoryInput_FallbackJSON(t *testing.T) { + raw := map[string]any{ + "tool_input": map[string]any{ + "foo": "bar", + }, + } + got := summariseHistoryInput(raw, "UnknownTool") + // Marshaled JSON should round-trip back something containing the key. + if got == "" { + t.Errorf("summariseHistoryInput fallback = \"\", want non-empty JSON") + } +} + +// TestSummariseHistoryResponse covers each switch arm of the response +// summariser: missing, string, map.output, map.is_error+error, +// map.is_error+no-error, map with arbitrary keys, empty map, and the +// "wrong type" default-fall-through. +func TestSummariseHistoryResponse(t *testing.T) { + t.Run("missing key", func(t *testing.T) { + if got := summariseHistoryResponse(map[string]any{}); got != "" { + t.Errorf("missing = %q, want \"\"", got) + } + }) + t.Run("string response", func(t *testing.T) { + raw := map[string]any{"tool_response": "ok"} + if got := summariseHistoryResponse(raw); got != "ok" { + t.Errorf("string = %q, want \"ok\"", got) + } + }) + t.Run("string response truncated", func(t *testing.T) { + long := make([]byte, historyInputMax+50) + for i := range long { + long[i] = 'x' + } + raw := map[string]any{"tool_response": string(long)} + got := summariseHistoryResponse(raw) + if len(got) == 0 || len(got) > historyInputMax { + t.Errorf("string truncated len=%d, want <= %d and > 0", len(got), historyInputMax) + } + }) + t.Run("map output single line", func(t *testing.T) { + raw := map[string]any{"tool_response": map[string]any{"output": "hello"}} + if got := summariseHistoryResponse(raw); got != "hello" { + t.Errorf("map.output single-line = %q, want \"hello\"", got) + } + }) + t.Run("map output multi-line takes first line", func(t *testing.T) { + raw := map[string]any{"tool_response": map[string]any{"output": "first\nsecond\nthird"}} + if got := summariseHistoryResponse(raw); got != "first" { + t.Errorf("map.output multiline = %q, want \"first\"", got) + } + }) + t.Run("map is_error with message", func(t *testing.T) { + raw := map[string]any{ + "tool_response": map[string]any{ + "is_error": true, + "error": "boom", + }, + } + if got := summariseHistoryResponse(raw); got != "boom" { + t.Errorf("map is_error+error = %q, want \"boom\"", got) + } + }) + t.Run("map is_error with no message", func(t *testing.T) { + raw := map[string]any{ + "tool_response": map[string]any{ + "is_error": true, + }, + } + if got := summariseHistoryResponse(raw); got != "error" { + t.Errorf("map is_error+no-error = %q, want \"error\"", got) + } + }) + t.Run("map empty falls through to keys empty", func(t *testing.T) { + raw := map[string]any{"tool_response": map[string]any{}} + if got := summariseHistoryResponse(raw); got != "" { + t.Errorf("empty map = %q, want \"\"", got) + } + }) + t.Run("map arbitrary keys → bracketed list", func(t *testing.T) { + raw := map[string]any{ + "tool_response": map[string]any{ + "foo": "x", + "bar": "y", + }, + } + got := summariseHistoryResponse(raw) + // Map iteration order is random, but the wrapper format is + // stable: starts with "[" and ends with "]". + if len(got) < 2 || got[0] != '[' || got[len(got)-1] != ']' { + t.Errorf("arbitrary keys = %q, want bracketed list", got) + } + }) + t.Run("unsupported response type", func(t *testing.T) { + raw := map[string]any{"tool_response": 42} + if got := summariseHistoryResponse(raw); got != "" { + t.Errorf("unsupported = %q, want \"\"", got) + } + }) +} + +// TestTruncateHistory covers the trim-and-truncate helper directly. +func TestTruncateHistory(t *testing.T) { + if got := truncateHistory(" hello "); got != "hello" { + t.Errorf("trim only = %q, want \"hello\"", got) + } + short := "abcdef" + if got := truncateHistory(short); got != "abcdef" { + t.Errorf("short pass-through = %q, want %q", got, short) + } + long := make([]byte, historyInputMax+10) + for i := range long { + long[i] = 'x' + } + got := truncateHistory(string(long)) + if len(got) != historyInputMax { + t.Errorf("truncated len = %d, want %d", len(got), historyInputMax) + } +} + +// TestSplitIDExt and TestIDLessThanExt exercise the cursor-id parser +// and comparator end-to-end including malformed inputs. +func TestSplitIDExt(t *testing.T) { + cases := []struct { + id string + wantNS int64 + wantSeq uint64 + }{ + {"1700000000-3", 1700000000, 3}, + {"42-0", 42, 0}, + {"", 0, 0}, // no '-' → zeroes + {"not-a-cursor", 0, 0}, // first segment unparseable, but '-' found + {"123-notnum", 123, 0}, // seq unparseable + } + for _, c := range cases { + ns, seq := splitIDExt(c.id) + if ns != c.wantNS || seq != c.wantSeq { + t.Errorf("splitIDExt(%q) = (%d, %d), want (%d, %d)", + c.id, ns, seq, c.wantNS, c.wantSeq) + } + } +} + +func TestIDLessThanExt(t *testing.T) { + // older nano → less. + if !idLessThanExt("100-0", "200-0") { + t.Error("100-0 < 200-0 should be true") + } + // equal nano → seq decides. + if !idLessThanExt("100-0", "100-1") { + t.Error("100-0 < 100-1 should be true") + } + if idLessThanExt("200-0", "100-9") { + t.Error("200-0 < 100-9 should be false") + } + // equal ids → not less. + if idLessThanExt("100-1", "100-1") { + t.Error("equal ids should not be less") + } +} + +// TestSynthEvent_BadJSON covers synthEvent's "json.Unmarshal failed" +// branch (returns ok=false). +func TestSynthEvent_BadJSON(t *testing.T) { + if _, ok := synthEvent("alpha", []byte("not-json")); ok { + t.Error("synthEvent should return false on invalid JSON") + } +} + +// TestSynthEvent_NoToolName covers the "tool_name missing" early return. +func TestSynthEvent_NoToolName(t *testing.T) { + line := []byte(`{"foo":"bar"}`) + if _, ok := synthEvent("alpha", line); ok { + t.Error("synthEvent should return false when tool_name is missing") + } +} + +// TestSynthEvent_Happy verifies the synthesised envelope: id is +// derived from ctm_timestamp, type is tool_call, payload contains the +// session+tool. +func TestSynthEvent_Happy(t *testing.T) { + ts := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC) + line := []byte(`{ + "tool_name":"Bash", + "tool_input":{"command":"echo hi"}, + "tool_response":{"output":"hi","is_error":false}, + "ctm_timestamp":"` + ts.Format(time.RFC3339) + `" + }`) + ev, ok := synthEvent("alpha", line) + if !ok { + t.Fatal("synthEvent returned false on valid line") + } + if ev.Session != "alpha" { + t.Errorf("Session = %q, want alpha", ev.Session) + } + if ev.Type != "tool_call" { + t.Errorf("Type = %q, want tool_call", ev.Type) + } + wantID := strconv.FormatInt(ts.UnixNano(), 10) + "-0" + if ev.ID != wantID { + t.Errorf("ID = %q, want %q", ev.ID, wantID) + } +} diff --git a/internal/serve/server_more_test.go b/internal/serve/server_more_test.go new file mode 100644 index 0000000..8541e5a --- /dev/null +++ b/internal/serve/server_more_test.go @@ -0,0 +1,392 @@ +package serve + +import ( + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/ctm/internal/serve/api" + "github.com/RandomCodeSpace/ctm/internal/serve/ingest" + "github.com/RandomCodeSpace/ctm/internal/serve/store" + "github.com/RandomCodeSpace/ctm/internal/session" +) + +// fakeCostStore is a tiny store.CostStore stub whose Range/Totals +// return a fixed error. Used to drive costSourceAdapter's err +// branches. Insert/Close are no-ops for the few tests that touch +// them (none here, but they're satisfied by interface). +type fakeCostStore struct { + rangeErr error + totalsErr error + rangePts []store.Point + totals store.Totals +} + +func (f *fakeCostStore) Insert([]store.Point) error { return nil } +func (f *fakeCostStore) Range(session string, since, until time.Time) ([]store.Point, error) { + if f.rangeErr != nil { + return nil, f.rangeErr + } + return f.rangePts, nil +} +func (f *fakeCostStore) Totals(since time.Time) (store.Totals, error) { + if f.totalsErr != nil { + return store.Totals{}, f.totalsErr + } + return f.totals, nil +} +func (f *fakeCostStore) Close() error { return nil } + +// TestCostSourceAdapter_RangeError covers the "Range returns err" +// fast-fail branch in costSourceAdapter.Range. +func TestCostSourceAdapter_RangeError(t *testing.T) { + wantErr := errors.New("boom") + a := costSourceAdapter{s: &fakeCostStore{rangeErr: wantErr}} + pts, err := a.Range("alpha", time.Now().Add(-time.Hour), time.Now()) + if err == nil || !errors.Is(err, wantErr) { + t.Errorf("Range err = %v, want %v", err, wantErr) + } + if pts != nil { + t.Errorf("Range pts = %v, want nil", pts) + } +} + +// TestCostSourceAdapter_TotalsError covers the "Totals returns err" +// fast-fail branch. +func TestCostSourceAdapter_TotalsError(t *testing.T) { + wantErr := errors.New("boom") + a := costSourceAdapter{s: &fakeCostStore{totalsErr: wantErr}} + got, err := a.Totals(time.Now().Add(-time.Hour)) + if err == nil || !errors.Is(err, wantErr) { + t.Errorf("Totals err = %v, want %v", err, wantErr) + } + if got != (api.CostTotals{}) { + t.Errorf("Totals = %+v, want zero", got) + } +} + +// TestLogsUUIDResolver_NilProjOrEmptyArgs covers the early-return +// guards on both Resolve methods. +func TestLogsUUIDResolver_NilProjOrEmptyArgs(t *testing.T) { + // nil projection. + r := logsUUIDResolver{proj: nil} + if _, ok := r.ResolveName("alpha"); ok { + t.Errorf("ResolveName(nil proj) should return false") + } + if _, ok := r.ResolveUUID("uuid"); ok { + t.Errorf("ResolveUUID(nil proj) should return false") + } + + // empty projection but non-empty proj — empty arg short-circuits. + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + writeSessionsJSON(t, path) + proj := ingest.New(path, &fakeTmuxClient{}) + proj.Reload() + r2 := logsUUIDResolver{proj: proj} + if _, ok := r2.ResolveName(""); ok { + t.Errorf("ResolveName(\"\") should return false") + } + if _, ok := r2.ResolveUUID(""); ok { + t.Errorf("ResolveUUID(\"\") should return false") + } +} + +// TestLogsUUIDResolver_ResolveName_NoUUIDStored covers the +// "session exists but UUID empty" branch. +func TestLogsUUIDResolver_ResolveName_NoUUIDStored(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + // Session has no UUID set. + writeSessionsJSON(t, path, &session.Session{Name: "alpha", Mode: "safe"}) + proj := ingest.New(path, &fakeTmuxClient{}) + proj.Reload() + + r := logsUUIDResolver{proj: proj} + if _, ok := r.ResolveName("alpha"); ok { + t.Errorf("ResolveName for empty-UUID session should return false") + } +} + +// TestLogsUUIDResolver_ResolveUUID_WorkdirFallbackHit covers the +// production workdir-derived fallback: when the requested uuid isn't +// in the projection's direct uuid map, ResolveUUID globs +// ~/.claude/projects//.jsonl, derives the session from the +// dirname (slashes → dashes), and returns its name. +func TestLogsUUIDResolver_ResolveUUID_WorkdirFallbackHit(t *testing.T) { + // Sandbox HOME so UserHomeDir + Glob hit our fake tree only. + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + // On Linux UserHomeDir consults $HOME, on macOS too; this is enough + // for CI and dev. Skip if UserHomeDir surprises us. + if got, err := os.UserHomeDir(); err != nil || got != homeDir { + t.Skipf("UserHomeDir didn't honour HOME override: got=%q err=%v", got, err) + } + + // Real workdir for the session. + workdir := "/srv/projects/codeiq" + // Claude projects layout: ~/.claude/projects//.jsonl + dirName := strings.ReplaceAll(workdir, "/", "-") + projDir := filepath.Join(homeDir, ".claude", "projects", dirName) + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + const uuid = "ffffffff-0000-0000-0000-000000000001" + if err := os.WriteFile(filepath.Join(projDir, uuid+".jsonl"), []byte{}, 0o600); err != nil { + t.Fatalf("write jsonl: %v", err) + } + + // Projection has the session WITHOUT this uuid (e.g. session + // rotated to a fresh claude session_id but the old transcript + // still exists on disk). The direct loop misses, the workdir + // fallback should still resolve to "codeiq" via the dirName. + dir := t.TempDir() + sessionsPath := filepath.Join(dir, "sessions.json") + writeSessionsJSON(t, sessionsPath, &session.Session{ + Name: "codeiq", + UUID: "current-uuid-different", + Workdir: workdir, + }) + proj := ingest.New(sessionsPath, &fakeTmuxClient{}) + proj.Reload() + + r := logsUUIDResolver{proj: proj} + got, ok := r.ResolveUUID(uuid) + if !ok { + t.Fatalf("ResolveUUID(orphan uuid via workdir fallback) returned false") + } + if got != "codeiq" { + t.Errorf("ResolveUUID = %q, want codeiq", got) + } +} + +// TestLogsUUIDResolver_ResolveUUID_FallbackHitButNoSessionMatch covers +// the case where the glob matches a single file but no projection +// session has a workdir whose dashed form equals the dirname → false. +func TestLogsUUIDResolver_ResolveUUID_FallbackHitButNoSessionMatch(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + if got, err := os.UserHomeDir(); err != nil || got != homeDir { + t.Skipf("UserHomeDir didn't honour HOME override: got=%q err=%v", got, err) + } + + dirName := "-srv-projects-codeiq" + projDir := filepath.Join(homeDir, ".claude", "projects", dirName) + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + const uuid = "fffffffe-0000-0000-0000-000000000001" + if err := os.WriteFile(filepath.Join(projDir, uuid+".jsonl"), []byte{}, 0o600); err != nil { + t.Fatalf("write jsonl: %v", err) + } + + // Projection has a session but its workdir doesn't match the dashed + // form ("/totally/different"). + dir := t.TempDir() + sessionsPath := filepath.Join(dir, "sessions.json") + writeSessionsJSON(t, sessionsPath, &session.Session{ + Name: "alpha", + UUID: "u-alpha", + Workdir: "/totally/different", + }) + proj := ingest.New(sessionsPath, &fakeTmuxClient{}) + proj.Reload() + + r := logsUUIDResolver{proj: proj} + if got, ok := r.ResolveUUID(uuid); ok { + t.Errorf("ResolveUUID = (%q, true), want false (no workdir match)", got) + } +} + +// TestSessionSourceAdapter_LastCheckpointAt_RealRepo covers the +// "cps[0].TS parses cleanly → return non-zero time" success branch +// of LastCheckpointAt. Requires git in PATH; skipped if absent. +func TestSessionSourceAdapter_LastCheckpointAt_RealRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skipf("git not in PATH: %v", err) + } + + // Build a real git repo with one checkpoint commit so + // CheckpointsCache.Get returns a populated slice with an RFC3339 TS. + repoDir := t.TempDir() + gitInit(t, repoDir) + if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + gitRun(t, repoDir, "add", ".") + gitRun(t, repoDir, "commit", "-m", "checkpoint: pre-yolo 2026-04-21T12:00:00") + + // Wire a projection with this workdir and a session source adapter. + sessionsPath := filepath.Join(t.TempDir(), "sessions.json") + writeSessionsJSON(t, sessionsPath, &session.Session{ + Name: "alpha", UUID: "u-alpha", Workdir: repoDir, + }) + proj := ingest.New(sessionsPath, &fakeTmuxClient{}) + proj.Reload() + + a := sessionSourceAdapter{proj: proj, cpCache: api.NewCheckpointsCache()} + ts, ok := a.LastCheckpointAt("alpha") + if !ok { + t.Fatalf("LastCheckpointAt returned ok=false on real checkpointed repo") + } + if ts.IsZero() { + t.Errorf("LastCheckpointAt ts is zero, want non-zero") + } +} + +// gitInit / gitRun are minimal shell-out helpers for the repo fixture. +// They mirror the helpers in internal/serve/git/checkpoints_test.go. +func gitInit(t *testing.T, dir string) { + t.Helper() + gitRun(t, dir, "init", "-q") + gitRun(t, dir, "config", "user.email", "test@example.com") + gitRun(t, dir, "config", "user.name", "Test") + // Some hosts default to gpgsign=true; force off so commits don't + // hang waiting for a signing agent we don't have. + gitRun(t, dir, "config", "commit.gpgsign", "false") +} + +// TestNew_DefaultsAllUnset covers the four `if path == ""` branches in +// New() that fall through to config.SessionsPath / TmuxConfPath / +// Dir()/logs / /tmp/ctm-statusline. Sandboxes HOME so the cost db is +// written into a temp tree rather than the dev's real ~/.config/ctm. +func TestNew_DefaultsAllUnset(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + if got, err := os.UserHomeDir(); err != nil || got != homeDir { + t.Skipf("UserHomeDir didn't honour HOME override: got=%q err=%v", got, err) + } + // config.Dir uses UserHomeDir → ~/.config/ctm. Pre-create so + // OpenCostStore doesn't trip on the parent. + if err := os.MkdirAll(filepath.Join(homeDir, ".config", "ctm"), 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + + port := pickFreePort(t) + srv, err := New(Options{ + Port: port, + Version: "vDef", + Token: testToken, + // Intentionally leave SessionsPath / TmuxConfPath / LogDir / + // StatuslineDumpDir empty — the empty-path branches inside + // New() should fall through to config defaults. + }) + if err != nil { + t.Fatalf("New defaults: %v", err) + } + t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() }) + + if !strings.HasPrefix(srv.logDir, homeDir) { + t.Errorf("logDir = %q, want under %q", srv.logDir, homeDir) + } +} + +// TestNew_PortInUseByNonCtm covers the "port bound by foreign listener" +// branch (lines 170-176). We bind a vanilla TCP listener first, then +// call New() against the same port — it should fail with the bind +// error wrapped, NOT ErrAlreadyRunning (probeIsCtmServe sees a non-ctm +// listener). +func TestNew_PortInUseByNonCtm(t *testing.T) { + port := pickFreePort(t) + // Bind a TCP listener that doesn't speak ctm-serve's healthz. + ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)) + if err != nil { + t.Fatalf("bind decoy: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + _, err = New(Options{ + Port: port, + Version: "vClash", + Token: testToken, + SessionsPath: filepath.Join(t.TempDir(), "sessions.json"), + TmuxConfPath: filepath.Join(t.TempDir(), "tmux.conf"), + LogDir: filepath.Join(t.TempDir(), "logs"), + StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"), + }) + if err == nil { + t.Fatal("New on busy port returned nil error, want bind failure") + } + if errors.Is(err, ErrAlreadyRunning) { + t.Errorf("err = ErrAlreadyRunning; want non-ctm bind-failure wrap. got=%v", err) + } +} + +// TestEventsSessionRoute exercises the GET /events/session/{name} +// closure registered in registerRoutes — the line counted at 779. We +// can't keep the SSE connection open for long, but a 200 + initial +// retry frame is enough to record coverage on the route. +func TestEventsSessionRoute(t *testing.T) { + port := pickFreePort(t) + srv, err := New(Options{ + Port: port, + Version: "vSSE", + Token: testToken, + SessionsPath: filepath.Join(t.TempDir(), "sessions.json"), + TmuxConfPath: filepath.Join(t.TempDir(), "tmux.conf"), + LogDir: filepath.Join(t.TempDir(), "logs"), + StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() }) + + mux := http.NewServeMux() + srv.registerRoutes(mux) + + // httptest.NewRecorder doesn't support Flush detection, but for SSE + // we just want the handler entered and response headers written. + // Use a real test server so the underlying Hijack/Flush works. + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/events/session/alpha", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Authorization", "Bearer "+testToken) + + // Use a client with an aggressive timeout so the long-lived SSE + // loop returns control to the test promptly. + client := &http.Client{Timeout: 200 * time.Millisecond} + resp, err := client.Do(req) + if err != nil { + // A timeout-on-read is fine — the route was hit. Surface only + // non-timeout failures. + if !strings.Contains(err.Error(), "Timeout") && + !strings.Contains(err.Error(), "deadline exceeded") && + !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("Do: %v", err) + } + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + // Force a fully self-contained env so user-level git config (eg. + // commit signing, hooks) can't leak in from the dev's machine. + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com", + "GIT_CONFIG_GLOBAL=/dev/null", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} diff --git a/internal/session/state_extra_test.go b/internal/session/state_extra_test.go new file mode 100644 index 0000000..5778c56 --- /dev/null +++ b/internal/session/state_extra_test.go @@ -0,0 +1,242 @@ +package session_test + +import ( + "os" + "path/filepath" + "sort" + "testing" + "time" + + "github.com/RandomCodeSpace/ctm/internal/session" +) + +// TestStoreUpdateHealth covers the happy path: the session's +// LastHealthStatus + LastHealthAt are stamped, persisted, and visible +// via Get on the next call. +func TestStoreUpdateHealth(t *testing.T) { + st := newStore(t) + if err := st.Save(session.New("alpha", "/work", "safe")); err != nil { + t.Fatalf("Save: %v", err) + } + before := time.Now().UTC().Add(-time.Second) + if err := st.UpdateHealth("alpha", "ok"); err != nil { + t.Fatalf("UpdateHealth: %v", err) + } + got, err := st.Get("alpha") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.LastHealthStatus != "ok" { + t.Errorf("LastHealthStatus = %q, want ok", got.LastHealthStatus) + } + if got.LastHealthAt.Before(before) { + t.Errorf("LastHealthAt = %v, want >= %v", got.LastHealthAt, before) + } + // Subsequent update overwrites. + if err := st.UpdateHealth("alpha", "degraded"); err != nil { + t.Fatalf("UpdateHealth degraded: %v", err) + } + got2, _ := st.Get("alpha") + if got2.LastHealthStatus != "degraded" { + t.Errorf("LastHealthStatus after second update = %q, want degraded", got2.LastHealthStatus) + } + if !got2.LastHealthAt.After(got.LastHealthAt) && !got2.LastHealthAt.Equal(got.LastHealthAt) { + // Within the same wall-clock tick they may compare equal; only + // fail if we somehow went backwards. + t.Errorf("LastHealthAt regressed: %v < %v", got2.LastHealthAt, got.LastHealthAt) + } +} + +// TestStoreUpdateHealth_Missing exercises the "session not found" branch. +func TestStoreUpdateHealth_Missing(t *testing.T) { + st := newStore(t) + if err := st.UpdateHealth("ghost", "ok"); err == nil { + t.Error("expected error updating health on missing session") + } +} + +// TestStoreUpdateAttached covers the happy path: LastAttachedAt is +// stamped to a non-zero, recent UTC timestamp. +func TestStoreUpdateAttached(t *testing.T) { + st := newStore(t) + if err := st.Save(session.New("alpha", "/work", "safe")); err != nil { + t.Fatalf("Save: %v", err) + } + before := time.Now().UTC().Add(-time.Second) + if err := st.UpdateAttached("alpha"); err != nil { + t.Fatalf("UpdateAttached: %v", err) + } + got, err := st.Get("alpha") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.LastAttachedAt.IsZero() { + t.Error("LastAttachedAt is zero, want non-zero after UpdateAttached") + } + if got.LastAttachedAt.Before(before) { + t.Errorf("LastAttachedAt = %v, want >= %v", got.LastAttachedAt, before) + } +} + +// TestStoreUpdateAttached_Missing exercises the "session not found" branch. +func TestStoreUpdateAttached_Missing(t *testing.T) { + st := newStore(t) + if err := st.UpdateAttached("ghost"); err == nil { + t.Error("expected error updating attached on missing session") + } +} + +// TestStoreUpdateMode_Missing covers the not-found branch in UpdateMode +// (UpdateMode happy path is already covered by TestStoreUpdateMode). +func TestStoreUpdateMode_Missing(t *testing.T) { + st := newStore(t) + if err := st.UpdateMode("ghost", "yolo"); err == nil { + t.Error("expected error updating mode on missing session") + } +} + +// TestStoreNames_Empty + Populated covers the Names() exposure used by +// shell completions. +func TestStoreNames(t *testing.T) { + st := newStore(t) + + // Empty store first. + got, err := st.Names() + if err != nil { + t.Fatalf("Names empty: %v", err) + } + if len(got) != 0 { + t.Errorf("Names empty = %v, want []", got) + } + + // Populate. + st.Save(session.New("alpha", "/work", "safe")) + st.Save(session.New("beta", "/work", "safe")) + st.Save(session.New("gamma", "/work", "yolo")) + + got, err = st.Names() + if err != nil { + t.Fatalf("Names: %v", err) + } + sort.Strings(got) + want := []string{"alpha", "beta", "gamma"} + if len(got) != len(want) { + t.Fatalf("Names len = %d, want %d (%v)", len(got), len(want), got) + } + for i, n := range want { + if got[i] != n { + t.Errorf("Names[%d] = %q, want %q", i, got[i], n) + } + } +} + +// TestStoreDelete_Missing covers Delete's "session not found" branch. +func TestStoreDelete_Missing(t *testing.T) { + st := newStore(t) + if err := st.Delete("ghost"); err == nil { + t.Error("expected error deleting missing session") + } +} + +// TestStoreRename_Missing covers Rename's "session not found" branch. +func TestStoreRename_Missing(t *testing.T) { + st := newStore(t) + if err := st.Rename("ghost", "newname"); err == nil { + t.Error("expected error renaming missing session") + } +} + +// TestStoreRename_Conflict covers the "newName already exists" branch. +func TestStoreRename_Conflict(t *testing.T) { + st := newStore(t) + st.Save(session.New("alpha", "/work", "safe")) + st.Save(session.New("beta", "/work", "safe")) + if err := st.Rename("alpha", "beta"); err == nil { + t.Error("expected error renaming over existing session") + } + // Both should still exist. + if _, err := st.Get("alpha"); err != nil { + t.Errorf("alpha should still exist after failed rename: %v", err) + } + if _, err := st.Get("beta"); err != nil { + t.Errorf("beta should still exist after failed rename: %v", err) + } +} + +// TestStoreRename_InvalidNewName covers the ValidateName failure branch. +func TestStoreRename_InvalidNewName(t *testing.T) { + st := newStore(t) + st.Save(session.New("alpha", "/work", "safe")) + if err := st.Rename("alpha", "bad/name"); err == nil { + t.Error("expected error renaming to invalid name") + } +} + +// TestStoreBackup_NoFile covers backupLocked's os.IsNotExist branch: +// before any Save() the sessions.json doesn't exist, Backup() returns +// ("", nil) — used by DeleteAll on a fresh install to skip backing up. +func TestStoreBackup_NoFile(t *testing.T) { + st := newStore(t) + path, err := st.Backup() + if err != nil { + t.Fatalf("Backup on empty store: %v", err) + } + if path != "" { + t.Errorf("Backup empty store = %q, want \"\"", path) + } +} + +// TestStoreDeleteAll_NoBackupOnEmpty covers DeleteAll when there's no +// existing sessions file: backupLocked returns ("", nil) and DeleteAll +// proceeds to write the empty state. (Hits the success path of the +// `if backupPath, err := ...; err == nil && backupPath != ""` guard.) +func TestStoreDeleteAll_NoBackupOnEmpty(t *testing.T) { + st := newStore(t) + if err := st.DeleteAll(); err != nil { + t.Fatalf("DeleteAll on empty store: %v", err) + } + // Result should still be a usable empty store. + list, err := st.List() + if err != nil { + t.Fatalf("List after DeleteAll: %v", err) + } + if len(list) != 0 { + t.Errorf("expected 0 sessions after DeleteAll on empty store, got %d", len(list)) + } +} + +// TestStoreDeleteAll_BacksUpExisting confirms DeleteAll creates a +// .bak. file when sessions.json existed prior. This makes +// the backupLocked-success branch in DeleteAll observable. +func TestStoreDeleteAll_BacksUpExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + st := session.NewStore(path) + + if err := st.Save(session.New("alpha", "/work", "safe")); err != nil { + t.Fatalf("Save: %v", err) + } + if err := st.DeleteAll(); err != nil { + t.Fatalf("DeleteAll: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + found := false + for _, e := range entries { + if filepath.Ext(e.Name()) == "" { + continue + } + // Look for a sibling with .bak. in its name. + if name := e.Name(); len(name) > len("sessions.json.bak.") && + name[:len("sessions.json.bak.")] == "sessions.json.bak." { + found = true + break + } + } + if !found { + t.Error("expected DeleteAll to leave a sessions.json.bak. file") + } +} diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx new file mode 100644 index 0000000..6645cae --- /dev/null +++ b/ui/src/App.test.tsx @@ -0,0 +1,348 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { TOKEN_KEY } from "@/lib/api"; + +/* + * App.test.tsx — integration test of the top-level shell. + * + * App.tsx wires the global providers (Theme -> design-system bridge -> + * QueryClient -> Auth -> Sse -> AuthGate) and a `createBrowserRouter`. + * For tests we: + * + * 1. drive route resolution through `window.history.pushState` rather + * than a MemoryRouter — RouterProvider takes a router instance, so + * we have to use the real (browser) history API the createBrowserRouter + * reads from. jsdom implements it. + * 2. mock the heavy children (Dashboard, DoctorPanel, FeedFullscreen, + * SessionDetail) — those have their own dedicated suites and pull in + * SSE/network on mount, which would explode in this shell-level test. + * 3. mock SseProvider so we don't actually open EventSource / use + * fetch-event-source. We expose a fake `useSseStatus` so + * ConnectionBanner can read the connected flag the same way it + * does in production. + * 4. stub `globalThis.fetch` for /api/auth/status so AuthGate resolves + * either to (unauthenticated) or to + * (authenticated). All other endpoints return 404 — the route + * stubs never call them. + */ + +vi.mock("@/components/SseProvider", () => { + let connected = true; + return { + SseProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + useSseStatus: () => ({ connected }), + /** Test-only escape hatch — flip the SSE banner state. */ + __setSseConnected: (v: boolean) => { + connected = v; + }, + }; +}); + +vi.mock("@/routes/Dashboard", () => ({ + Dashboard: () =>
dashboard
, +})); + +vi.mock("@/routes/DoctorPanel", () => ({ + DoctorPanel: () =>
doctor
, +})); + +vi.mock("@/routes/FeedFullscreen", () => ({ + FeedFullscreen: () =>
feed-fs
, +})); + +// LoginForm renders a real
— much lighter than a stub here, but +// we lean on a stub so we don't need to wire useLogin's mutation path. +vi.mock("@/routes/LoginForm", () => ({ + LoginForm: ({ onSwitchToSignup }: { onSwitchToSignup?: () => void }) => ( +
+ login + {onSwitchToSignup && ( + + )} +
+ ), +})); + +vi.mock("@/routes/SignupForm", () => ({ + SignupForm: ({ onSwitchToLogin }: { onSwitchToLogin?: () => void }) => ( +
+ signup + {onSwitchToLogin && ( + + )} +
+ ), +})); + +// design-system pulls in real CSS in App.tsx — the bridge component +// only needs ToastRegion + ThemeProvider. Stub them so jsdom doesn't +// have to parse design-system styles. +vi.mock("@ossrandom/design-system", () => ({ + ThemeProvider: ({ + mode, + children, + }: { + mode: "light" | "dark"; + children: ReactNode; + }) => ( +
+ {children} +
+ ), + ToastRegion: () =>
, +})); + +// design-system styles import — vite/vitest treats `.css` as opaque +// modules in node, but the bare-specifier resolution needs a stub so +// the dynamic import doesn't try to walk into the package. +vi.mock("@ossrandom/design-system/styles.css", () => ({})); + +// Lazy import so the vi.mock factories above are hoisted before the +// module under test pulls them in. +async function loadApp() { + const mod = await import("@/App"); + return mod.App; +} + +interface AuthStatus { + registered: boolean; + authenticated: boolean; +} + +interface FetchState { + authStatus?: AuthStatus | "error"; +} + +function buildFetchStub(state: FetchState = {}): typeof globalThis.fetch { + return vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/auth/status")) { + if (state.authStatus === "error") { + return new Response("boom", { status: 500 }); + } + const body: AuthStatus = state.authStatus ?? { + registered: true, + authenticated: true, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof globalThis.fetch; +} + +/** + * createBrowserRouter reads from window.history. Reset the URL to a + * fresh path before each test so route assertions are deterministic. + */ +function navigateTo(path: string) { + window.history.pushState({}, "", path); +} + +describe("App", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + localStorage.setItem(TOKEN_KEY, "test-token"); + + // jsdom doesn't implement matchMedia. ThemeProvider uses it to + // resolve "system" preference into light/dark. + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); + } + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + localStorage.clear(); + // Walk back to "/" so the next test's createBrowserRouter + // initialises at a known path. + window.history.pushState({}, "", "/"); + vi.restoreAllMocks(); + // vi.resetModules so each test re-imports App with a fresh + // createBrowserRouter (its `router` constant is module-level). + vi.resetModules(); + }); + + it("renders Dashboard at /", async () => { + globalThis.fetch = buildFetchStub(); + navigateTo("/"); + const App = await loadApp(); + render(); + expect(await screen.findByTestId("dashboard-stub")).toBeInTheDocument(); + // Provider tree wired around it. + expect(screen.getByTestId("ds-theme")).toHaveAttribute("data-mode", "dark"); + expect(screen.getByTestId("toast-region")).toBeInTheDocument(); + expect(screen.getByTestId("sse-provider-stub")).toBeInTheDocument(); + }); + + it("renders Dashboard at /s/:name (session route)", async () => { + globalThis.fetch = buildFetchStub(); + navigateTo("/s/alpha"); + const App = await loadApp(); + render(); + expect(await screen.findByTestId("dashboard-stub")).toBeInTheDocument(); + }); + + it("renders Dashboard at the /s/:name/* tab variants", async () => { + globalThis.fetch = buildFetchStub(); + for (const path of [ + "/s/alpha/feed", + "/s/alpha/checkpoints", + "/s/alpha/pane", + "/s/alpha/subagents", + "/s/alpha/teams", + "/s/alpha/meta", + ]) { + navigateTo(path); + const App = await loadApp(); + const { unmount } = render(); + expect(await screen.findByTestId("dashboard-stub")).toBeInTheDocument(); + unmount(); + vi.resetModules(); + } + }); + + it("renders FeedFullscreen at /feed", async () => { + globalThis.fetch = buildFetchStub(); + navigateTo("/feed"); + const App = await loadApp(); + render(); + expect( + await screen.findByTestId("feed-fullscreen-stub"), + ).toBeInTheDocument(); + }); + + it("renders DoctorPanel at /doctor", async () => { + globalThis.fetch = buildFetchStub(); + navigateTo("/doctor"); + const App = await loadApp(); + render(); + expect(await screen.findByTestId("doctor-stub")).toBeInTheDocument(); + }); + + it("matches the catchall route for unknown URLs without throwing", async () => { + globalThis.fetch = buildFetchStub(); + navigateTo("/this/does/not/exist"); + const App = await loadApp(); + // The router's `*` route uses . We don't + // assert on the URL change because react-router v7 + jsdom don't + // settle the redirect reliably inside a test render. We DO assert + // the App didn't crash and the providers still mounted — i.e., the + // catch-all branch in the routes array is exercised without an + // unhandled error from a missing match. + expect(() => render()).not.toThrow(); + await waitFor(() => { + expect(screen.getByTestId("sse-provider-stub")).toBeInTheDocument(); + }); + }); + + it("renders LoginForm when the daemon reports registered & unauthenticated", async () => { + globalThis.fetch = buildFetchStub({ + authStatus: { registered: true, authenticated: false }, + }); + navigateTo("/"); + const App = await loadApp(); + render(); + expect(await screen.findByTestId("login-stub")).toBeInTheDocument(); + // Dashboard is gated out. + expect(screen.queryByTestId("dashboard-stub")).not.toBeInTheDocument(); + }); + + it("renders SignupForm when the daemon reports no registered user", async () => { + globalThis.fetch = buildFetchStub({ + authStatus: { registered: false, authenticated: false }, + }); + navigateTo("/"); + const App = await loadApp(); + render(); + expect(await screen.findByTestId("signup-stub")).toBeInTheDocument(); + expect(screen.queryByTestId("dashboard-stub")).not.toBeInTheDocument(); + }); + + it("AuthGate user can switch from signup to login via the override", async () => { + globalThis.fetch = buildFetchStub({ + authStatus: { registered: false, authenticated: false }, + }); + navigateTo("/"); + const App = await loadApp(); + render(); + + expect(await screen.findByTestId("signup-stub")).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: /switch-to-login/i }), + ); + expect(await screen.findByTestId("login-stub")).toBeInTheDocument(); + }); + + it("AuthGate shows a daemon-error banner when /api/auth/status fails", async () => { + globalThis.fetch = buildFetchStub({ authStatus: "error" }); + navigateTo("/"); + const App = await loadApp(); + render(); + expect( + await screen.findByText(/could not reach the daemon/i), + ).toBeInTheDocument(); + expect(screen.queryByTestId("dashboard-stub")).not.toBeInTheDocument(); + }); + + it("ConnectionBanner is hidden when SSE is connected", async () => { + globalThis.fetch = buildFetchStub(); + navigateTo("/"); + const App = await loadApp(); + render(); + await screen.findByTestId("dashboard-stub"); + expect( + screen.queryByText(/connection lost/i), + ).not.toBeInTheDocument(); + }); + + it("ConnectionBanner surfaces when SSE drops", async () => { + const sse = await import("@/components/SseProvider"); + (sse as unknown as { __setSseConnected: (v: boolean) => void }).__setSseConnected( + false, + ); + globalThis.fetch = buildFetchStub(); + navigateTo("/"); + const App = await loadApp(); + render(); + await screen.findByTestId("dashboard-stub"); + expect(screen.getByText(/connection lost/i)).toBeInTheDocument(); + // Reset so other tests see a connected SSE. + (sse as unknown as { __setSseConnected: (v: boolean) => void }).__setSseConnected( + true, + ); + }); +}); diff --git a/ui/src/components/QuotaStrip.test.tsx b/ui/src/components/QuotaStrip.test.tsx new file mode 100644 index 0000000..c1e0213 --- /dev/null +++ b/ui/src/components/QuotaStrip.test.tsx @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QuotaStrip } from "@/components/QuotaStrip"; +import type { Quota } from "@/hooks/useQuota"; + +/* + * QuotaStrip is a pure presentational component. It reads from + * `useQuota` (a tanstack query hook) and renders two QuotaBars: 5h + + * Weekly. We mock useQuota directly — no QueryClient needed — and + * exercise every quota state: + * + * - undefined data -> "—" placeholders, no progressbar fill + * - low (<75%) -> bg-fg-muted + * - mid (>=75%) -> bg-accent-gold + * - high (>=90%) -> bg-alert-ember + * - over (>100%) -> clamped to 100% on the bar but rounded display value + * - reset timers render with relativeFuture and a tooltip + * + * Tests use a fixed `Date.now()` via vi.useFakeTimers (no setInterval — + * format helpers compute once on render) so the relative-time strings + * are deterministic. All `vi.useFakeTimers` ops are scoped per test + * and restored in afterEach so no flake leaks across the suite. + */ + +const mockUseQuota = vi.fn(); + +vi.mock("@/hooks/useQuota", () => ({ + useQuota: () => mockUseQuota(), +})); + +function setQuota(data: Quota | null | undefined) { + mockUseQuota.mockReturnValue({ data }); +} + +const NOW = new Date("2026-04-25T12:00:00Z"); + +describe("QuotaStrip", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + mockUseQuota.mockReset(); + }); + + it("renders both bars with placeholders when quota data is undefined", () => { + setQuota(undefined); + render(); + + // Region wrapper. + const region = screen.getByRole("region", { name: /rate limit usage/i }); + expect(region).toBeInTheDocument(); + + // Two progressbars — one per label. + const bars = screen.getAllByRole("progressbar"); + expect(bars).toHaveLength(2); + + // Labels in correct order. + expect(screen.getByText("5h")).toBeInTheDocument(); + expect(screen.getByText("Weekly")).toBeInTheDocument(); + + // No aria-valuenow when pct is unknown. + bars.forEach((b) => { + expect(b).not.toHaveAttribute("aria-valuenow"); + }); + // Two "—" placeholders, one per bar. + expect(screen.getAllByText("—")).toHaveLength(2); + }); + + it("renders both bars with placeholders when data is null", () => { + setQuota(null); + render(); + expect(screen.getAllByRole("progressbar")).toHaveLength(2); + expect(screen.getAllByText("—")).toHaveLength(2); + }); + + it("renders low/nominal usage with the muted fill colour", () => { + setQuota({ + five_hr_pct: 12, + weekly_pct: 40, + five_hr_resets_at: "2026-04-25T16:00:00Z", + weekly_resets_at: "2026-04-30T00:00:00Z", + }); + render(); + + const bars = screen.getAllByRole("progressbar"); + expect(bars[0]).toHaveAttribute("aria-valuenow", "12"); + expect(bars[1]).toHaveAttribute("aria-valuenow", "40"); + + // Inner fill div — first child of each progressbar. + const fill5h = bars[0].firstElementChild as HTMLElement; + const fillWk = bars[1].firstElementChild as HTMLElement; + expect(fill5h.className).toContain("bg-fg-muted"); + expect(fillWk.className).toContain("bg-fg-muted"); + expect(fill5h.style.width).toBe("12%"); + expect(fillWk.style.width).toBe("40%"); + + // Display percentages. + expect(screen.getByText("12%")).toBeInTheDocument(); + expect(screen.getByText("40%")).toBeInTheDocument(); + }); + + it("renders mid usage (>=75%) with the gold warning colour", () => { + setQuota({ + five_hr_pct: 75, + weekly_pct: 80, + five_hr_resets_at: "2026-04-25T16:00:00Z", + weekly_resets_at: "2026-04-30T00:00:00Z", + }); + render(); + + const bars = screen.getAllByRole("progressbar"); + const fill5h = bars[0].firstElementChild as HTMLElement; + const fillWk = bars[1].firstElementChild as HTMLElement; + expect(fill5h.className).toContain("bg-accent-gold"); + expect(fillWk.className).toContain("bg-accent-gold"); + expect(screen.getByText("75%")).toBeInTheDocument(); + expect(screen.getByText("80%")).toBeInTheDocument(); + }); + + it("renders high usage (>=90%) with the ember critical colour", () => { + setQuota({ + five_hr_pct: 92, + weekly_pct: 99, + five_hr_resets_at: "2026-04-25T16:00:00Z", + weekly_resets_at: "2026-04-30T00:00:00Z", + }); + render(); + + const bars = screen.getAllByRole("progressbar"); + const fill5h = bars[0].firstElementChild as HTMLElement; + const fillWk = bars[1].firstElementChild as HTMLElement; + expect(fill5h.className).toContain("bg-alert-ember"); + expect(fillWk.className).toContain("bg-alert-ember"); + expect(screen.getByText("92%")).toBeInTheDocument(); + expect(screen.getByText("99%")).toBeInTheDocument(); + }); + + it("clamps over-100 percentages to a 100%-wide bar but rounds display value", () => { + setQuota({ + five_hr_pct: 137, + weekly_pct: -5, + five_hr_resets_at: "2026-04-25T16:00:00Z", + weekly_resets_at: "2026-04-30T00:00:00Z", + }); + render(); + + const bars = screen.getAllByRole("progressbar"); + const fill5h = bars[0].firstElementChild as HTMLElement; + const fillWk = bars[1].firstElementChild as HTMLElement; + + // Width clamped to 100% / 0%. + expect(fill5h.style.width).toBe("100%"); + expect(fillWk.style.width).toBe("0%"); + + // aria-valuenow reflects the clamped (safe) value, not the raw input. + expect(bars[0]).toHaveAttribute("aria-valuenow", "100"); + expect(bars[1]).toHaveAttribute("aria-valuenow", "0"); + + // Display value comes from the clamped/rounded number. + expect(screen.getByText("100%")).toBeInTheDocument(); + expect(screen.getByText("0%")).toBeInTheDocument(); + }); + + it("rounds fractional percentages for display while keeping width precise", () => { + setQuota({ + five_hr_pct: 33.6, + weekly_pct: 50.4, + five_hr_resets_at: "2026-04-25T16:00:00Z", + weekly_resets_at: "2026-04-30T00:00:00Z", + }); + render(); + + expect(screen.getByText("34%")).toBeInTheDocument(); + expect(screen.getByText("50%")).toBeInTheDocument(); + const bars = screen.getAllByRole("progressbar"); + expect((bars[0].firstElementChild as HTMLElement).style.width).toBe( + "33.6%", + ); + expect((bars[1].firstElementChild as HTMLElement).style.width).toBe( + "50.4%", + ); + }); + + it("shows reset-in copy and a tooltip when resetAt is provided", () => { + setQuota({ + five_hr_pct: 30, + weekly_pct: 50, + // 4 hours into the future from the fake NOW. + five_hr_resets_at: "2026-04-25T16:00:00Z", + // 5 days into the future. + weekly_resets_at: "2026-04-30T12:00:00Z", + }); + render(); + + expect(screen.getByText(/resets in 4 hr/i)).toBeInTheDocument(); + expect(screen.getByText(/resets in 5 days/i)).toBeInTheDocument(); + + // Tooltips carry the raw ISO timestamp. + const fiveHrLabel = screen.getByText(/resets in 4 hr/i); + expect(fiveHrLabel).toHaveAttribute( + "title", + "Resets at 2026-04-25T16:00:00Z", + ); + }); + + it("omits the reset-in copy when resetAt is missing", () => { + setQuota({ + five_hr_pct: 30, + weekly_pct: 50, + // Both reset timestamps blank. + five_hr_resets_at: "", + weekly_resets_at: "", + }); + render(); + + expect(screen.queryByText(/resets in/i)).not.toBeInTheDocument(); + }); + + it("uses the bar's aria-label for assistive tech", () => { + setQuota({ + five_hr_pct: 30, + weekly_pct: 50, + five_hr_resets_at: "2026-04-25T16:00:00Z", + weekly_resets_at: "2026-04-30T12:00:00Z", + }); + render(); + expect( + screen.getByRole("progressbar", { name: /5h quota usage/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("progressbar", { name: /weekly quota usage/i }), + ).toBeInTheDocument(); + }); + + it("supports the partial-data case where only one quota track has reset info", () => { + setQuota({ + five_hr_pct: 22, + weekly_pct: 88, + five_hr_resets_at: "2026-04-25T16:00:00Z", + weekly_resets_at: "", + }); + render(); + + expect(screen.getByText(/resets in 4 hr/i)).toBeInTheDocument(); + // Only ONE "resets in" line — weekly doesn't render its reset chip. + expect(screen.getAllByText(/resets in/i)).toHaveLength(1); + }); +});