// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js"; const PROJECTS = [ { path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" }, ]; const BRANCHES = [ { name: "main", is_head: true }, { name: "spawn-v2", is_head: false, worktree: "/home/user/wt-spawn" }, { name: "free-branch", is_head: false }, ]; /** Stub the fetches the form makes. `gateBranches` (when given) holds the * /api/branches response until released, so tests can destroy the form while * its branch list is still in flight. */ function stubFetch(opts?: { gateBranches?: (release: () => void) => void; projects?: unknown[]; }): string[] { const urls: string[] = []; const gated = new Promise((resolve) => { if (opts?.gateBranches) opts.gateBranches(resolve); else resolve(); }); vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); urls.push(url); if (url.includes("/api/names")) { return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 }); } if (url.includes("/api/branches")) { await gated; return new Response(JSON.stringify(BRANCHES), { status: 200 }); } if (url.includes("/api/projects")) { return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 }); } return new Response( JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }), { status: 200 }, ); }), ); return urls; } describe("spawnForm worktree defaults and branch picker (#61)", () => { let handle: SpawnFormHandle | null = null; let urls: string[]; beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { handle?.destroy(); handle = null; vi.unstubAllGlobals(); vi.useRealTimers(); }); async function mount( seed?: Parameters[0]["seed"], onChange?: (values: SpawnFormValues) => void, ) { urls = stubFetch(); handle = createSpawnForm({ seed, onSuccess() {}, onChange }); document.body.appendChild(handle.el); for (let i = 0; i < 40; i++) await Promise.resolve(); return handle.el; } const drain = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; function pickProject(form: HTMLFormElement): void { form.querySelector(".project-other")!.click(); form .querySelectorAll(".project-box") .forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click()); } it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => { const form = await mount(); expect(form.querySelector("input[name=worktree_enabled]")!.checked).toBe( true, ); pickProject(form); await drain(); expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); expect(form.querySelector(".wt-repo")!.textContent).toContain( "/home/user/maestro", ); }); it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => { urls = stubFetch({ projects: [ { path: "/home/user/wt-91", name: "wt-91", is_worktree: true, base_repo: "/home/user/maestro", last_activity: "2026-08-20T10:00:00Z", }, ], }); handle = createSpawnForm({ onSuccess() {} }); document.body.appendChild(handle.el); for (let i = 0; i < 40; i++) await Promise.resolve(); handle.el .querySelectorAll(".project-box") .forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click()); await drain(); expect(handle.el.querySelector("input[name=worktree_enabled]")!.checked).toBe( false, ); expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); expect(handle.el.querySelector(".wt-body")!.hidden).toBe(true); }); it("still probes the gitroot for a free-text directory", async () => { const form = await mount(); // Escape hatch: switch to a free-text dir; only now does /api/gitroot // fire — debounced, so the quiet period must elapse first (#74). form.querySelector(".project-other")!.click(); const raw = form.querySelector("input[name=cwd_raw]")!; raw.value = "/opt/somewhere"; raw.dispatchEvent(new Event("input")); await vi.advanceTimersByTimeAsync(250); await drain(); expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true); }); it("populates existing branches, disabling ones held by another worktree", async () => { const form = await mount(); pickProject(form); const radio = form.querySelector( 'input[name=worktree_branch_mode][value="existing"]', )!; radio.click(); await drain(); const select = form.querySelector(".branch-select")!; expect(urls.some((u) => u.includes("/api/branches?path="))).toBe(true); const options = [...select.options]; expect(options.map((o) => o.value)).toEqual(["main", "spawn-v2", "free-branch"]); const held = options.find((o) => o.value === "spawn-v2")!; expect(held.disabled).toBe(true); expect(held.textContent).toContain("/home/user/wt-spawn"); expect(options.find((o) => o.value === "free-branch")!.disabled).toBe(false); }); it("emits the branch mode as draft state when it changes", async () => { const changes: SpawnFormValues[] = []; const form = await mount(undefined, (v) => changes.push(v)); await drain(); const radio = form.querySelector( 'input[name=worktree_branch_mode][value="existing"]', )!; radio.click(); await drain(); const modes = changes.map((c) => (c as { worktreeBranchMode?: string }).worktreeBranchMode); expect(modes).toContain("new"); expect(modes.at(-1)).toBe("existing"); }); it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => { const form = await mount(); pickProject(form); await drain(); const newLabel = form.querySelector(".wt-new-wrap")!.textContent!.trim(); const existLabel = form.querySelector(".wt-exist-wrap")!.textContent!.trim(); expect(newLabel).not.toBe("Branch"); expect(existLabel).not.toBe("Branch"); expect(newLabel).not.toBe(existLabel); expect(newLabel).toMatch(/name/i); expect(existLabel).toMatch(/pick|choose|select/i); }); it("collapses the advanced (worktree) section by default (#93)", async () => { const form = await mount(); const toggle = form.querySelector(".wt-toggle")!; expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(form.querySelector(".wt-collapsible")!.hidden).toBe(true); }); it("states in the collapsed header that a worktree will be created by default (#93)", async () => { const form = await mount(); expect(form.querySelector(".wt-summary")!.textContent).toMatch(/creates? a worktree/i); }); it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => { urls = stubFetch({ projects: [ { path: "/home/user/wt-91", name: "wt-91", is_worktree: true, base_repo: "/home/user/maestro", last_activity: "2026-08-20T10:00:00Z", }, ], }); handle = createSpawnForm({ onSuccess() {} }); document.body.appendChild(handle.el); for (let i = 0; i < 40; i++) await Promise.resolve(); handle.el .querySelectorAll(".project-box") .forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click()); await drain(); expect(handle.el.querySelector(".wt-summary")!.textContent).toMatch(/no worktree/i); // Still collapsed — picking a project must not force it open. expect(handle.el.querySelector(".wt-toggle")!.getAttribute("aria-expanded")).toBe( "false", ); }); it("expanding the advanced section does not reorder the fields above it (#93)", async () => { const form = await mount(); const before = [...form.querySelectorAll(".field")]; form.querySelector(".wt-toggle")!.click(); expect(form.querySelector(".wt-toggle")!.getAttribute("aria-expanded")).toBe( "true", ); expect(form.querySelector(".wt-collapsible")!.hidden).toBe(false); expect([...form.querySelectorAll(".field")]).toStrictEqual(before); }); it("does not repopulate a destroyed form when the branch list lands late", async () => { const gate: { release?: () => void } = {}; urls = stubFetch({ gateBranches: (r) => (gate.release = r) }); handle = createSpawnForm({ onSuccess() {} }); document.body.appendChild(handle.el); for (let i = 0; i < 40; i++) await Promise.resolve(); pickProject(handle.el); handle.el .querySelector('input[name=worktree_branch_mode][value="existing"]')! .click(); handle.destroy(); gate.release?.(); await drain(); expect( handle.el.querySelector(".branch-select")!.options.length, ).toBe(0); }); });