// @vitest-environment happy-dom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Session } from "../../types.js"; import { Sidebar } from "./Sidebar.js"; afterEach(() => { cleanup(); vi.useRealTimers(); }); function session(id: string, over: Partial = {}): Session { return { id, cwd: "/home/n/alpha", command: "hx src/", env: {}, metadata: { name: id }, status: "Running", created_at: "2026-08-24T10:00:00Z", ...over, } as Session; } /** The card element for a session, by the `data-session-id` the row carries. */ function card(id: string): HTMLElement { const el = document.querySelector(`[data-session-id="${id}"]`); if (!el) throw new Error(`no card for ${id}`); return el; } function tree(): HTMLElement { return document.querySelector(".tree")!; } describe("SessionCard layout", () => { it("renders two lines: the name, then a meta line naming the program", () => { render(); const row = card("alpha"); expect(row.textContent).toContain("alpha"); const meta = row.querySelector(".card-line2")!; // The program only — never its arguments. expect(meta.textContent).toContain("hx"); expect(meta.textContent).not.toContain("src/"); }); it("shows no duration under the 30s hold, and one above it", () => { const now = Date.parse("2026-08-24T10:00:00Z"); vi.setSystemTime(now); const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" }); const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" }); render(); expect(card("fresh").querySelector(".activity-duration")).toBeNull(); expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m"); }); it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => { render( , ); const rail = (id: string) => card(id).querySelector("[data-activity]")!; expect(rail("idle").dataset.activity).toBe("idle"); expect(rail("idle").className).not.toContain("rail-"); expect(rail("await").className).toContain("rail-blink"); expect(rail("err").className).toContain("rail-flash"); // Status never rests on colour alone. expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input"); }); it("renders blocked work as diff notation with the spelled-out tooltip", () => { render( , ); const blocked = card("alpha").querySelector(".leaf-blocked")!; expect(blocked.textContent).toBe("+124-18?3"); expect(blocked.title).toContain("2 uncommitted files"); }); it("dims a provisional name and leaves a settled one alone", () => { render( , ); expect(card("gen").querySelector(".text-muted")).not.toBeNull(); expect(card("user").textContent).toContain("user"); }); }); describe("keyboard walk", () => { const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })]; it("j and k step the highlight through the cards", () => { render(); fireEvent.keyDown(tree(), { key: "j" }); expect(card("a").dataset.highlighted).toBe("true"); fireEvent.keyDown(tree(), { key: "j" }); expect(card("b").dataset.highlighted).toBe("true"); fireEvent.keyDown(tree(), { key: "k" }); expect(card("a").dataset.highlighted).toBe("true"); }); it("stops on a collapsed folder header and Enter expands onto its first card", () => { render(); const header = document.querySelector('[data-nav-key="folder:one"]')!; fireEvent.click(header); expect(header.getAttribute("aria-expanded")).toBe("false"); fireEvent.keyDown(tree(), { key: "j" }); expect( document.querySelector('[data-nav-key="folder:one"]')!.dataset.highlighted, ).toBe("true"); fireEvent.keyDown(tree(), { key: "Enter" }); expect(card("a").dataset.highlighted).toBe("true"); }); it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => { const onSelect = vi.fn(); const onFocusPane = vi.fn(); render(); fireEvent.keyDown(tree(), { key: "j" }); fireEvent.keyDown(tree(), { key: "Enter" }); expect(onSelect).toHaveBeenCalledWith("a"); fireEvent.keyDown(tree(), { key: "Tab" }); expect(onFocusPane).toHaveBeenCalled(); }); it("previews the session the walk lands on", () => { const onPreview = vi.fn(); render(); fireEvent.keyDown(tree(), { key: "j" }); expect(onPreview).toHaveBeenCalledWith("a"); }); it("x asks to kill the highlighted session and r renames it", () => { const onKill = vi.fn(); render(); fireEvent.keyDown(tree(), { key: "j" }); fireEvent.keyDown(tree(), { key: "x" }); expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })); fireEvent.keyDown(tree(), { key: "r" }); expect(card("a").querySelector(".leaf-rename")).not.toBeNull(); }); }); describe("context menu", () => { it("offers rename, spawn similar and kill", () => { const onSpawnSimilar = vi.fn(); const onKill = vi.fn(); render( , ); fireEvent.contextMenu(card("alpha")); const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent); expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]); fireEvent.click(screen.getByText("Spawn similar")); expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" })); }); it("renames in place: Enter commits the field, Escape cancels", () => { const onRename = vi.fn(); render(); fireEvent.contextMenu(card("alpha")); fireEvent.click(screen.getByText("Rename…")); const input = card("alpha").querySelector(".leaf-rename")!; expect(input.value).toBe("alpha"); fireEvent.change(input, { target: { value: " renamed " } }); fireEvent.keyDown(input, { key: "Enter" }); expect(onRename).toHaveBeenCalledWith("alpha", "renamed"); expect(card("alpha").querySelector(".leaf-rename")).toBeNull(); }); }); describe("search", () => { it("filters the tree down to matching sessions", () => { render(); fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } }); expect(document.querySelector('[data-session-id="alpha"]')).toBeNull(); expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull(); }); }); describe("empty state", () => { it("offers a spawn when there are no sessions at all", () => { const onSpawn = vi.fn(); render(); fireEvent.click(screen.getByText("+ Spawn session")); expect(onSpawn).toHaveBeenCalled(); }); });