// @vitest-environment happy-dom import { beforeAll, describe, expect, it } from "vitest"; import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js"; import type { CatalogListener, CatalogStore } from "./events.js"; import type { Session } from "./types.js"; beforeAll(() => { Object.defineProperty(window, "localStorage", { value: { getItem: () => null, setItem: () => {} }, }); window.fetch = () => Promise.reject(new Error("offline")); }); function session(id: string, cwd: string): Session { return { id, cwd, command: "claude", env: {}, metadata: {}, status: "Running", created_at: "2026-08-24T10:00:00Z", } as Session; } 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; } const A = "aaaaaaaa-0000-0000-0000-00000000000a"; const B = "aaaaaaaa-0000-0000-0000-00000000000b"; /** Two sessions in sibling directories: two folder headers, two cards. */ function twoFolders(): { el: HTMLElement; handle: ReturnType } { const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")])); // Mounted: `focusList` moves real DOM focus, which needs a live document. document.body.replaceChildren(handle.el); return { el: handle.el, handle }; } function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void { el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); } function highlighted(el: HTMLElement): string | null { return el.querySelector(".is-highlighted")?.dataset.navKey ?? null; } function tree(el: HTMLElement): HTMLElement { return el.querySelector(".tree")!; } describe("sidebar keyboard navigation", () => { it("walks the cards and never stops on an expanded folder", () => { const { el } = twoFolders(); // The folders are there — the walk just refuses to land on them. expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2); press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${A}`); press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${B}`); // Wraps. press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${A}`); press(tree(el), "k"); expect(highlighted(el)).toBe(`session:${B}`); }); it("accepts the arrow keys as well as j/k", () => { const { el } = twoFolders(); press(tree(el), "ArrowDown"); expect(highlighted(el)).toBe(`session:${A}`); press(tree(el), "ArrowUp"); // Backwards from the first card wraps to the last. expect(highlighted(el)).toBe(`session:${B}`); }); it("stops on a collapsed folder, since its cards are gone", () => { const { el } = twoFolders(); const header = el.querySelectorAll(".tree-folder-header")[0]; header.click(); // collapse /one press(tree(el), "j"); expect(highlighted(el)).toBe("folder:one"); press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${B}`); // Enter expands it again and lands on the card it was hiding. press(tree(el), "j"); expect(highlighted(el)).toBe("folder:one"); press(tree(el), "Enter"); expect(highlighted(el)).toBe(`session:${A}`); }); it("focusList starts from the session on screen", () => { const { el, handle } = twoFolders(); handle.setHighlights({ open: new Set([B]), focused: B }); handle.focusList(); expect(highlighted(el)).toBe(`session:${B}`); expect(handle.hasListFocus()).toBe(true); }); it("Tab asks the shell for the pane back", () => { const { el } = twoFolders(); let asked = 0; document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1)); press(tree(el), "Tab"); expect(asked).toBe(1); }); it("leaves the keyboard alone while a row is being renamed", () => { const { el } = twoFolders(); press(tree(el), "j"); const input = document.createElement("input"); tree(el).appendChild(input); input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true })); expect(highlighted(el)).toBe(`session:${A}`); }); });