// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; /** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in * spawnForm.ts (#74). */ const DEBOUNCE_MS = 250; const PROJECTS = [ { path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" }, ]; /** Stub the fetches the form makes. Gitroot responses for a given path can be * held until `releaseGitRoot(path)` is called, so tests can land answers out * of order. */ function stubFetch(opts?: { holdGitRootFor?: string[] }): { urls: string[]; gitRootCalls: string[]; releaseGitRoot: (path: string) => void; } { const urls: string[] = []; const gitRootCalls: string[] = []; const releases = new Map void>(); const held = new Map>(); vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); urls.push(url); if (url.includes("/api/dirs")) { return new Response(JSON.stringify({ entries: [] }), { status: 200 }); } if (url.includes("/api/gitroot")) { const path = new URL(url, "http://localhost").searchParams.get("path") ?? ""; gitRootCalls.push(path); if (opts?.holdGitRootFor?.includes(path)) { let release!: () => void; held.set( path, new Promise((r) => { release = r; }), ); releases.set(path, release); } await held.get(path); return new Response(JSON.stringify({ root: path }), { status: 200 }); } if (url.includes("/api/names")) { return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 }); } if (url.includes("/api/projects")) { return new Response(JSON.stringify(PROJECTS), { status: 200 }); } return new Response( JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }), { status: 200 }, ); }), ); return { urls, gitRootCalls, releaseGitRoot: (path: string) => releases.get(path)?.(), }; } describe("spawnForm gitroot probe debounce (#74)", () => { let handle: SpawnFormHandle | null = null; beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { handle?.destroy(); handle = null; vi.unstubAllGlobals(); vi.useRealTimers(); }); async function mount(opts?: Parameters[0]) { const stub = stubFetch(opts); handle = createSpawnForm({ onSuccess() {} }); document.body.appendChild(handle.el); for (let i = 0; i < 40; i++) await Promise.resolve(); return { form: handle.el, stub }; } const drain = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; function typeRaw(form: HTMLFormElement, value: string): void { const raw = form.querySelector("input[name=cwd_raw]")!; raw.value = value; raw.dispatchEvent(new Event("input")); } it("a keystroke burst fires exactly one probe after the quiet period", async () => { const { form, stub } = await mount(); form.querySelector(".project-other")!.click(); // Burst: every keystroke reschedules; nothing may hit /api/gitroot yet. for (let i = 1; i <= "/opt/somewhere".length; i++) { typeRaw(form, "/opt/somewhere".slice(0, i)); await drain(); expect(stub.gitRootCalls).toEqual([]); } await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); await drain(); expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]); expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1); }); it("a stale probe answer cannot overwrite a newer input", async () => { const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] }); form.querySelector(".project-other")!.click(); // Probe for the old target, held in flight. typeRaw(form, "/repo/old"); await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); // Newer target supersedes it and answers immediately. typeRaw(form, "/repo/new"); await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); await drain(); expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]); expect(form.querySelector(".wt-repo")!.textContent).toContain("/repo/new"); expect( form.querySelector("input[name=worktree_enabled]")!.checked, ).toBe(true); // The late answer to the old target lands last but must lose. stub.releaseGitRoot("/repo/old"); await drain(); expect(form.querySelector(".wt-repo")!.textContent).toContain("/repo/new"); expect( form.querySelector("input[name=worktree_enabled]")!.checked, ).toBe(true); }); });