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.
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user