// @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 { 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" })); }); });