// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { closeSpawnModal, openSpawnModal } from "./spawnModal.js"; import type { Session } from "./types.js"; function stubFetch(): void { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); const body = url.includes("/api/info") ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } : url.includes("/api/projects") ? [] : url.includes("/api/names") ? { names: ["cello"] } : undefined; return new Response(JSON.stringify(body), { status: 200 }); }), ); } /** Drain the microtask queue so the form's initial loads land. */ async function flush(): Promise { for (let i = 0; i < 20; i++) await Promise.resolve(); } const modal = () => document.getElementById("modal-host"); describe("spawn modal", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); }); afterEach(() => { closeSpawnModal(); vi.unstubAllGlobals(); }); it("mounts the shared spawn form inside a dialog", async () => { openSpawnModal({ onSuccess: () => {} }); await flush(); const host = modal()!; expect(host).toBeTruthy(); expect(host.querySelector('[role="dialog"]')).toBeTruthy(); expect(host.querySelector("form.spawn-form")).toBeTruthy(); expect(host.querySelector("#spawn-title")?.textContent).toBe("Spawn session"); // The modal adds the Cancel the shared form doesn't ship. expect(host.querySelector(".form-actions .cancel")).toBeTruthy(); }); it("opens with the project filter focused, harness before project", async () => { openSpawnModal({ onSuccess: () => {} }); await flush(); const form = modal()!.querySelector("form.spawn-form")!; expect(document.activeElement).toBe(form.querySelector(".project-filter")); const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent); expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]); }); it("titles a seeded open as a clone", async () => { openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} }); await flush(); expect(modal()!.querySelector("#spawn-title")?.textContent).toBe("Spawn similar"); }); it("closes on Escape, on Cancel and on a backdrop click", async () => { for (const dismiss of [ () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })), () => (modal()!.querySelector(".form-actions .cancel") as HTMLButtonElement).click(), () => modal()!.dispatchEvent(new MouseEvent("click", { bubbles: true })), () => (modal()!.querySelector(".close") as HTMLButtonElement).click(), ]) { openSpawnModal({ onSuccess: () => {} }); await flush(); dismiss(); expect(modal()).toBeNull(); } }); it("leaves no stray Escape handler behind after closing", async () => { openSpawnModal({ onSuccess: () => {} }); await flush(); closeSpawnModal(); // A second Escape with no modal up must not throw on the torn-down form. window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); expect(modal()).toBeNull(); }); it("opening twice tears the first one down rather than stacking dialogs", async () => { openSpawnModal({ onSuccess: () => {} }); await flush(); openSpawnModal({ onSuccess: () => {} }); await flush(); expect(document.querySelectorAll("#modal-host")).toHaveLength(1); expect(document.querySelectorAll("form.spawn-form")).toHaveLength(1); }); it("closes before handing the created session to its caller", async () => { const seen: Array<{ session: Session; stillOpen: boolean }> = []; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.includes("/api/sessions") && init?.method === "POST") { return new Response(JSON.stringify({ id: "s1", cwd: "/x", command: "zsh" }), { status: 200 }); } const body = url.includes("/api/info") ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } : url.includes("/api/projects") ? [] : url.includes("/api/names") ? { names: ["cello"] } : undefined; return new Response(JSON.stringify(body), { status: 200 }); }), ); openSpawnModal({ // A plain, worktree-free spawn: the point here is where the created // session is handed over, not what the form validates. seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false }, onSuccess: (session) => seen.push({ session, stillOpen: modal() != null }), }); await flush(); (modal()!.querySelector("form.spawn-form") as HTMLFormElement).dispatchEvent( new Event("submit", { bubbles: true, cancelable: true }), ); await flush(); expect(seen).toHaveLength(1); expect(seen[0].session.id).toBe("s1"); expect(seen[0].stillOpen).toBe(false); }); });