// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; import type { Project } from "./types.js"; const NOW = "2026-08-23T12:00:00Z"; function project(overrides: Partial): Project { return { path: `/home/user/${overrides.name ?? "proj"}`, name: "proj", is_worktree: false, ...overrides, }; } /** Three fetches gate the form's initial load: /api/info, /api/projects, and * the /api/names batch (#53). */ function stubFetch(info: unknown, projects: Project[]): void { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); const body = url.includes("/api/info") ? info : url.includes("/api/projects") ? projects : url.includes("/api/names") ? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] } : undefined; return new Response(JSON.stringify(body), { status: 200 }); }), ); } function boxPaths(form: HTMLFormElement): string[] { return [...form.querySelectorAll(".project-box")].map( (b) => b.dataset.cwd ?? "", ); } describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => { let handle: SpawnFormHandle | null = null; beforeEach(() => { vi.useFakeTimers({ now: Date.parse(NOW) }); }); afterEach(() => { handle?.destroy(); handle = null; vi.unstubAllGlobals(); vi.useRealTimers(); }); async function mount(projects: Project[], seed?: Parameters[0]["seed"]) { stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects); handle = createSpawnForm({ seed, onSuccess() {}, }); document.body.appendChild(handle.el); for (let i = 0; i < 20; i++) await Promise.resolve(); return handle; } it("renders one box per project, fresh before stale", async () => { const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW }); const stale = project({ name: "stale", path: "/home/user/stale", last_activity: "2026-01-01T00:00:00Z", }); const form = (await mount([stale, fresh])).el; expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]); const boxes = [...form.querySelectorAll(".project-box")]; expect(boxes[1].classList.contains("is-stale")).toBe(true); expect(boxes[0].classList.contains("is-stale")).toBe(false); }); it("shows the name as the primary line and the full path unabbreviated", async () => { const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" }); const form = (await mount([p])).el; const box = form.querySelector(".project-box")!; expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config"); // Not `~/t/nixos-config`: the abbreviation hid what distinguishes two // projects sharing a basename (#95). expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config"); }); it("lists only main checkouts as boxes — worktrees belong to step two", async () => { const main = project({ name: "maestro", path: "/home/user/maestro" }); const wt = project({ name: "wt", path: "/home/user/maestro/worktrees/wt", is_worktree: true, base_repo: main.path, }); const form = (await mount([wt, main])).el; expect(boxPaths(form)).toEqual(["/home/user/maestro"]); expect(form.querySelector(".project-box.is-worktree")).toBeNull(); }); it("filters boxes as the operator types, keeping a repo its worktree matched", async () => { const a = project({ name: "maestro", path: "/home/user/maestro" }); const b = project({ name: "tea", path: "/home/user/work/tea" }); const wt = project({ name: "bulgarians", path: "/home/user/maestro/worktrees/bulgarians", is_worktree: true, base_repo: a.path, }); const form = (await mount([a, b, wt])).el; const filter = form.querySelector(".project-filter")!; filter.value = "tea"; filter.dispatchEvent(new Event("input")); expect(boxPaths(form)).toEqual(["/home/user/work/tea"]); // A query that only matches a worktree still surfaces its repository box. filter.value = "bulgarians"; filter.dispatchEvent(new Event("input")); expect(boxPaths(form)).toEqual(["/home/user/maestro"]); filter.value = "zzz"; filter.dispatchEvent(new Event("input")); expect(boxPaths(form)).toEqual([]); expect(form.querySelector(".picker-empty")).toBeTruthy(); }); it("picking a project selects its cwd and pre-selects its last command", async () => { const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude", }); const onChange = vi.fn(); stubFetch( { hostname: "h", version: "0", home_dir: "/home/user", presets: [ { id: "claude", label: "Claude", command: "claude" }, { id: "custom", label: "Custom…", command: "" }, ], }, [p], ); handle = createSpawnForm({ onSuccess() {}, onChange }); const form = handle.el; document.body.appendChild(form); for (let i = 0; i < 20; i++) await Promise.resolve(); onChange.mockClear(); const box = form.querySelector(".project-box")!; box.click(); const values = form.querySelector("input[name=name]")!.value; const last = onChange.mock.lastCall![0]; expect(last.cwd).toBe("/home/user/maestro"); expect(last.command).toBe("claude"); expect(values).toBeTruthy(); // The raw input stays hidden — the picked box is the source of truth. expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true); expect(box.classList.contains("is-active")).toBe(true); }); it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => { const p = project({ name: "maestro", path: "/home/user/maestro" }); const form = (await mount([p])).el; const other = form.querySelector(".project-other")!; other.click(); expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false); }); it("falls back to the raw input when no projects exist", async () => { const form = (await mount([])).el; expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false); const raw = form.querySelector(".cwd-raw")!; expect(raw.value).toBe("/home/user"); }); });