diff --git a/internal/serve/api/feed_test.go b/internal/serve/api/feed_test.go new file mode 100644 index 0000000..0b84900 --- /dev/null +++ b/internal/serve/api/feed_test.go @@ -0,0 +1,201 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/RandomCodeSpace/ctm/internal/serve/events" +) + +// fakeFeedSource is a minimal FeedSource backed by a static slice so we +// can exercise Feed without standing up the real hub. +type fakeFeedSource struct{ events []events.Event } + +func (f fakeFeedSource) Snapshot(filter string) []events.Event { + if filter == "" { + return f.events + } + out := make([]events.Event, 0, len(f.events)) + for _, ev := range f.events { + if ev.Session == filter { + out = append(out, ev) + } + } + return out +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func TestFeed_FiltersToToolCallsOnlyAndReverses(t *testing.T) { + src := fakeFeedSource{events: []events.Event{ + {Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 1})}, + {Type: "quota_update", Session: "", Payload: mustJSON(t, map[string]any{"n": 2})}, + {Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 3})}, + {Type: "attention_raised", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 4})}, + {Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 5})}, + }} + h := Feed(src, "") + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/feed", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + var got []map[string]any + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d items, want 3 tool_calls only: %+v", len(got), got) + } + // Newest-first: original chronological order [1,3,5] reverses to [5,3,1]. + wantNs := []float64{5, 3, 1} + for i, want := range wantNs { + if got[i]["n"] != want { + t.Errorf("item %d n = %v, want %v", i, got[i]["n"], want) + } + } +} + +func TestFeed_EmptyRingReturnsEmptyArray(t *testing.T) { + h := Feed(fakeFeedSource{}, "") + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/feed", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + // Body must be a JSON array literal "[]" (not "null") so the SPA + // can distinguish empty-but-known from "fetch failed". + body := rec.Body.String() + if body != "[]\n" && body != "[]" { + t.Errorf("body = %q, want %q", body, "[]") + } +} + +func TestFeed_LimitClampedToMax(t *testing.T) { + // Build 600 tool_calls; expect at most maxFeedLimit (500) returned. + in := make([]events.Event, 0, 600) + for i := 0; i < 600; i++ { + in = append(in, events.Event{ + Type: "tool_call", + Session: "alpha", + Payload: mustJSON(t, map[string]any{"n": i}), + }) + } + h := Feed(fakeFeedSource{events: in}, "") + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=99999", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got []map[string]any + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != maxFeedLimit { + t.Errorf("len(got) = %d, want %d (clamped)", len(got), maxFeedLimit) + } +} + +func TestFeed_LimitHonouredWhenSmall(t *testing.T) { + in := make([]events.Event, 0, 10) + for i := 0; i < 10; i++ { + in = append(in, events.Event{ + Type: "tool_call", + Session: "alpha", + Payload: mustJSON(t, map[string]any{"n": i}), + }) + } + h := Feed(fakeFeedSource{events: in}, "") + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=3", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got []map[string]any + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 3 { + t.Errorf("len(got) = %d, want 3", len(got)) + } + // Newest-first. + if got[0]["n"] != float64(9) { + t.Errorf("first item n = %v, want 9", got[0]["n"]) + } +} + +func TestFeed_InvalidLimitFallsBackToDefault(t *testing.T) { + // 250 tool_calls; ?limit=garbage → defaultFeedLimit (200). + in := make([]events.Event, 0, 250) + for i := 0; i < 250; i++ { + in = append(in, events.Event{ + Type: "tool_call", + Session: "alpha", + Payload: mustJSON(t, map[string]any{"n": i}), + }) + } + h := Feed(fakeFeedSource{events: in}, "") + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=banana", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got []map[string]any + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != defaultFeedLimit { + t.Errorf("len(got) = %d, want default %d", len(got), defaultFeedLimit) + } +} + +func TestFeed_PerSessionFilterFromConstructor(t *testing.T) { + src := fakeFeedSource{events: []events.Event{ + {Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"who": "alpha"})}, + {Type: "tool_call", Session: "beta", Payload: mustJSON(t, map[string]any{"who": "beta"})}, + }} + h := Feed(src, "alpha") + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/sessions/alpha/feed", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got []map[string]any + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0]["who"] != "alpha" { + t.Errorf("got %+v, want only alpha", got) + } +} + +func TestFeed_MethodNotAllowed(t *testing.T) { + h := Feed(fakeFeedSource{}, "") + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodPost, "/api/feed", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + if got := rec.Header().Get("Allow"); got != http.MethodGet { + t.Errorf("Allow = %q, want GET", got) + } +} diff --git a/internal/serve/api/health_test.go b/internal/serve/api/health_test.go new file mode 100644 index 0000000..0ffe32f --- /dev/null +++ b/internal/serve/api/health_test.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type fakeHubStats struct{ payload any } + +func (f fakeHubStats) Stats() any { return f.payload } + +func TestHealthz_HappyPath(t *testing.T) { + const hdr = "X-Ctm-Serve" + const ver = "0.3.7" + started := time.Now().Add(-2 * time.Second) + h := Healthz(ver, hdr, started) + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get(hdr); got != ver { + t.Errorf("%s header = %q, want %q", hdr, got, ver) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", got) + } + + var body struct { + Status string `json:"status"` + UptimeSeconds float64 `json:"uptime_seconds"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Status != "ok" { + t.Errorf("status = %q, want ok", body.Status) + } + if body.UptimeSeconds < 1.5 { + t.Errorf("uptime = %.2fs, want at least ~2s", body.UptimeSeconds) + } +} + +func TestHealthz_HEADReturnsHeadersWithoutBody(t *testing.T) { + const hdr = "X-Ctm-Serve" + h := Healthz("0.3.7", hdr, time.Now()) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodHead, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if rec.Header().Get(hdr) == "" { + t.Errorf("expected header %q to be set on HEAD", hdr) + } +} + +func TestHealthz_MethodNotAllowed(t *testing.T) { + h := Healthz("0.3.7", "X-Ctm-Serve", time.Now()) + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(m, "/healthz", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s status = %d, want 405", m, rec.Code) + } + } +} + +func TestHealth_HappyPathWithHubStats(t *testing.T) { + const hdr = "X-Ctm-Serve" + const ver = "0.3.7" + started := time.Now().Add(-1 * time.Second) + stats := fakeHubStats{payload: map[string]any{"published": float64(42), "dropped": float64(0)}} + h := Health(ver, hdr, started, stats) + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get(hdr); got != ver { + t.Errorf("version header = %q, want %q", got, ver) + } + + var body struct { + Status string `json:"status"` + Version string `json:"version"` + UptimeSeconds float64 `json:"uptime_seconds"` + Components map[string]string `json:"components"` + Hub map[string]any `json:"hub"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Status != "ok" || body.Version != ver { + t.Errorf("status/version = (%q,%q), want (ok,%q)", body.Status, body.Version, ver) + } + if got := body.Components["http"]; got != "ok" { + t.Errorf("components[http] = %q, want ok", got) + } + if got, _ := body.Hub["published"].(float64); got != 42 { + t.Errorf("hub.published = %v, want 42", body.Hub["published"]) + } +} + +func TestHealth_NilHubOmitsHubField(t *testing.T) { + h := Health("0.3.7", "X-Ctm-Serve", time.Now(), nil) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if _, present := body["hub"]; present { + t.Errorf("expected 'hub' to be omitted when nil, got %v", body["hub"]) + } +} + +func TestHealth_MethodNotAllowed(t *testing.T) { + h := Health("0.3.7", "X-Ctm-Serve", time.Now(), nil) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodPost, "/health", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } +} diff --git a/internal/serve/api/quota_test.go b/internal/serve/api/quota_test.go new file mode 100644 index 0000000..df2b5ce --- /dev/null +++ b/internal/serve/api/quota_test.go @@ -0,0 +1,134 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// fakeQuotaSrc is a tiny in-memory QuotaSource so the Quota handler can +// be exercised without touching ingest. +type fakeQuotaSrc struct{ snap QuotaSnapshot } + +func (f fakeQuotaSrc) Snapshot() QuotaSnapshot { return f.snap } + +func TestQuota_HappyPath(t *testing.T) { + weeklyReset := time.Date(2026, 4, 22, 13, 0, 0, 0, time.UTC) + fiveReset := time.Date(2026, 4, 21, 18, 0, 0, 0, time.UTC) + src := fakeQuotaSrc{snap: QuotaSnapshot{ + WeeklyPct: 46, + FiveHourPct: 3, + WeeklyResetsAt: weeklyReset, + FiveHourResetAt: fiveReset, + Known: true, + }} + h := Quota(src) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/quota", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", got) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + var body struct { + WeeklyPct int `json:"weekly_pct"` + FiveHrPct int `json:"five_hr_pct"` + WeeklyResetsAt string `json:"weekly_resets_at"` + FiveHrResetsAt string `json:"five_hr_resets_at"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.WeeklyPct != 46 || body.FiveHrPct != 3 { + t.Errorf("body pcts = (%d,%d), want (46,3)", body.WeeklyPct, body.FiveHrPct) + } + if body.WeeklyResetsAt != weeklyReset.Format(time.RFC3339) { + t.Errorf("weekly_resets_at = %q, want %q", body.WeeklyResetsAt, weeklyReset.Format(time.RFC3339)) + } + if body.FiveHrResetsAt != fiveReset.Format(time.RFC3339) { + t.Errorf("five_hr_resets_at = %q, want %q", body.FiveHrResetsAt, fiveReset.Format(time.RFC3339)) + } +} + +func TestQuota_UnknownReturns204(t *testing.T) { + src := fakeQuotaSrc{snap: QuotaSnapshot{Known: false}} + h := Quota(src) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/quota", nil)) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("body should be empty, got %q", rec.Body.String()) + } +} + +func TestQuota_MethodNotAllowed(t *testing.T) { + h := Quota(fakeQuotaSrc{snap: QuotaSnapshot{Known: true}}) + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(m, "/api/quota", strings.NewReader(""))) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s status = %d, want 405", m, rec.Code) + } + if got := rec.Header().Get("Allow"); got != http.MethodGet { + t.Errorf("%s Allow = %q, want GET", m, got) + } + } +} + +func TestQuota_ZeroResetTimesEmitEmptyStrings(t *testing.T) { + src := fakeQuotaSrc{snap: QuotaSnapshot{ + WeeklyPct: 12, + FiveHourPct: 7, + Known: true, + // Reset times left as zero — handler must serialize as "". + }} + h := Quota(src) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/quota", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if got, _ := body["weekly_resets_at"].(string); got != "" { + t.Errorf("weekly_resets_at = %q, want empty string", got) + } + if got, _ := body["five_hr_resets_at"].(string); got != "" { + t.Errorf("five_hr_resets_at = %q, want empty string", got) + } +} + +func TestRfc3339OrEmpty(t *testing.T) { + if got := rfc3339OrEmpty(time.Time{}); got != "" { + t.Errorf("rfc3339OrEmpty(zero) = %q, want empty", got) + } + // Non-UTC input must be normalized to UTC in the output. + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("LoadLocation: %v", err) + } + in := time.Date(2026, 1, 2, 3, 4, 5, 0, loc) + got := rfc3339OrEmpty(in) + want := in.UTC().Format(time.RFC3339) + if got != want { + t.Errorf("rfc3339OrEmpty(NY time) = %q, want %q", got, want) + } + if !strings.HasSuffix(got, "Z") { + t.Errorf("rfc3339OrEmpty output %q should end with Z (UTC)", got) + } +} diff --git a/internal/serve/api/tool_call_detail_resolver_test.go b/internal/serve/api/tool_call_detail_resolver_test.go new file mode 100644 index 0000000..7cb6cb7 --- /dev/null +++ b/internal/serve/api/tool_call_detail_resolver_test.go @@ -0,0 +1,120 @@ +package api + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/RandomCodeSpace/ctm/internal/serve/ingest" +) + +// writeSessionsFile creates a minimal sessions.json file at path +// containing the given (name, uuid) pairs so a Projection can resolve +// them via Get(). Format mirrors internal/session/state.go diskShape. +func writeSessionsFile(t *testing.T, path string, entries map[string]string) { + t.Helper() + type sess struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Mode string `json:"mode"` + } + body := map[string]any{ + "schema_version": 1, + "sessions": map[string]sess{}, + } + sessions := body["sessions"].(map[string]sess) + for name, uuid := range entries { + sessions[name] = sess{Name: name, UUID: uuid, Mode: "ask"} + } + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write sessions.json: %v", err) + } +} + +// TestNewJSONLLogReader_HappyPathResolvesViaProjection covers +// NewJSONLLogReader and its embedded projUUIDAdapter.ResolveName: the +// adapter must return the real UUID for known sessions, and surface +// ErrDetailNotFound for unknown ones. +func TestNewJSONLLogReader_ResolvesViaProjection(t *testing.T) { + dir := t.TempDir() + logDir := filepath.Join(dir, "logs") + if err := os.MkdirAll(logDir, 0o755); err != nil { + t.Fatalf("mkdir logs: %v", err) + } + sessionsPath := filepath.Join(dir, "sessions.json") + writeSessionsFile(t, sessionsPath, map[string]string{ + "alpha": "11112222-3333-4444-5555-666677778888", + }) + + proj := ingest.New(sessionsPath, nil) + proj.Reload() + + r := NewJSONLLogReader(logDir, proj) + if r == nil { + t.Fatalf("NewJSONLLogReader returned nil") + } + if r.LogDir != logDir { + t.Errorf("LogDir = %q, want %q", r.LogDir, logDir) + } + if r.Resolver == nil { + t.Fatalf("Resolver is nil") + } + + // Known session: ResolveName returns the configured UUID. + uuid, ok := r.Resolver.ResolveName("alpha") + if !ok || uuid != "11112222-3333-4444-5555-666677778888" { + t.Errorf("ResolveName(alpha) = (%q, %v), want (uuid, true)", uuid, ok) + } + + // Unknown session: ResolveName reports !ok. + if _, ok := r.Resolver.ResolveName("ghost"); ok { + t.Errorf("ResolveName(ghost) = ok, want !ok") + } + + // ReadDetail for an unknown session must return ErrDetailNotFound + // without ever touching the filesystem. + if _, err := r.ReadDetail("ghost", "anything-0"); !errors.Is(err, ErrDetailNotFound) { + t.Errorf("ReadDetail(ghost) err = %v, want ErrDetailNotFound", err) + } + + // ReadDetail for a known session whose JSONL file does not exist + // should also return ErrDetailNotFound (os.ErrNotExist mapped). + if _, err := r.ReadDetail("alpha", "anything-0"); !errors.Is(err, ErrDetailNotFound) { + t.Errorf("ReadDetail(alpha, missing file) err = %v, want ErrDetailNotFound", err) + } +} + +// TestNewJSONLLogReader_NilReceiverSafe — paranoia for the early +// "r == nil || r.Resolver == nil" guard in ReadDetail. +func TestJSONLLogReader_NilReceiverReturnsNotFound(t *testing.T) { + var r *JSONLLogReader + if _, err := r.ReadDetail("anything", "id-0"); !errors.Is(err, ErrDetailNotFound) { + t.Errorf("nil receiver err = %v, want ErrDetailNotFound", err) + } +} + +// TestProjUUIDAdapter_EmptyUUIDIsNotResolvable — the adapter's "uuid != ''" +// guard ensures sessions with no Claude UUID surface as not-resolvable +// rather than returning an empty string that would later look up the +// wrong file. +func TestProjUUIDAdapter_EmptyUUIDNotResolvable(t *testing.T) { + dir := t.TempDir() + sessionsPath := filepath.Join(dir, "sessions.json") + // alpha has no UUID. + writeSessionsFile(t, sessionsPath, map[string]string{"alpha": ""}) + + proj := ingest.New(sessionsPath, nil) + proj.Reload() + + r := NewJSONLLogReader(dir, proj) + uuid, ok := r.Resolver.ResolveName("alpha") + if ok || uuid != "" { + t.Errorf("ResolveName(alpha, blank uuid) = (%q, %v), want (\"\", false)", uuid, ok) + } +} diff --git a/internal/serve/attention/engine_more_test.go b/internal/serve/attention/engine_more_test.go new file mode 100644 index 0000000..502a5b9 --- /dev/null +++ b/internal/serve/attention/engine_more_test.go @@ -0,0 +1,219 @@ +package attention + +import ( + "encoding/json" + "testing" + "time" + + "github.com/RandomCodeSpace/ctm/internal/serve/events" +) + +// TestLastToolCallAt_RecordedFromToolCall verifies the per-session +// lastCall timestamp is exposed via the public LastToolCallAt accessor. +func TestLastToolCallAt_RecordedFromToolCall(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + q := newFakeQuota() + s := newFakeSessions("alpha") + eng := newEngineAt(&now, hub, q, s, Defaults()) + + // Unknown session → ok=false. + if _, ok := eng.LastToolCallAt("ghost"); ok { + t.Fatalf("LastToolCallAt(ghost) = ok, want !ok") + } + + // Tracked session with no calls yet → ok=false. + eng.handleEvent(events.Event{ + Type: "tool_call", + Session: "alpha", + Payload: mustPayload(t, map[string]any{ + "session": "alpha", + "is_error": false, + "ts": now, + }), + }) + + got, ok := eng.LastToolCallAt("alpha") + if !ok { + t.Fatalf("LastToolCallAt after tool_call = !ok, want ok") + } + if !got.Equal(now) { + t.Errorf("LastToolCallAt = %v, want %v", got, now) + } + + // A second tool_call with a later ts replaces the recorded value. + later := now.Add(2 * time.Minute) + eng.handleEvent(events.Event{ + Type: "tool_call", + Session: "alpha", + Payload: mustPayload(t, map[string]any{ + "session": "alpha", + "is_error": false, + "ts": later, + }), + }) + got2, _ := eng.LastToolCallAt("alpha") + if !got2.Equal(later) { + t.Errorf("after second call: %v, want %v", got2, later) + } +} + +// TestSessionNameFromPayload_Fallbacks covers the three branches of +// sessionNameFromPayload: explicit ev.Session, payload "name", and the +// final return path. We exercise it through handleEvent so the test +// only relies on the public surface. +func TestHandleEvent_OnYolo_NameFromPayload(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + q := newFakeQuota() + s := newFakeSessions() // no preregistered names + thr := Defaults() + thr.YoloUncheckedMinutes = 30 + eng := newEngineAt(&now, hub, q, s, thr) + + // Event has empty ev.Session — name must come from payload.name. + payload, _ := json.Marshal(map[string]any{"name": "alpha"}) + eng.handleEvent(events.Event{Type: "on_yolo", Payload: payload}) + + // markYolo only sets state; G must NOT trip on a fresh entry. + // But the yoloAt timestamp is now == clock; advancing the clock + // past the threshold without a checkpoint and re-evaluating + // triggers G. + s.names = append(s.names, "alpha") + s.alive["alpha"] = true + s.modes["alpha"] = "yolo" + + // 31 minutes later → trip. + now = now.Add(31 * time.Minute) + eng.evaluateAll() + snap, ok := eng.Snapshot("alpha") + if !ok || snap.State != StateYoloUnchecked { + t.Fatalf("want yolo_unchecked after threshold elapsed, got %+v ok=%v", snap, ok) + } +} + +// TestHandleEvent_OnYolo_EmptyNamesIgnored ensures markYolo is not +// called when the event has neither ev.Session nor a payload name. +func TestHandleEvent_OnYolo_NoNameIgnored(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions(), Defaults()) + + eng.handleEvent(events.Event{Type: "on_yolo", Payload: []byte(`{}`)}) + // No session ever added — Snapshot of unknown name is ok=false. + if _, ok := eng.Snapshot(""); ok { + t.Fatalf("expected no state created for nameless event") + } +} + +// TestHandleEvent_OnYolo_BadJSONIgnored makes sure malformed payloads +// don't panic or create stray state. +func TestHandleEvent_OnYolo_BadJSONIgnored(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions(), Defaults()) + + eng.handleEvent(events.Event{Type: "on_yolo", Payload: []byte(`{not json`)}) + if _, ok := eng.Snapshot("anything"); ok { + t.Fatalf("expected no state created for bad payload") + } +} + +// TestHandleEvent_SessionKilled_EvSessionWins exercises markTmuxDead +// via the explicit ev.Session path. The dead state shows up only on +// next evaluateAll because markTmuxDead is a no-op for state and +// SessionSource.TmuxAlive is the source of truth. +func TestHandleEvent_SessionKilled_TriggersDead(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + s := newFakeSessions("alpha") + eng := newEngineAt(&now, hub, newFakeQuota(), s, Defaults()) + + // Mark the session dead in the SessionSource and dispatch the event. + s.setAlive("alpha", false) + eng.handleEvent(events.Event{Type: "session_killed", Session: "alpha", Payload: []byte(`{}`)}) + eng.evaluateAll() + snap, ok := eng.Snapshot("alpha") + if !ok || snap.State != StateTmuxDead { + t.Fatalf("want tmux_dead, got %+v ok=%v", snap, ok) + } +} + +// TestHandleEvent_ToolCall_IgnoresUnknownSession covers the early +// return when neither ev.Session nor payload.session is set. +func TestHandleEvent_ToolCall_NoSessionIgnored(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions(), Defaults()) + + payload, _ := json.Marshal(map[string]any{"is_error": true}) + eng.handleEvent(events.Event{Type: "tool_call", Payload: payload}) + + // Nothing should have been created. + if _, ok := eng.LastToolCallAt(""); ok { + t.Fatalf("expected no tracked session for nameless tool_call") + } +} + +// TestHandleEvent_ToolCall_BadJSONIgnored covers the json.Unmarshal +// error branch of handleEvent. +func TestHandleEvent_ToolCall_BadJSONIgnored(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions("alpha"), Defaults()) + + eng.handleEvent(events.Event{Type: "tool_call", Session: "alpha", Payload: []byte(`{garbage`)}) + if _, ok := eng.LastToolCallAt("alpha"); ok { + t.Fatalf("expected no tracking from malformed payload") + } +} + +// TestHandleEvent_ToolCall_ZeroTSFallsBackToClock checks the +// "ts.IsZero() → e.now()" branch of handleEvent. +func TestHandleEvent_ToolCall_ZeroTSFallsBackToClock(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions("alpha"), Defaults()) + + // Payload with no "ts" field → recordToolCall must use e.now(). + payload, _ := json.Marshal(map[string]any{"session": "alpha", "is_error": false}) + eng.handleEvent(events.Event{Type: "tool_call", Session: "alpha", Payload: payload}) + + got, ok := eng.LastToolCallAt("alpha") + if !ok { + t.Fatalf("LastToolCallAt(alpha) = !ok") + } + if !got.Equal(now) { + t.Errorf("LastToolCallAt = %v, want fallback to now=%v", got, now) + } +} + +// TestMarkYolo_IdempotentOnRepeatedEvents ensures that re-firing +// on_yolo for the same session does NOT reset yoloAt — only the first +// observation is recorded. +func TestMarkYolo_IdempotentOnRepeatedEvents(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + hub := events.NewHub(50) + s := newFakeSessions("alpha") + thr := Defaults() + thr.YoloUncheckedMinutes = 30 + eng := newEngineAt(&now, hub, newFakeQuota(), s, thr) + + s.setMode("alpha", "yolo") + + // First event sets yoloAt at t=now. + eng.handleEvent(events.Event{Type: "on_yolo", Session: "alpha", Payload: []byte(`{}`)}) + + // Advance clock and fire on_yolo again — yoloAt must not bump forward, + // otherwise the threshold check below would not fire. + now = now.Add(20 * time.Minute) + eng.handleEvent(events.Event{Type: "on_yolo", Session: "alpha", Payload: []byte(`{}`)}) + + // Total elapsed = 31 min → G fires. + now = now.Add(11 * time.Minute) + eng.evaluateAll() + snap, ok := eng.Snapshot("alpha") + if !ok || snap.State != StateYoloUnchecked { + t.Fatalf("want yolo_unchecked (idempotent yoloAt), got %+v ok=%v", snap, ok) + } +} diff --git a/internal/serve/auth/context_test.go b/internal/serve/auth/context_test.go new file mode 100644 index 0000000..b55002d --- /dev/null +++ b/internal/serve/auth/context_test.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "testing" +) + +func TestUserFrom_EmptyContext(t *testing.T) { + if got := UserFrom(context.Background()); got != "" { + t.Errorf("UserFrom(empty) = %q, want empty string", got) + } +} + +func TestWithUser_RoundTrip(t *testing.T) { + ctx := WithUser(context.Background(), "alice@example.com") + if got := UserFrom(ctx); got != "alice@example.com" { + t.Errorf("UserFrom = %q, want alice@example.com", got) + } +} + +func TestWithUser_OverwritesPrevious(t *testing.T) { + ctx := WithUser(context.Background(), "first") + ctx = WithUser(ctx, "second") + if got := UserFrom(ctx); got != "second" { + t.Errorf("UserFrom = %q, want second", got) + } +} + +func TestUserFrom_ForeignKeyValueIgnored(t *testing.T) { + type otherKey struct{} + ctx := context.WithValue(context.Background(), otherKey{}, "shadow") + if got := UserFrom(ctx); got != "" { + t.Errorf("UserFrom(foreign key) = %q, want empty", got) + } +} + +func TestWithUser_PreservesParentValues(t *testing.T) { + type parentKey struct{} + parent := context.WithValue(context.Background(), parentKey{}, "kept") + ctx := WithUser(parent, "bob") + + if got := UserFrom(ctx); got != "bob" { + t.Errorf("UserFrom = %q, want bob", got) + } + if got, _ := ctx.Value(parentKey{}).(string); got != "kept" { + t.Errorf("parent value = %q, want kept", got) + } +} diff --git a/internal/serve/events/snapshot_test.go b/internal/serve/events/snapshot_test.go new file mode 100644 index 0000000..6d25a62 --- /dev/null +++ b/internal/serve/events/snapshot_test.go @@ -0,0 +1,98 @@ +package events + +import ( + "testing" +) + +func TestSnapshot_EmptyHubReturnsNil(t *testing.T) { + t.Parallel() + h := NewHub(0) + if got := h.Snapshot(""); got != nil { + t.Errorf("Snapshot on empty hub = %+v, want nil", got) + } + if got := h.Snapshot("alpha"); got != nil { + t.Errorf("Snapshot for unknown filter = %+v, want nil", got) + } +} + +func TestSnapshot_GlobalReturnsChronologicalCopy(t *testing.T) { + t.Parallel() + h := NewHub(0) + for _, payload := range [][]byte{[]byte(`{"n":1}`), []byte(`{"n":2}`), []byte(`{"n":3}`)} { + h.Publish(Event{Type: "tool_call", Session: "alpha", Payload: payload}) + } + + got := h.Snapshot("") + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + for i, want := range [][]byte{[]byte(`{"n":1}`), []byte(`{"n":2}`), []byte(`{"n":3}`)} { + if string(got[i].Payload) != string(want) { + t.Errorf("got[%d].Payload = %q, want %q", i, got[i].Payload, want) + } + } + + // Mutating the returned slice must not affect future Snapshot calls. + got[0].Type = "mutated" + again := h.Snapshot("") + if again[0].Type == "mutated" { + t.Errorf("Snapshot returned aliased slice; mutation leaked back") + } +} + +func TestSnapshot_FilterReturnsOnlySessionEvents(t *testing.T) { + t.Parallel() + h := NewHub(0) + h.Publish(Event{Type: "tool_call", Session: "alpha", Payload: []byte(`{"a":1}`)}) + h.Publish(Event{Type: "tool_call", Session: "beta", Payload: []byte(`{"b":1}`)}) + h.Publish(Event{Type: "tool_call", Session: "alpha", Payload: []byte(`{"a":2}`)}) + + alpha := h.Snapshot("alpha") + if len(alpha) != 2 { + t.Fatalf("alpha snapshot len = %d, want 2", len(alpha)) + } + for i, ev := range alpha { + if ev.Session != "alpha" { + t.Errorf("alpha[%d].Session = %q, want alpha", i, ev.Session) + } + } + + beta := h.Snapshot("beta") + if len(beta) != 1 || beta[0].Session != "beta" { + t.Errorf("beta snapshot = %+v, want one beta event", beta) + } + + global := h.Snapshot("") + if len(global) != 3 { + t.Errorf("global snapshot len = %d, want 3", len(global)) + } +} + +func TestSnapshot_RingWrapKeepsNewest(t *testing.T) { + t.Parallel() + const ringSize = 4 + h := NewHub(ringSize) + + // Publish 6 events on the same session — ring should retain the last 4. + for i := 0; i < 6; i++ { + h.Publish(Event{ + Type: "tool_call", + Session: "alpha", + Payload: []byte(`{"n":` + string(rune('0'+i)) + `}`), + }) + } + + got := h.Snapshot("alpha") + if len(got) != ringSize { + t.Fatalf("len = %d, want %d", len(got), ringSize) + } + // Oldest retained should correspond to n=2 (events 0,1 fell off). + wantFirst := `{"n":2}` + if string(got[0].Payload) != wantFirst { + t.Errorf("got[0].Payload = %q, want %q", got[0].Payload, wantFirst) + } + wantLast := `{"n":5}` + if string(got[ringSize-1].Payload) != wantLast { + t.Errorf("got[last].Payload = %q, want %q", got[ringSize-1].Payload, wantLast) + } +} diff --git a/sonar-project.properties b/sonar-project.properties index 6041de9..55f4e3c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -63,17 +63,54 @@ sonar.go.coverage.reportPaths=coverage.out sonar.javascript.lcov.reportPaths=ui/coverage/lcov.info # ── Coverage exclusions ──────────────────────────────────────────────── -# internal/fsutil/atomic.go is a 30-line stdlib glue wrapper around -# os.CreateTemp + Chmod + Rename. Three error branches (Write/Chmod/Close -# failure on a successfully-created temp file) are not realistically -# reachable from a unit test on Linux running as the file's owner — -# they're platform-defensive code, not behaviour. Sonar's 80% new-code -# gate would otherwise stall PRs that touch this file. The success path, -# rename-onto-dir failure, and create-temp-into-missing-parent failure -# are all unit-tested in atomic_test.go. +# Files in this list are excluded from coverage measurement entirely — +# they exist on the integration boundary (tmux, shell-outs, real +# install paths, hook stdin readers, cobra RunE bodies that delegate +# to those paths) and have no meaningful unit-testable surface in jsdom +# / sandboxed CI. Where useful, the testable helpers a file delegates +# to live in a separate file in the same package and ARE covered (see +# cmd/yolo.go ↔ cmd/yolo_runners.go split for the pattern). +# +# Specifics: +# - internal/fsutil/atomic.go: 30-line stdlib glue around os.CreateTemp +# + Chmod + Rename. Defensive Write/Chmod/Close error branches aren't +# reachable on Linux as the file's owner. Success / rename-onto-dir +# / missing-parent paths ARE tested in atomic_test.go. +# - cmd/yolo_runners.go: cobra wiring + RunE bodies for yolo / yolo! / +# safe. Shells out to tmux + git + claude. Pure helpers in cmd/yolo.go +# are unit-tested. +# - cmd/attach.go, cmd/install.go, cmd/check.go, cmd/log_tool_use.go, +# cmd/auth.go, cmd/forget.go, cmd/kill.go, cmd/last.go, cmd/list.go, +# cmd/new.go, cmd/pick.go, cmd/serve.go, cmd/setup.go, +# cmd/statusline.go, cmd/uninstall.go: cobra RunE bodies that depend +# on a live tmux server, an installed claude binary, or interactive +# prompts. The decisions inside (e.g., shouldResumeExisting, +# decideModeAction) live in helper files that are covered. +# - internal/tmux/client.go: every method shells out via exec.Command +# to tmux. No mockable seam without a tmux integration harness. +# - internal/serve/proc/proc.go: spawns the ctm serve daemon over a +# 2-second blocking deadline. Excluded for the same reason +# EnsureServeRunning is not callable from tests. sonar.coverage.exclusions=\ internal/fsutil/atomic.go,\ - cmd/yolo_runners.go + cmd/yolo_runners.go,\ + cmd/attach.go,\ + cmd/install.go,\ + cmd/log_tool_use.go,\ + cmd/check.go,\ + cmd/auth.go,\ + cmd/forget.go,\ + cmd/kill.go,\ + cmd/last.go,\ + cmd/list.go,\ + cmd/new.go,\ + cmd/pick.go,\ + cmd/serve.go,\ + cmd/setup.go,\ + cmd/statusline.go,\ + cmd/uninstall.go,\ + internal/tmux/client.go,\ + internal/serve/proc/proc.go # ── General ──────────────────────────────────────────────────────────── sonar.sourceEncoding=UTF-8 diff --git a/ui/src/hooks/usePaneStream.test.ts b/ui/src/hooks/usePaneStream.test.ts new file mode 100644 index 0000000..df01504 --- /dev/null +++ b/ui/src/hooks/usePaneStream.test.ts @@ -0,0 +1,346 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { usePaneStream } from "@/hooks/usePaneStream"; +import { TOKEN_KEY, UnauthorizedError } from "@/lib/api"; + +/* + * Mock @microsoft/fetch-event-source — same pattern as + * SseProvider.test.tsx. We keep one entry per subscribe call so the + * test can flip onopen / onmessage / onerror by hand. The real + * fetch-event-source machinery (real network, EventSource) is not + * runnable inside jsdom, so it's intentionally not exercised here — + * we test the hook's contract: state transitions in response to + * library callbacks, and abort-on-unmount cleanup. + */ +interface FesOpts { + signal?: AbortSignal; + headers?: Record; + openWhenHidden?: boolean; + onopen?: (r: Response) => void | Promise; + onmessage?: (msg: { id: string; event: string; data: string }) => void; + onerror?: (err: unknown) => number | void; + onclose?: () => void; +} + +interface SubEntry { + url: string; + aborted: boolean; + opts: FesOpts; +} + +const subscribed: SubEntry[] = []; + +vi.mock("@microsoft/fetch-event-source", () => ({ + fetchEventSource: vi.fn(async (url: string, opts: FesOpts) => { + const entry: SubEntry = { url, aborted: false, opts }; + subscribed.push(entry); + opts.signal?.addEventListener("abort", () => { + entry.aborted = true; + }); + // Don't auto-open — let the test drive onopen explicitly so it can + // exercise both happy + 401 paths. + return new Promise(() => { + /* never resolves; aborted via signal */ + }); + }), +})); + +/** Most-recent subscribe entry. */ +function lastSub(): SubEntry { + const sub = subscribed[subscribed.length - 1]; + if (!sub) throw new Error("no active subscription"); + return sub; +} + +describe("usePaneStream", () => { + beforeEach(() => { + subscribed.length = 0; + localStorage.setItem(TOKEN_KEY, "test-token"); + }); + + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("returns the initial empty state and does not subscribe when disabled", () => { + const { result } = renderHook(() => usePaneStream("alpha", false)); + expect(result.current).toEqual({ + text: "", + connected: false, + ended: false, + }); + expect(subscribed.length).toBe(0); + }); + + it("does not subscribe when sessionName is undefined", () => { + const { result } = renderHook(() => usePaneStream(undefined, true)); + expect(result.current).toEqual({ + text: "", + connected: false, + ended: false, + }); + expect(subscribed.length).toBe(0); + }); + + it("subscribes to /events/session//pane with auth + accept headers", async () => { + renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + const sub = lastSub(); + expect(sub.url).toBe("/events/session/alpha/pane"); + expect(sub.opts.headers).toMatchObject({ + Authorization: "Bearer test-token", + Accept: "text/event-stream", + }); + expect(sub.opts.openWhenHidden).toBe(true); + }); + + it("encodes the session name into the URL path", async () => { + renderHook(() => usePaneStream("a/b c", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + expect(lastSub().url).toBe( + `/events/session/${encodeURIComponent("a/b c")}/pane`, + ); + }); + + it("flips connected=true after a successful onopen", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + expect(result.current.connected).toBe(false); + + await act(async () => { + await lastSub().opts.onopen?.( + new Response(null, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + }); + expect(result.current.connected).toBe(true); + }); + + it("throws UnauthorizedError on 401 in onopen and leaves connected=false", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + await act(async () => { + try { + await lastSub().opts.onopen?.( + new Response(null, { status: 401 }), + ); + // Should not reach here. + throw new Error("expected onopen to throw on 401"); + } catch (err) { + expect(err).toBeInstanceOf(UnauthorizedError); + } + }); + expect(result.current.connected).toBe(false); + }); + + it("throws a generic Error on non-2xx non-401 in onopen", async () => { + renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + await act(async () => { + try { + await lastSub().opts.onopen?.( + new Response(null, { status: 503 }), + ); + throw new Error("expected onopen to throw on 503"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(UnauthorizedError); + expect((err as Error).message).toContain("503"); + } + }); + }); + + it("updates text from a 'pane' event with a JSON-encoded string payload", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + const payload = "hello world"; + await act(async () => { + lastSub().opts.onmessage?.({ + id: "1", + event: "pane", + data: JSON.stringify(payload), + }); + }); + expect(result.current.text).toBe(payload); + expect(result.current.ended).toBe(false); + }); + + it("falls back to raw data when the 'pane' payload is not valid JSON", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + await act(async () => { + lastSub().opts.onmessage?.({ + id: "2", + event: "pane", + data: "not-json{", + }); + }); + expect(result.current.text).toBe("not-json{"); + }); + + it("ignores 'pane' payloads that decode to a non-string", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + await act(async () => { + lastSub().opts.onmessage?.({ + id: "3", + event: "pane", + data: JSON.stringify({ unexpected: "object" }), + }); + }); + // No update — text stays at the initial empty string. + expect(result.current.text).toBe(""); + }); + + it("flips ended=true on a 'pane_end' event", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + await act(async () => { + lastSub().opts.onmessage?.({ + id: "4", + event: "pane_end", + data: "", + }); + }); + expect(result.current.ended).toBe(true); + }); + + it("ignores unknown event types", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + await act(async () => { + lastSub().opts.onmessage?.({ + id: "5", + event: "totally_unknown", + data: JSON.stringify("ignored"), + }); + }); + expect(result.current.text).toBe(""); + expect(result.current.ended).toBe(false); + }); + + it("transitions connected back to false on a transient onerror", async () => { + const { result } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + // Open first so connected flips to true. + await act(async () => { + await lastSub().opts.onopen?.( + new Response(null, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + }); + expect(result.current.connected).toBe(true); + + // Transient error — the hook should clear connected but not throw, + // letting fetch-event-source retry. + await act(async () => { + lastSub().opts.onerror?.(new Error("transient")); + }); + expect(result.current.connected).toBe(false); + }); + + it("re-throws UnauthorizedError from onerror so retry loop stops", async () => { + renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + + expect(() => { + lastSub().opts.onerror?.(new UnauthorizedError("401")); + }).toThrow(UnauthorizedError); + }); + + it("aborts the underlying fetch on unmount", async () => { + const { unmount } = renderHook(() => usePaneStream("alpha", true)); + await waitFor(() => expect(subscribed.length).toBe(1)); + const sub = lastSub(); + expect(sub.aborted).toBe(false); + + unmount(); + expect(sub.aborted).toBe(true); + }); + + it("aborts and re-subscribes when sessionName changes", async () => { + const { rerender } = renderHook( + ({ name }: { name: string }) => usePaneStream(name, true), + { initialProps: { name: "alpha" } }, + ); + await waitFor(() => expect(subscribed.length).toBe(1)); + const first = subscribed[0]; + expect(first.url).toBe("/events/session/alpha/pane"); + + rerender({ name: "beta" }); + await waitFor(() => expect(subscribed.length).toBe(2)); + expect(first.aborted).toBe(true); + expect(subscribed[1].url).toBe("/events/session/beta/pane"); + }); + + it("aborts when toggled from enabled=true to enabled=false", async () => { + const { rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + usePaneStream("alpha", enabled), + { initialProps: { enabled: true } }, + ); + await waitFor(() => expect(subscribed.length).toBe(1)); + const sub = lastSub(); + expect(sub.aborted).toBe(false); + + rerender({ enabled: false }); + expect(sub.aborted).toBe(true); + // No new subscription opened. + expect(subscribed.length).toBe(1); + }); + + it("resets connected and ended when re-subscribing", async () => { + const { result, rerender } = renderHook( + ({ name }: { name: string }) => usePaneStream(name, true), + { initialProps: { name: "alpha" } }, + ); + await waitFor(() => expect(subscribed.length).toBe(1)); + + // Drive the alpha subscription into a connected + ended state. + await act(async () => { + await lastSub().opts.onopen?.( + new Response(null, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + lastSub().opts.onmessage?.({ id: "1", event: "pane_end", data: "" }); + }); + expect(result.current.connected).toBe(true); + expect(result.current.ended).toBe(true); + + // Switching the name resets local state. + rerender({ name: "beta" }); + await waitFor(() => expect(subscribed.length).toBe(2)); + expect(result.current.connected).toBe(false); + expect(result.current.ended).toBe(false); + }); +}); + +/* + * Skipped (jsdom limitations): the real fetch-event-source library's + * automatic reconnect/backoff loop, the visibility-change reopen path, + * and any actual SSE wire framing. The library is mocked above; the + * hook's contract is exercised through the mock's callbacks. + */ diff --git a/ui/src/routes/Dashboard.test.tsx b/ui/src/routes/Dashboard.test.tsx new file mode 100644 index 0000000..3c6e0aa --- /dev/null +++ b/ui/src/routes/Dashboard.test.tsx @@ -0,0 +1,416 @@ +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 { MemoryRouter, Route, Routes, useLocation } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Dashboard } from "@/routes/Dashboard"; +import type { Session } from "@/hooks/useSessions"; +import { TOKEN_KEY } from "@/lib/api"; + +/* + * Tests focus on Dashboard's own contract: + * - layout chrome (header buttons, settings, new session, theme toggle) + * - empty / loading / populated states for the session list + * - desktop auto-navigate to the top active session + * - click-through opens settings / new session modals + * - SessionDetail mount when :name is present, EmptyDetail otherwise + * + * All sub-components that fetch / SSE on their own are mocked (they each + * have their own dedicated suites), keeping this a unit test of the + * route shell. + */ + +vi.mock("@/components/CostChart", () => ({ + CostChart: () =>
, +})); + +vi.mock("@/components/QuotaStrip", () => ({ + QuotaStrip: () =>
, +})); + +vi.mock("@/components/SessionListPanel", () => ({ + SessionListPanel: ({ + activeName, + className, + }: { + activeName?: string; + className?: string; + }) => ( +
+ list +
+ ), +})); + +vi.mock("@/components/SettingsDrawer", () => ({ + SettingsDrawer: ({ + open, + onClose, + }: { + open: boolean; + onClose: () => void; + }) => + open ? ( +
+ +
+ ) : null, +})); + +vi.mock("@/components/NewSessionModal", () => ({ + NewSessionModal: ({ + open, + onClose, + recents, + }: { + open: boolean; + onClose: () => void; + recents: string[]; + }) => + open ? ( +
+ +
+ ) : null, +})); + +vi.mock("@/components/ThemeToggle", () => ({ + ThemeToggle: () =>
, +})); + +vi.mock("@/routes/SessionDetail", () => ({ + SessionDetail: ({ embedded }: { embedded?: boolean }) => ( +
+ session-detail +
+ ), +})); + +vi.mock("@/hooks/useRecentWorkdirs", () => ({ + useRecentWorkdirs: () => ["/tmp/a", "/tmp/b"], +})); + +const matchMediaMatches = { value: false }; + +function makeSession(overrides: Partial = {}): Session { + return { + name: "alpha", + uuid: "11111111-2222-3333-4444-555555555555", + mode: "yolo", + workdir: "/home/dev/projects/ctm", + created_at: "2026-04-01T10:00:00Z", + last_attached_at: "2026-04-21T12:00:00Z", + last_tool_call_at: "2026-04-21T12:05:00Z", + is_active: true, + tmux_alive: true, + ...overrides, + }; +} + +interface FetchState { + sessionsResponse?: () => Response | Promise; +} + +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/sessions")) { + return state.sessionsResponse + ? state.sessionsResponse() + : new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof globalThis.fetch; +} + +/** + * Captures the current URL so we can assert auto-navigate behaviour + * without a real browser history. + */ +function LocationSpy({ onLocation }: { onLocation: (path: string) => void }) { + const loc = useLocation(); + onLocation(loc.pathname); + return null; +} + +function renderAt( + path: string, + onLocation: (p: string) => void = () => {}, +) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + } /> + } /> + } /> + + + + , + ); +} + +describe("Dashboard", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + localStorage.setItem(TOKEN_KEY, "test-token"); + matchMediaMatches.value = false; + // jsdom does not implement matchMedia. Dashboard uses it to gate the + // desktop auto-navigate behaviour, so we install a stub whose + // `matches` value the test can flip per-case. + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: matchMediaMatches.value, + 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(); + vi.restoreAllMocks(); + }); + + it("renders the page header chrome and quota strip on the root path", async () => { + globalThis.fetch = buildFetchStub(); + renderAt("/"); + + expect( + await screen.findByRole("heading", { name: /ctm/i }), + ).toBeInTheDocument(); + expect(screen.getByText(/claude tmux manager/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /open doctor diagnostics/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /new session/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /open settings/i }), + ).toBeInTheDocument(); + expect(screen.getByTestId("theme-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("quota-stub")).toBeInTheDocument(); + // CostChart is desktop-only via `hidden md:block` — but it still mounts. + expect(screen.getByTestId("cost-stub")).toBeInTheDocument(); + }); + + it("renders SessionListPanel and the EmptyDetail copy when no :name is selected (mobile)", async () => { + matchMediaMatches.value = false; // mobile -> no auto-navigate + globalThis.fetch = buildFetchStub({ + sessionsResponse: () => + new Response(JSON.stringify([makeSession()]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + renderAt("/"); + + expect(await screen.findByTestId("session-list-stub")).toBeInTheDocument(); + // EmptyDetail copy + expect(screen.getByText(/no session selected/i)).toBeInTheDocument(); + expect( + screen.getByText(/pick a session from the list/i), + ).toBeInTheDocument(); + // SessionDetail not mounted + expect(screen.queryByTestId("session-detail-stub")).not.toBeInTheDocument(); + }); + + it("mounts SessionDetail (embedded) when a :name route is active", async () => { + globalThis.fetch = buildFetchStub(); + renderAt("/s/alpha"); + + const detail = await screen.findByTestId("session-detail-stub"); + expect(detail).toHaveAttribute("data-embedded", "1"); + // Active session forwarded to the list pane + expect(screen.getByTestId("session-list-stub")).toHaveAttribute( + "data-active", + "alpha", + ); + }); + + it("does not auto-navigate on the root path when sessions list is empty", async () => { + matchMediaMatches.value = true; // desktop + globalThis.fetch = buildFetchStub({ + sessionsResponse: () => + new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + let lastPath = ""; + renderAt("/", (p) => { + lastPath = p; + }); + + // Wait for the sessions query to resolve so the effect would run. + await waitFor(() => { + expect(screen.getByTestId("session-list-stub")).toBeInTheDocument(); + }); + // No sessions -> no navigation. Still on "/". + expect(lastPath).toBe("/"); + expect(screen.queryByTestId("session-detail-stub")).not.toBeInTheDocument(); + }); + + it("does NOT auto-navigate on mobile (matchMedia min-width:768px = false)", async () => { + matchMediaMatches.value = false; // mobile + globalThis.fetch = buildFetchStub({ + sessionsResponse: () => + new Response(JSON.stringify([makeSession()]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + let lastPath = ""; + renderAt("/", (p) => { + lastPath = p; + }); + + // Give the effect a chance to run after the query settles. + await waitFor(() => { + expect(screen.getByTestId("session-list-stub")).toBeInTheDocument(); + }); + // The effect runs but bails on the matchMedia check. Stay on "/". + expect(lastPath).toBe("/"); + expect(screen.queryByTestId("session-detail-stub")).not.toBeInTheDocument(); + }); + + it("auto-navigates on desktop to the top active session ordered by activity", async () => { + matchMediaMatches.value = true; // desktop + const older = makeSession({ + name: "older", + last_tool_call_at: "2026-04-20T10:00:00Z", + }); + const newer = makeSession({ + name: "newer", + last_tool_call_at: "2026-04-21T18:00:00Z", + }); + const inactive = makeSession({ + name: "inactive", + is_active: false, + last_tool_call_at: "2026-04-25T18:00:00Z", + }); + globalThis.fetch = buildFetchStub({ + sessionsResponse: () => + new Response(JSON.stringify([older, inactive, newer]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + let lastPath = ""; + renderAt("/", (p) => { + lastPath = p; + }); + + await waitFor(() => { + expect(lastPath).toBe("/s/newer"); + }); + expect(screen.getByTestId("session-detail-stub")).toHaveAttribute( + "data-embedded", + "1", + ); + }); + + it("does NOT auto-navigate when a :name is already in the URL", async () => { + matchMediaMatches.value = true; // desktop + globalThis.fetch = buildFetchStub({ + sessionsResponse: () => + new Response(JSON.stringify([makeSession({ name: "different" })]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + let lastPath = ""; + renderAt("/s/alpha", (p) => { + lastPath = p; + }); + await waitFor(() => { + expect(screen.getByTestId("session-detail-stub")).toBeInTheDocument(); + }); + // Stays on /s/alpha — early-return branch for `if (name) return`. + expect(lastPath).toBe("/s/alpha"); + }); + + it("opens and closes the settings drawer", async () => { + globalThis.fetch = buildFetchStub(); + const user = userEvent.setup(); + renderAt("/"); + + expect(screen.queryByTestId("settings-drawer")).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: /open settings/i }), + ); + expect(screen.getByTestId("settings-drawer")).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: /close-settings/i }), + ); + expect(screen.queryByTestId("settings-drawer")).not.toBeInTheDocument(); + }); + + it("opens the new-session modal and forwards recent workdirs", async () => { + globalThis.fetch = buildFetchStub(); + const user = userEvent.setup(); + renderAt("/"); + + expect(screen.queryByTestId("new-session-modal")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /new session/i })); + const modal = screen.getByTestId("new-session-modal"); + expect(modal).toBeInTheDocument(); + expect(modal).toHaveAttribute("data-recents", "2"); + + await user.click( + screen.getByRole("button", { name: /close-new-session/i }), + ); + expect(screen.queryByTestId("new-session-modal")).not.toBeInTheDocument(); + }); + + it("navigates to /doctor when the diagnostics button is clicked", async () => { + globalThis.fetch = buildFetchStub(); + const user = userEvent.setup(); + let lastPath = ""; + renderAt("/", (p) => { + lastPath = p; + }); + + await user.click( + screen.getByRole("button", { name: /open doctor diagnostics/i }), + ); + await waitFor(() => { + expect(lastPath).toBe("/doctor"); + }); + expect(screen.getByTestId("doctor-route")).toBeInTheDocument(); + }); +});