This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/components/spawn/SpawnForm.test.tsx
T
Miguel Palhas babdc678e0 feat(maestro): port spawn dialog to React (#248)
Ports the vanilla spawn form/modal to src/components/spawn/:
SpawnModal, SpawnForm, DirAutocomplete, BranchPicker, and a
useFastForward hook for the one-keystroke spawn. Pure logic modules
(spawnForm.ts's buildSpawnRequest, fastForward.ts, spawnSimilar.ts,
projectList.ts, toolIcon.ts) are reused as-is rather than re-derived.

Exports supportsStructuredView from spawnForm.ts so the React form can
share the structured-view-eligibility check with the vanilla one.
2026-09-09 01:04:05 +01:00

384 lines
14 KiB
TypeScript

// @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> = {}): DaemonInfo {
return { hostname: "h", version: "1", home_dir: "/home/n", presets, ...over };
}
function project(over: Partial<Project> = {}): Project {
return { path: "/repos/p", name: "p", last_activity: new Date().toISOString(), ...over };
}
function session(over: Partial<Session> = {}): 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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm seed={{ command: "opencode" }} onSuccess={vi.fn()} />);
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(<SpawnForm seed={{ command: "hx src/" }} onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={onSuccess} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={onSuccess} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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(<SpawnForm onSuccess={onSuccess} />);
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(<SpawnForm onSuccess={onSuccess} />);
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(<SpawnForm onSuccess={vi.fn()} />);
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();
});
});