This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/components/sidebar/Sidebar.test.tsx
T
Miguel Palhas e8e98baf4d feat(maestro): port the sidebar to React
Ports catalog.ts's tree renderer to components under
src/components/sidebar/, following docs/SIDEBAR.md: two-line cards
behind a 5px activity rail, adaptive meta line (duration, detail,
program, diff stat), collapsible folders, j/k walk, inline rename and a
right-click menu.

The nav walk reads a pure model (model.ts) rather than the DOM, so the
rule that an expanded folder header is skipped and a collapsed one is
not is testable without a render. Rail motion needs @keyframes, so it
stays in sidebar.css rather than the Tailwind config.

Closes #244
2026-09-09 00:21:13 +01:00

228 lines
7.8 KiB
TypeScript

// @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> = {}): 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<HTMLElement>(`[data-session-id="${id}"]`);
if (!el) throw new Error(`no card for ${id}`);
return el;
}
function tree(): HTMLElement {
return document.querySelector<HTMLElement>(".tree")!;
}
describe("SessionCard layout", () => {
it("renders two lines: the name, then a meta line naming the program", () => {
render(<Sidebar sessions={[session("alpha")]} />);
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(<Sidebar sessions={[fresh, held]} />);
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(
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>,
);
const rail = (id: string) => card(id).querySelector<HTMLElement>("[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(
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>,
);
const blocked = card("alpha").querySelector<HTMLElement>(".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(
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>,
);
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(<Sidebar sessions={two} />);
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(<Sidebar sessions={two} />);
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
fireEvent.click(header);
expect(header.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(tree(), { key: "j" });
expect(
document.querySelector<HTMLElement>('[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(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
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(<Sidebar sessions={two} onPreview={onPreview} />);
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(<Sidebar sessions={two} onKill={onKill} />);
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(
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
);
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(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
fireEvent.contextMenu(card("alpha"));
fireEvent.click(screen.getByText("Rename…"));
const input = card("alpha").querySelector<HTMLInputElement>(".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(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
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(<Sidebar sessions={[]} onSpawn={onSpawn} />);
fireEvent.click(screen.getByText("+ Spawn session"));
expect(onSpawn).toHaveBeenCalled();
});
});