// @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js"; /** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called — * everything else (names, projects, gitroot) answers immediately with an * empty/default body. Lets a test pick a preset (or type a custom command) * mid-flight, then observe what the late `/api/info` resolution does to it. */ function stubDeferredInfo(): { resolveInfo: () => void } { let releaseInfo: () => void = () => {}; const infoGate = new Promise((resolve) => { releaseInfo = resolve; }); vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/api/info")) { await infoGate; return new Response( JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [ { id: "claude", label: "Claude", command: "claude" }, { id: "fish", label: "Fish", command: "fish" }, { id: "custom", label: "Custom…", command: "" }, ], }), { status: 200 }, ); } if (url.includes("/api/names")) { return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 }); } return new Response(JSON.stringify([]), { status: 200 }); }), ); return { resolveInfo: releaseInfo }; } describe("spawnForm preset selection vs. late /api/info (#76)", () => { let handle: SpawnFormHandle | null = null; afterEach(() => { handle?.destroy(); handle = null; vi.unstubAllGlobals(); }); async function drain(): Promise { for (let i = 0; i < 40; i++) await Promise.resolve(); } it("keeps an operator-picked tile after /api/info resolves late", async () => { const { resolveInfo } = stubDeferredInfo(); let values: SpawnFormValues | undefined; handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); const form = handle.el; document.body.appendChild(form); await drain(); // Fallback presets are up; the operator picks "zsh" before /api/info lands. const zshTile = [...form.querySelectorAll(".tile")].find( (t) => t.dataset.id === "zsh", )!; zshTile.click(); expect(zshTile.classList.contains("is-active")).toBe(true); // /api/info lands late with a preset list that doesn't even include "zsh". resolveInfo(); await drain(); // The operator's pick must not be discarded back to the new list's first // entry — `onChange` only starts firing once the initial load settles // (`ready`), which happens right here, so this is the first place the // published command is observable. expect(values?.command).toBe("zsh"); }); it("keeps an operator-typed custom command after /api/info resolves late", async () => { const { resolveInfo } = stubDeferredInfo(); let values: SpawnFormValues | undefined; handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); const form = handle.el; document.body.appendChild(form); await drain(); const customTile = [...form.querySelectorAll(".tile")].find( (t) => t.dataset.id === "custom", )!; customTile.click(); const cmdRaw = form.querySelector(".cmd-raw")!; cmdRaw.value = "htop"; cmdRaw.dispatchEvent(new Event("input")); resolveInfo(); await drain(); expect(values?.command).toBe("htop"); expect(cmdRaw.value).toBe("htop"); }); it("still applies the real presets' first entry when the operator never touched the command", async () => { const { resolveInfo } = stubDeferredInfo(); let values: SpawnFormValues | undefined; handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); const form = handle.el; document.body.appendChild(form); await drain(); resolveInfo(); await drain(); // Untouched: the refined preset list is still free to settle the command. expect(values?.command).toBe("claude"); const fishTile = [...form.querySelectorAll(".tile")].find( (t) => t.dataset.id === "fish", ); expect(fishTile).toBeDefined(); }); });