// @vitest-environment happy-dom import { beforeAll, describe, expect, it } from "vitest"; import { renderSidebar } from "./catalog.js"; import type { CatalogListener, CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; // happy-dom runs without a backing store; renderSidebar reads the persisted // sidebar width on mount. beforeAll(() => { Object.defineProperty(window, "localStorage", { value: { getItem: () => null, setItem: () => {} }, }); // No daemon here; renderSidebar treats a failed info fetch as "absolute // paths are a fine fallback". window.fetch = () => Promise.reject(new Error("offline")); }); function session(overrides: Partial): Session { return { id: "11111111-2222-3333-4444-555555555555", cwd: "/home/naps/tea/maestro", command: "claude", env: {}, metadata: {}, status: "Running", created_at: "2026-08-24T10:00:00Z", ...overrides, }; } function storeWith(sessions: Session[]): CatalogStore { const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; return { snapshot: () => state, // Like the real store, deliver an immediate snapshot so the sidebar // renders its rows during construction. subscribe: (fn: CatalogListener) => { fn(state); return () => {}; }, } as unknown as CatalogStore; } function rowFor(el: HTMLElement, id: string): HTMLElement | null { return el.querySelector(`[data-session-id="${id}"]`); } describe("blocked-work indicator (#83)", () => { it("marks the row of a blocked session with the shared phrasing", () => { const blocked = session({ id: "aaaaaaaa-0000-0000-0000-000000000001", blocked_work: { uncommitted_files: 3, unpushed_commits: 2 }, }); const el = renderSidebar(storeWith([blocked])).el; const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked"); expect(badge?.textContent).toContain("⚠"); expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits"); }); it("does not mark clear or unprobed sessions", () => { const clear = session({ id: "aaaaaaaa-0000-0000-0000-000000000002", blocked_work: {} as InTheWay, }); const unprobed = session({ id: "aaaaaaaa-0000-0000-0000-000000000003", }); const el = renderSidebar(storeWith([clear, unprobed])).el; expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); }); it("restates the warning at the kill confirm", async () => { const confirmations: string[] = []; window.confirm = (msg?: string) => { confirmations.push(msg ?? ""); return false; }; const blocked = session({ id: "aaaaaaaa-0000-0000-0000-000000000004", command: "bash", blocked_work: { untracked_files: 1 }, }); const el = renderSidebar(storeWith([blocked])).el; const kill = rowFor(el, blocked.id)?.querySelector(".leaf-kill"); kill?.click(); await Promise.resolve(); expect(confirmations).toHaveLength(1); expect(confirmations[0]).toContain("1 untracked file"); expect(confirmations[0]).toContain("refused unless forced"); }); it("confirms plainly when nothing blocks", async () => { const confirmations: string[] = []; window.confirm = (msg?: string) => { confirmations.push(msg ?? ""); return false; }; const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); const el = renderSidebar(storeWith([plain])).el; rowFor(el, plain.id)?.querySelector(".leaf-kill")?.click(); await Promise.resolve(); expect(confirmations).toEqual(['Kill session "claude"?']); }); }); describe("agent-reported detail (#112)", () => { const ago = (ms: number): string => new Date(Date.now() - ms).toISOString(); it("renders the reported detail right after the duration on line 2", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000020", status: "Dead", activity: "Error", activity_since: ago(90_000), activity_detail: "cargo build failed", }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed"); const order = [...line2!.children].map((c) => c.className); expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration")); expect(order.indexOf("activity-detail")).toBeLessThan( order.findIndex((c) => c.startsWith("card-flag")), ); }); it("omits the detail when no report is in force", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000021", activity: "Active", activity_since: ago(90_000), }); const el = renderSidebar(storeWith([s])).el; expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull(); }); }); describe("session cards (#107)", () => { const ago = (ms: number): string => new Date(Date.now() - ms).toISOString(); it("keeps the name and the actions in the same flow line, actions reserving their width", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" }); const el = renderSidebar(storeWith([s])).el; const line1 = rowFor(el, s.id)?.querySelector(".card-line1"); expect(line1).not.toBeNull(); // Same flex line ⇒ the name truncates before it can ever reach the buttons. expect(line1?.querySelector(".leaf-name")).not.toBeNull(); expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild); }); it("stays a single line while freshly active", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000011", activity: "Active", activity_since: ago(5_000), }); const el = renderSidebar(storeWith([s])).el; expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull(); }); it("shows dot + floored duration once the state has been held past 30s", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000012", activity: "Active", activity_since: ago(4 * 60_000 + 30_000), }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.dataset.activity).toBe("active"); expect(line2?.querySelector(".activity-dot")).not.toBeNull(); expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m"); }); it("maps each activity state to its contract slug", () => { const mk = (id: string, activity: string) => session({ id, activity: activity as Session["activity"], status: "Running", activity_since: ago(90_000), }); const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle"); const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput"); const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error"); const el = renderSidebar(storeWith([idle, wait, error])).el; expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( "idle", ); expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( "awaiting-input", ); expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( "error", ); }); it("treats an unknown activity variant as idle instead of failing to render", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000016", activity: "Teleported" as Session["activity"], activity_since: ago(120_000), }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.getAttribute("data-activity")).toBe("idle"); expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m"); }); it("moves blocked work onto line 2 in the shared phrasing", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000017", blocked_work: { uncommitted_files: 3, unpushed_commits: 2 }, }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.textContent).toContain("⚠"); expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits"); // Line 1 carries no badges. expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull(); }); it("shows exited on line 2 for dead sessions", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000018", status: "Dead", }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.textContent).toContain("exited"); }); it("carries the state word only in tooltips and aria labels, never as text", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000019", command: "claude", metadata: { name: "worker" }, activity: "AwaitingInput", activity_since: ago(90_000), }); const el = renderSidebar(storeWith([s])).el; const row = rowFor(el, s.id) as HTMLElement; const dot = row.querySelector(".activity-dot"); expect(dot?.getAttribute("title")).toContain("Awaiting input"); expect(dot?.getAttribute("aria-label")).toContain("Awaiting input"); expect(row.textContent).not.toMatch(/awaiting.?input/i); }); it("keeps the attach navigation and depth indentation", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" }); const el = renderSidebar(storeWith([s])).el; const row = rowFor(el, s.id) as HTMLAnchorElement; expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a"); expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/); }); });