// @vitest-environment happy-dom import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { BranchInfo, DaemonInfo, Project, Session, SpawnPreset } from "../../types.js"; import { SpawnForm } from "./SpawnForm.js"; vi.mock("../../api.js", () => ({ createSession: vi.fn(), fetchBranches: vi.fn(), fetchNames: vi.fn(), getDaemonInfo: vi.fn(), gitRoot: vi.fn(), listDir: vi.fn(), projects: vi.fn(), })); import { createSession, fetchBranches, fetchNames, getDaemonInfo, gitRoot, listDir, projects as fetchProjects, } from "../../api.js"; const mockCreateSession = vi.mocked(createSession); const mockFetchBranches = vi.mocked(fetchBranches); const mockFetchNames = vi.mocked(fetchNames); const mockGetDaemonInfo = vi.mocked(getDaemonInfo); const mockGitRoot = vi.mocked(gitRoot); const mockListDir = vi.mocked(listDir); const mockFetchProjects = vi.mocked(fetchProjects); const presets: SpawnPreset[] = [ { id: "claude", label: "Claude", command: "claude" }, { id: "opencode", label: "opencode", command: "opencode" }, { id: "zsh", label: "Shell", command: "zsh" }, { id: "custom", label: "Custom…", command: "" }, ]; function info(over: Partial = {}): DaemonInfo { return { hostname: "h", version: "1", home_dir: "/home/n", presets, ...over }; } function project(over: Partial = {}): Project { return { path: "/repos/p", name: "p", last_activity: new Date().toISOString(), ...over }; } function session(over: Partial = {}): Session { return { id: "s1", cwd: "/repos/p", command: "claude", env: {}, metadata: {}, status: "Running", created_at: "2026-01-01T00:00:00Z", ...over, }; } /** Defaults every test starts from: one known project, no branches, a fresh * generated handle. Individual tests override what they care about. */ function stub(over: { projects?: Project[]; info?: DaemonInfo; names?: string[]; branches?: BranchInfo[] } = {}) { mockGetDaemonInfo.mockResolvedValue(over.info ?? info()); mockFetchProjects.mockResolvedValue(over.projects ?? [project()]); mockFetchNames.mockResolvedValue({ names: over.names ?? ["amber-fox"] }); mockFetchBranches.mockResolvedValue(over.branches ?? []); mockGitRoot.mockResolvedValue({}); mockListDir.mockResolvedValue({ path: "", entries: [] }); } afterEach(() => { cleanup(); vi.clearAllMocks(); }); async function settle() { await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull()); } /** Everything worktree/name/structured-related lives behind the "Advanced" * disclosure, collapsed by default (#93) — mirrors the vanilla form exactly. */ function openAdvanced() { fireEvent.click(screen.getByRole("button", { name: /Advanced/ })); } describe("SpawnForm harness picker", () => { it("selects the first non-custom preset by default and reveals the custom input on Custom…", async () => { stub(); render(); await settle(); expect(screen.getByRole("button", { name: /^Claude/ }).className).toContain("border-accent"); fireEvent.click(screen.getByRole("button", { name: "Custom…" })); expect(screen.getByPlaceholderText("bash -l").hidden).toBe(false); }); it("resolves a seeded command to its matching preset", async () => { stub(); render(); await settle(); const opencode = screen.getByRole("button", { name: /opencode/ }); expect(opencode.className).toContain("border-accent"); }); it("drops an unmatched seeded command into the custom field", async () => { stub(); render(); await settle(); const raw = screen.getByPlaceholderText("bash -l") as HTMLInputElement; expect(raw.hidden).toBe(false); expect(raw.value).toBe("hx src/"); }); }); describe("SpawnForm project picker", () => { it("lists discovered projects and reveals the worktree step on pick", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] }); render(); await settle(); openAdvanced(); expect(screen.queryByText("Worktree")).toBeNull(); fireEvent.click(screen.getByText("a")); expect(screen.getByText("Worktree")).not.toBeNull(); expect(screen.getByText("+ new").closest("button")?.getAttribute("data-active")).toBe("true"); }); it("filters the project grid by name", async () => { stub({ projects: [project({ path: "/repos/alpha", name: "alpha" }), project({ path: "/repos/beta", name: "beta" })] }); render(); await settle(); fireEvent.change(screen.getByPlaceholderText("Filter projects…"), { target: { value: "alp" } }); expect(screen.getByText("alpha")).not.toBeNull(); expect(screen.queryByText("beta")).toBeNull(); }); it("falls back to the raw directory input when no projects are discovered", async () => { stub({ projects: [] }); render(); await settle(); expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/home/n"); }); it("Other directory… clears the picker and opens the raw input", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })] }); render(); await settle(); fireEvent.click(screen.getByText("a")); expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBe("true"); fireEvent.click(screen.getByText("Other directory…")); expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBeNull(); expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/repos/a"); }); }); describe("SpawnForm worktree defaults", () => { it("defaults the toggle on for a picked project and infers the repo without a gitroot probe", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })] }); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByText("a")); expect(screen.getByText("repo: /repos/a")).not.toBeNull(); expect(mockGitRoot).not.toHaveBeenCalled(); }); it("defaults the toggle off for a project that is itself a worktree (#91)", async () => { stub({ projects: [ project({ path: "/repos/main", name: "main" }), project({ path: "/repos/wt", name: "wt", is_worktree: true, base_repo: "/repos/main" }), ], }); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByText("main")); fireEvent.click(screen.getByText("wt")); expect((screen.getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(false); }); it("still probes gitroot for a free-text directory", async () => { stub(); mockGitRoot.mockResolvedValue({ root: "/repos/found" }); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByText("Other directory…")); fireEvent.change(screen.getByPlaceholderText("/home/user/project"), { target: { value: "/repos/found/sub" }, }); await waitFor(() => expect(mockGitRoot).toHaveBeenCalledWith("/repos/found/sub")); expect(await screen.findByText("repo: /repos/found")).not.toBeNull(); }); it("populates existing branches, disabling the one held by another worktree", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })], branches: [ { name: "main", is_head: true }, { name: "feat/x", is_head: false, worktree: "/repos/other" }, { name: "feat/y", is_head: false }, ], }); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByText("a")); fireEvent.click(screen.getByLabelText("Existing branch")); const y = await screen.findByRole("option", { name: "feat/y" }); expect((y as HTMLOptionElement).disabled).toBe(false); expect((screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement).disabled).toBe( true, ); }); it("submits the first pickable branch when the operator never opens the select", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })], branches: [ { name: "main", is_head: true }, { name: "feat/y", is_head: false }, ], }); mockCreateSession.mockResolvedValue(session()); const onSuccess = vi.fn(); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByText("a")); fireEvent.click(screen.getByLabelText("Existing branch")); await screen.findByRole("option", { name: "feat/y" }); fireEvent.click(screen.getByRole("button", { name: "Spawn" })); await waitFor(() => expect(onSuccess).toHaveBeenCalled()); expect(mockCreateSession).toHaveBeenCalledWith( expect.objectContaining({ worktree: expect.objectContaining({ branch: "feat/y", create_branch: false }) }), ); }); }); describe("SpawnForm structured view", () => { it("defaults on for a resolved Claude Code command and off for an unsupported one", async () => { stub(); render(); await settle(); openAdvanced(); expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(true); fireEvent.click(screen.getByRole("button", { name: /^Shell/ })); expect(screen.queryByLabelText("Structured view")).toBeNull(); }); it("keeps an unchecked box unchecked across a command change between two supported tools", async () => { stub(); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByLabelText("Structured view")); fireEvent.click(screen.getByRole("button", { name: /opencode/ })); fireEvent.click(screen.getByRole("button", { name: /^Claude/ })); expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(false); }); }); describe("SpawnForm submit", () => { it("builds and posts the request, reporting the created session", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })] }); const created = session(); mockCreateSession.mockResolvedValue(created); const onSuccess = vi.fn(); render(); await settle(); fireEvent.click(screen.getByText("a")); fireEvent.click(screen.getByRole("button", { name: "Spawn" })); await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created)); expect(mockCreateSession).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/repos/a", command: "claude" }), ); }); it("shows the daemon's rejection and leaves the form open", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })] }); mockCreateSession.mockRejectedValue(new Error("cwd is not a directory")); render(); await settle(); fireEvent.click(screen.getByText("a")); fireEvent.click(screen.getByRole("button", { name: "Spawn" })); expect(await screen.findByText("cwd is not a directory")).not.toBeNull(); }); it("rejects an existing-branch worktree with nothing picked", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })], branches: [] }); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByText("a")); fireEvent.click(screen.getByLabelText("Existing branch")); await screen.findByText("No local branches found."); fireEvent.click(screen.getByRole("button", { name: "Spawn" })); expect(await screen.findByText("Pick an existing branch.")).not.toBeNull(); expect(mockCreateSession).not.toHaveBeenCalled(); }); }); describe("SpawnForm Enter-to-submit (#97)", () => { it("submits from the session name field", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })] }); mockCreateSession.mockResolvedValue(session()); const onSuccess = vi.fn(); render(); await settle(); fireEvent.click(screen.getByText("a")); openAdvanced(); fireEvent.keyDown(screen.getByPlaceholderText("named after your first prompt"), { key: "Enter" }); await waitFor(() => expect(onSuccess).toHaveBeenCalled()); }); it("first Enter picks a not-yet-active project box, the second submits", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] }); mockCreateSession.mockResolvedValue(session()); const onSuccess = vi.fn(); render(); await settle(); const boxB = screen.getByText("b").closest("button")!; fireEvent.keyDown(boxB, { key: "Enter" }); expect(onSuccess).not.toHaveBeenCalled(); expect(boxB.getAttribute("data-active")).toBe("true"); fireEvent.keyDown(boxB, { key: "Enter" }); await waitFor(() => expect(onSuccess).toHaveBeenCalled()); }); it("toggles the worktree checkbox on Enter instead of submitting", async () => { stub({ projects: [project({ path: "/repos/a", name: "a" })] }); render(); await settle(); openAdvanced(); fireEvent.click(screen.getByText("a")); const checkbox = screen.getByLabelText("Create git worktree") as HTMLInputElement; expect(checkbox.checked).toBe(true); fireEvent.keyDown(checkbox, { key: "Enter" }); expect(checkbox.checked).toBe(false); expect(mockCreateSession).not.toHaveBeenCalled(); }); });