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.
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { act, renderHook } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { Project, Session, SpawnPreset } from "../../types.js";
|
|
import { useFastForward } from "./useFastForward.js";
|
|
|
|
vi.mock("../../api.js", () => ({
|
|
createSession: vi.fn(),
|
|
fetchNames: vi.fn(),
|
|
getDaemonInfo: vi.fn(),
|
|
projects: vi.fn(),
|
|
}));
|
|
|
|
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
|
|
|
|
const mockCreateSession = vi.mocked(createSession);
|
|
const mockFetchNames = vi.mocked(fetchNames);
|
|
const mockGetDaemonInfo = vi.mocked(getDaemonInfo);
|
|
const mockFetchProjects = vi.mocked(fetchProjects);
|
|
|
|
const presets: SpawnPreset[] = [{ id: "claude", label: "Claude", command: "claude" }];
|
|
|
|
function project(over: Partial<Project> = {}): Project {
|
|
return { path: "/repos/p", name: "p", base_repo: "/repos/p", last_command: "claude", ...over };
|
|
}
|
|
|
|
function session(): Session {
|
|
return {
|
|
id: "s1",
|
|
cwd: "/repos/p",
|
|
command: "claude",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
}
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe("useFastForward", () => {
|
|
it("spawns immediately once every default resolves", async () => {
|
|
mockFetchProjects.mockResolvedValue([project()]);
|
|
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
|
|
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
|
const created = session();
|
|
mockCreateSession.mockResolvedValue(created);
|
|
const onSpawned = vi.fn();
|
|
const onFallback = vi.fn();
|
|
|
|
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
|
|
await act(() => result.current());
|
|
|
|
expect(mockCreateSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: "/repos/p",
|
|
command: "claude",
|
|
worktree: { base_repo: "/repos/p", branch: "amber-fox", create_branch: true },
|
|
}),
|
|
);
|
|
expect(onSpawned).toHaveBeenCalledWith(created);
|
|
expect(onFallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to a pre-filled seed when the daemon rejects the spawn", async () => {
|
|
mockFetchProjects.mockResolvedValue([project()]);
|
|
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
|
|
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
|
mockCreateSession.mockRejectedValue(new Error("boom"));
|
|
const onSpawned = vi.fn();
|
|
const onFallback = vi.fn();
|
|
|
|
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
|
|
await act(() => result.current());
|
|
|
|
expect(onSpawned).not.toHaveBeenCalled();
|
|
expect(onFallback).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cwd: "/repos/p", command: "claude", worktreeBranch: "amber-fox" }),
|
|
);
|
|
});
|
|
|
|
it("falls back when nothing is known yet (no projects, no handle)", async () => {
|
|
mockFetchProjects.mockResolvedValue([]);
|
|
mockFetchNames.mockRejectedValue(new Error("exhausted"));
|
|
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
|
const onSpawned = vi.fn();
|
|
const onFallback = vi.fn();
|
|
|
|
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
|
|
await act(() => result.current());
|
|
|
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
|
expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ cwd: "" }));
|
|
});
|
|
|
|
it("prefers the focused session's project over the freshest one", async () => {
|
|
mockFetchProjects.mockResolvedValue([
|
|
project({ path: "/repos/fresh", name: "fresh" }),
|
|
project({ path: "/repos/current", name: "current", last_activity: "2020-01-01T00:00:00Z" }),
|
|
]);
|
|
mockFetchNames.mockResolvedValue({ names: ["h"] });
|
|
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
|
mockCreateSession.mockResolvedValue(session());
|
|
const onSpawned = vi.fn();
|
|
|
|
const { result } = renderHook(() =>
|
|
useFastForward({ focusedCwd: "/repos/current", onSpawned, onFallback: vi.fn() }),
|
|
);
|
|
await act(() => result.current());
|
|
|
|
expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repos/current" }));
|
|
});
|
|
});
|