// @vitest-environment jsdom import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Project } from "@paperclipai/shared"; import { buildCreatePayload, usePromoteToProject, type BrainstormerPayload, type PromoteState, type UsePromoteToProjectResult, } from "./usePromoteToProject"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; function fakeProject(overrides: Partial = {}): Project { return { id: "proj-xyz", companyId: "co-1", urlKey: "my-new-project", goalId: null, goalIds: [], goals: [], name: "My New Project", description: null, status: "planned", leadAgentId: null, targetDate: null, color: null, pauseReason: null, pausedAt: null, executionWorkspacePolicy: null, codebase: { kind: "none" } as unknown as Project["codebase"], workspaces: [], primaryWorkspace: null, archivedAt: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, } as Project; } // Harness: render a tiny component that calls the hook so we can drive it // from tests via a ref. Keeps us in the Phase 8 createRoot+act pattern and // avoids @testing-library/react-hooks (not installed). function makeHarness() { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); let current: UsePromoteToProjectResult | null = null; function Probe(props: { conversationId: string | null; companyId: string | null; createProject?: ( companyId: string, data: Record, ) => Promise; }) { current = usePromoteToProject({ conversationId: props.conversationId, companyId: props.companyId, createProject: props.createProject, }); return null; } function render(props: Parameters[0]) { act(() => { root.render(createElement(Probe, props)); }); } function unmount() { act(() => { root.unmount(); }); if (container.parentNode) container.remove(); } return { render, unmount, get hook() { if (!current) throw new Error("Hook not rendered yet"); return current; }, getState(): PromoteState { return current!.state; }, }; } describe("usePromoteToProject", () => { let harness: ReturnType; beforeEach(() => { harness = makeHarness(); }); afterEach(() => { harness.unmount(); }); const payload: BrainstormerPayload = { goal: "Ship nexus phase 12", acceptanceCriteria: ["animation ≤ 700ms", "ESC cancels"], gates: ["pm-review"], engineerTemplateId: undefined, }; it("starts in idle state", () => { harness.render({ conversationId: "c-1", companyId: "co-1" }); expect(harness.getState()).toEqual({ kind: "idle" }); }); it("transitions idle → prompting on startPrompting()", () => { harness.render({ conversationId: "c-1", companyId: "co-1" }); act(() => harness.hook.startPrompting()); expect(harness.getState()).toEqual({ kind: "prompting", conversationId: "c-1" }); }); it("does not transition when conversationId is null", () => { harness.render({ conversationId: null, companyId: "co-1" }); act(() => harness.hook.startPrompting()); expect(harness.getState()).toEqual({ kind: "idle" }); }); it("transitions prompting → creating → done on successful confirm()", async () => { const createProject = vi.fn().mockResolvedValue( fakeProject({ id: "proj-7", urlKey: "nexus-phase-12", name: "Nexus Phase 12" }), ); harness.render({ conversationId: "c-1", companyId: "co-1", createProject }); act(() => harness.hook.startPrompting()); await act(async () => { await harness.hook.confirm(payload); }); expect(createProject).toHaveBeenCalledTimes(1); const [calledCompany, calledBody] = createProject.mock.calls[0]!; expect(calledCompany).toBe("co-1"); expect(calledBody).toMatchObject({ name: "Ship nexus phase 12", description: "Ship nexus phase 12", status: "planned", originConversationId: "c-1", brainstormer: { goal: "Ship nexus phase 12", acceptanceCriteria: ["animation ≤ 700ms", "ESC cancels"], gates: ["pm-review"], }, }); expect(harness.getState()).toEqual({ kind: "done", projectId: "proj-7", projectSlug: "nexus-phase-12", projectName: "Nexus Phase 12", }); }); it("transitions to error state when createProject rejects", async () => { const createProject = vi.fn().mockRejectedValue(new Error("boom")); harness.render({ conversationId: "c-1", companyId: "co-1", createProject }); act(() => harness.hook.startPrompting()); await act(async () => { await harness.hook.confirm(payload); }); expect(harness.getState()).toEqual({ kind: "error", message: "boom" }); }); it("cancel() returns to idle from prompting", () => { harness.render({ conversationId: "c-1", companyId: "co-1" }); act(() => harness.hook.startPrompting()); expect(harness.getState().kind).toBe("prompting"); act(() => harness.hook.cancel()); expect(harness.getState()).toEqual({ kind: "idle" }); }); it("refuses confirm() when companyId is null", async () => { const createProject = vi.fn(); harness.render({ conversationId: "c-1", companyId: null, createProject }); act(() => harness.hook.startPrompting()); await act(async () => { await harness.hook.confirm(payload); }); expect(createProject).not.toHaveBeenCalled(); expect(harness.getState().kind).toBe("error"); }); }); describe("buildCreatePayload", () => { it("derives name from the first 60 chars of goal first line", () => { const body = buildCreatePayload( { goal: "Rewrite the promote flow\nwith animated transitions", acceptanceCriteria: [], gates: [], }, "conv-1", ); expect(body.name).toBe("Rewrite the promote flow"); }); it("falls back to 'Untitled project' when goal is empty", () => { const body = buildCreatePayload( { goal: "", acceptanceCriteria: [], gates: [] }, "conv-1", ); expect(body.name).toBe("Untitled project"); expect(body.description).toBeUndefined(); }); it("truncates goal first line to 60 chars", () => { const longLine = "a".repeat(120); const body = buildCreatePayload( { goal: longLine, acceptanceCriteria: [], gates: [] }, "conv-1", ); expect((body.name as string).length).toBe(60); }); it("includes brainstormer metadata under brainstormer key", () => { const body = buildCreatePayload( { goal: "G", acceptanceCriteria: ["a", "b"], gates: ["pm-review"], engineerTemplateId: "tpl-1", }, "conv-2", ); expect(body.brainstormer).toEqual({ goal: "G", acceptanceCriteria: ["a", "b"], gates: ["pm-review"], engineerTemplateId: "tpl-1", }); expect(body.originConversationId).toBe("conv-2"); }); });