// @vitest-environment happy-dom import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { renderSidebar } from "./catalog.js"; import { closeContextMenu } from "./contextMenu.js"; import type { CatalogListener, CatalogStore } from "./events.js"; import type { Session } from "./types.js"; import { normalizeSessionName } from "./types.js"; beforeAll(() => { Object.defineProperty(window, "localStorage", { value: { getItem: () => null, setItem: () => {} }, }); }); let mounted: { el: HTMLElement; dispose: () => void } | null = null; afterEach(() => { closeContextMenu(); mounted?.dispose(); mounted = null; vi.unstubAllGlobals(); }); function session(overrides: Partial = {}): Session { return { id: "aaaaaaaa-0000-0000-0000-0000000000ff", cwd: "/home/naps/tea/maestro", command: "claude", env: {}, metadata: { name: "amber-falcon" }, 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, subscribe: (fn: CatalogListener) => { fn(state); return () => {}; }, } as unknown as CatalogStore; } /** Mount a sidebar with one session; the handle is disposed after each test. */ function mount(s: Session): HTMLElement { vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error("offline"))), ); const handle = renderSidebar(storeWith([s])); mounted = handle; return handle.el; } function cardIn(el: HTMLElement, s: Session): HTMLElement { return el.querySelector(`[data-session-id="${s.id}"]`) as HTMLElement; } /** Mount a sidebar with one session and right-click its card. */ function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } { const el = mount(s); const row = cardIn(el, s); row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true })); return { el, row }; } function menuItem(label: string): HTMLButtonElement { const found = [...document.querySelectorAll(".context-menu-item")].find( (b) => b.textContent === label, ); if (!found) throw new Error(`no "${label}" entry in the context menu`); return found; } describe("session name normalisation (#125)", () => { it("trims, and treats a blank name as a clear", () => { expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon"); expect(normalizeSessionName(" ")).toBe(""); }); it("caps at the daemon's limit, counting code points", () => { expect(normalizeSessionName("x".repeat(80))).toHaveLength(64); expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64); }); }); describe("sidebar card context menu (#125)", () => { it("suppresses the browser menu and offers rename, spawn similar and kill", () => { const s = session(); const row = cardIn(mount(s), s); const ev = new Event("contextmenu", { bubbles: true, cancelable: true }); row.dispatchEvent(ev); expect(ev.defaultPrevented).toBe(true); expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([ "Rename…", "Spawn similar", "Kill session", ]); }); it("spawn similar dispatches the same event the ⧉ button does", () => { const s = session(); openCardMenu(s); const seen = vi.fn(); document.addEventListener("catalog-spawn-similar", seen); menuItem("Spawn similar").click(); document.removeEventListener("catalog-spawn-similar", seen); expect(seen).toHaveBeenCalledOnce(); }); }); describe("inline rename (#125)", () => { it("pre-fills the field with the session's own name", () => { const { row } = openCardMenu(session()); menuItem("Rename…").click(); const input = row.querySelector("input.leaf-rename"); expect(input?.value).toBe("amber-falcon"); expect(row.querySelector("span.leaf-name")).toBeNull(); }); it("starts empty for a session that has no name of its own", () => { const { row } = openCardMenu(session({ metadata: {} })); menuItem("Rename…").click(); expect(row.querySelector("input.leaf-rename")?.value).toBe(""); }); it("holds the tree still while the field is open", () => { const s = session(); const { el, row } = openCardMenu(s); menuItem("Rename…").click(); const input = row.querySelector("input.leaf-rename"); // A catalog delta mid-edit must not rebuild the row out from under it. document.dispatchEvent(new CustomEvent("catalog-rerender")); expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input); }); it("Enter PATCHes the session with the typed name", () => { const s = session(); const { row } = openCardMenu(s); menuItem("Rename…").click(); const input = row.querySelector("input.leaf-rename") as HTMLInputElement; input.value = "crimson-otter"; const fetchMock = vi.fn(() => Promise.resolve(new Response(JSON.stringify(s), { status: 200 })), ); vi.stubGlobal("fetch", fetchMock); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe(`/api/sessions/${s.id}`); expect(init.method).toBe("PATCH"); expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" }); }); it("Escape cancels without a request and restores the name", () => { const s = session(); const { el, row } = openCardMenu(s); menuItem("Rename…").click(); const input = row.querySelector("input.leaf-rename") as HTMLInputElement; input.value = "discarded"; const fetchMock = vi.fn(() => Promise.reject(new Error("offline"))); vi.stubGlobal("fetch", fetchMock); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(fetchMock).not.toHaveBeenCalled(); // The row is rebuilt from the store, so the old name comes back. expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon"); expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull(); }); });