// @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 rowPaths(form: HTMLFormElement): string[] { return [...form.querySelectorAll(".project-row:not(.project-other)")].map( (r) => r.dataset.cwd ?? "", ); } describe("spawnForm project picker", () => { 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 row 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(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]); const rows = [...form.querySelectorAll(".project-row")]; expect(rows[1].classList.contains("is-stale")).toBe(true); expect(rows[0].classList.contains("is-stale")).toBe(false); }); it("nests worktrees under their base repository", async () => { const main = project({ name: "maestro", path: "/home/user/maestro" }); const wt = project({ name: "wt", path: "/elsewhere/wt", is_worktree: true, base_repo: main.path, }); const form = (await mount([wt, main])).el; expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]); const [mainRow, wtRow] = [ ...form.querySelectorAll(".project-row:not(.project-other)"), ]; expect(wtRow.classList.contains("is-worktree")).toBe(true); expect(mainRow.classList.contains("is-worktree")).toBe(false); }); it("filters rows as the operator types", async () => { const a = project({ name: "maestro", path: "/home/user/maestro" }); const b = project({ name: "tea", path: "/home/user/work/tea" }); const form = (await mount([a, b])).el; const filter = form.querySelector(".project-filter")!; filter.value = "tea"; filter.dispatchEvent(new Event("input")); expect(rowPaths(form)).toEqual(["/home/user/work/tea"]); filter.value = "zzz"; filter.dispatchEvent(new Event("input")); expect(rowPaths(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 row = form.querySelector(".project-row")!; row.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 row is the source of truth. expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true); expect(row.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"); }); });