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.
384 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|