Files
fusion/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx
gsxdsm 91737b1831 FN-7433: add explicit onboarding git setup modes
Expose explicit git setup choices in first-run onboarding and project registration.

- Add setup wizard modes for existing repositories, new git initialization, and remote cloning with mode-specific hints and validation.
- Extend project registration to accept gitSetupMode while preserving legacy cloneUrl-only behavior.
- Cover init and clone validation paths with dashboard tests, localized copy, docs, and a published package changeset.

Files changed:
 .changeset/fn-7433-onboarding-git-setup.md         |   7 +
 docs/dashboard-guide.md                            |  10 ++
 docs/getting-started.md                            |  10 +-
 packages/dashboard/app/api/legacy.ts               |   1 +
 .../dashboard/app/components/SetupWizardModal.css  |  77 +++++++++-
 .../dashboard/app/components/SetupWizardModal.tsx  | 159 ++++++++++++++-------
 .../components/__tests__/SetupWizardModal.test.tsx | 107 +++++++++++++-
 .../dashboard/src/__tests__/project-routes.test.ts |  98 +++++++++++++
 .../src/routes/register-project-routes.ts          |  25 +++-
 packages/i18n/locales/en/app.json                  |  23 ++-
 packages/i18n/locales/es/app.json                  |  25 +++-
 packages/i18n/locales/fr/app.json                  |  25 +++-
 packages/i18n/locales/ko/app.json                  |  25 +++-
 packages/i18n/locales/zh-CN/app.json               |  25 +++-
 packages/i18n/locales/zh-TW/app.json               |  25 +++-
 15 files changed, 538 insertions(+), 104 deletions(-)

Fusion-Task-Id: FN-7433

Fusion-Task-Lineage: 022c25c2-3c2d-4e47-aa8c-02525caad672

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-02 11:38:34 -07:00

988 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SetupWizardModal } from "../SetupWizardModal";
import { AGENT_PRESETS } from "../agent-presets";
import { buildAgentCreatePayload, mapPresetToAgentDraft } from "../agent-presets/agentCreatePayload";
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
X: ({ size, ...props }: any) => <span data-testid="close-icon" {...props}>×</span>,
Loader2: ({ size, ...props }: any) => <span data-testid="loader" {...props}>⟳</span>,
Sparkles: ({ size, ...props }: any) => <span data-testid="sparkles-icon" {...props}>✨</span>,
CheckCircle: ({ size, ...props }: any) => <span data-testid="check-icon" {...props}>✓</span>,
Folder: ({ size, ...props }: any) => <span {...props}>📁</span>,
FolderOpen: ({ size, ...props }: any) => <span {...props}>📂</span>,
ChevronRight: ({ size, ...props }: any) => <span {...props}>→</span>,
ChevronUp: ({ size, ...props }: any) => <span {...props}>↑</span>,
Eye: ({ size, ...props }: any) => <span {...props}>👁</span>,
EyeOff: ({ size, ...props }: any) => <span {...props}>🙈</span>,
AlertCircle: ({ size, ...props }: any) => <span {...props}>⚠</span>,
};
});
// Mock useNodes hook
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(() => ({
nodes: [],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
})),
}));
// Mock api module
// FNXC:Onboarding 2026-06-25-13:20:
// SetupWizardModal now probes `detectWorkspace` on existing-directory path entry
// (FNXC:Workspace 2026-06-24-21:00). The mock must export it or the component's
// path-change handler throws an unhandled rejection. Default to a non-workspace
// single-repo result so the legacy submit-payload assertions stay deterministic.
vi.mock("../../api", () => ({
registerProject: vi.fn(),
createAgent: vi.fn(),
detectWorkspace: vi.fn().mockResolvedValue({ repos: [], isWorkspace: false }),
browseDirectory: vi.fn().mockResolvedValue({
currentPath: "/home/user",
parentPath: "/home",
entries: [],
}),
}));
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
ExperimentalAgentOnboardingModal: ({ isOpen, onClose, onUseDraft }: { isOpen: boolean; onClose: () => void; onUseDraft: (draft: any) => void }) => (
isOpen ? (
<div data-testid="agent-interview-modal">
AI Interview Modal
<button
type="button"
onClick={() => {
onUseDraft({
name: "Launch Coordinator",
title: "Launch Planning Agent",
icon: "◇",
role: "not-a-real-role",
instructionsText: "Coordinate launch tasks.",
soul: "Strategic launch planner.",
skills: ["planning", "review"],
runtimeHint: "codex-local",
maxTurns: 24,
thinkingLevel: "medium",
});
onClose();
}}
>
Use Draft
</button>
</div>
) : null
),
}));
import { createAgent, registerProject, detectWorkspace } from "../../api";
import { useNodes } from "../../hooks/useNodes";
const mockRegisterProject = vi.mocked(registerProject);
const mockCreateAgent = vi.mocked(createAgent);
const mockDetectWorkspace = vi.mocked(detectWorkspace);
const mockUseNodes = vi.mocked(useNodes);
function buildMockProject(overrides = {}) {
return {
id: "proj_123",
name: "test-project",
path: "/home/user/project",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function buildMockAgent(overrides = {}) {
return {
id: "agent_123",
projectId: "proj_123",
name: "Agent",
role: "custom",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
async function registerProjectFromWizard() {
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/home/user/project" },
});
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "test-project" },
});
fireEvent.click(screen.getByText("Register Project"));
return screen.findByText("Create your first agent");
}
describe("SetupWizardModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRegisterProject.mockReset();
mockCreateAgent.mockReset();
mockDetectWorkspace.mockResolvedValue({ repos: [], isWorkspace: false });
});
it("starts on the project form without an auth token step", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText("Welcome to Fusion")).toBeDefined();
expect(screen.getByText("Project Name")).toBeDefined();
expect(screen.getByLabelText("Fusion logo")).toBeDefined();
expect(screen.getByText("Repository setup")).toBeDefined();
expect(screen.getByLabelText("Use Existing Directory")).toBeDefined();
expect(screen.getByLabelText("Initialize New Repository")).toBeDefined();
expect(screen.getByLabelText("Clone Git Repository")).toBeDefined();
expect(screen.getByText("Advanced settings")).toBeDefined();
expect(screen.queryByText("Set Auth Token")).toBeNull();
expect(screen.queryByText("Set Token & Continue")).toBeNull();
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
"href",
"https://discord.gg/ksrfuy7WYR"
);
});
it("has DirectoryPicker for path selection", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
// DirectoryPicker renders an input and a Browse button
expect(screen.getByPlaceholderText("/path/to/your/project")).toBeDefined();
expect(screen.getByText("Browse")).toBeDefined();
});
it("auto-populates project name from selected directory path", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
// Type a path in the DirectoryPicker input
const pathInput = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(pathInput, { target: { value: "/home/user/my-awesome-project" } });
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
expect(nameInput.value).toBe("my-awesome-project");
});
it("register button is disabled when required fields are empty", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
const registerBtn = screen.getByText("Register Project").closest("button")!;
expect(registerBtn.disabled).toBe(true);
});
it("register button is enabled when path and name are provided", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/home/user/project" },
});
// Name auto-populates; ensure it's not empty
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
expect(nameInput.value).toBe("project");
const registerBtn = screen.getByText("Register Project").closest("button")!;
expect(registerBtn.disabled).toBe(false);
});
it("existing-directory submit payload is unchanged", async () => {
mockRegisterProject.mockResolvedValueOnce({
id: "proj_existing",
name: "existing-project",
path: "/existing/project",
status: "active",
isolationMode: "in-process",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/existing/project" },
});
fireEvent.click(screen.getByText("Register Project"));
await waitFor(() => {
/*
FNXC:SetupWizard 2026-06-25-16:55:
Project registration now always sends the operator-visible workspace and task prefix fields, even when the existing-directory path is a single repo.
Keep this stale-expectation test pinned to the current create-project contract instead of asserting the pre-workspace payload shape.
*/
expect(mockRegisterProject).toHaveBeenCalledWith({
name: "project",
path: "/existing/project",
isolationMode: "in-process",
nodeId: undefined,
gitSetupMode: "existing",
cloneUrl: undefined,
workspaceMode: false,
taskPrefix: "PROJ",
});
});
});
it("renders first-class repository setup modes without opening advanced settings", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText("Repository setup")).toBeDefined();
expect(screen.getByText(/Register a folder that is already a git repository/)).toBeDefined();
expect(screen.getByText(/let Fusion run git init/)).toBeDefined();
expect(screen.getByText(/Clone a remote repository/)).toBeDefined();
expect(screen.queryByLabelText("Repository URL")).toBeNull();
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
expect(screen.getByLabelText("Repository URL")).toBeDefined();
expect(screen.getByPlaceholderText("/path/for/new-clone")).toBeDefined();
expect(screen.getByText(/destination must be empty or absent/)).toBeDefined();
expect(screen.getByText(/Fusion will run git clone into the destination directory/)).toBeDefined();
});
it("clone mode submit sends cloneUrl payload", async () => {
const onProjectRegistered = vi.fn();
mockRegisterProject.mockResolvedValueOnce({
id: "proj_clone",
name: "fusion",
path: "/tmp/fusion",
status: "active",
isolationMode: "in-process",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
fireEvent.change(screen.getByLabelText("Repository URL"), {
target: { value: "https://github.com/runfusion/fusion.git" },
});
fireEvent.change(screen.getByPlaceholderText("/path/for/new-clone"), {
target: { value: "/tmp/fusion" },
});
fireEvent.click(screen.getByText("Clone and Register Project"));
await waitFor(() => {
/*
FNXC:SetupWizard 2026-06-25-16:55:
Clone registration uses the same explicit workspace/task-prefix contract as existing-directory registration so downstream project setup receives one complete shape.
*/
expect(mockRegisterProject).toHaveBeenCalledWith({
name: "fusion",
path: "/tmp/fusion",
isolationMode: "in-process",
nodeId: undefined,
gitSetupMode: "clone",
cloneUrl: "https://github.com/runfusion/fusion.git",
workspaceMode: false,
taskPrefix: "FUSI",
});
});
expect(await screen.findByText("Create your first agent")).toBeDefined();
expect(screen.getByRole("radio", { name: "CEO selected" })).toHaveAttribute("aria-checked", "true");
expect(onProjectRegistered).not.toHaveBeenCalled();
});
it("initialize mode submits an explicit init setup payload without clone or workspace state", async () => {
mockRegisterProject.mockResolvedValueOnce({
id: "proj_init",
name: "new-project",
path: "/tmp/new-project",
status: "active",
isolationMode: "in-process",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByLabelText("Initialize New Repository"));
expect(screen.getByText(/Fusion will run git init during registration/)).toBeDefined();
fireEvent.change(screen.getByPlaceholderText("/path/to/new-project"), {
target: { value: "/tmp/new-project" },
});
fireEvent.click(screen.getByText("Initialize and Register Project"));
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalledWith({
name: "new-project",
path: "/tmp/new-project",
isolationMode: "in-process",
nodeId: undefined,
gitSetupMode: "init",
cloneUrl: undefined,
workspaceMode: false,
taskPrefix: "NEWP",
});
});
});
it("switching setup modes clears stale clone and workspace state", async () => {
mockDetectWorkspace.mockResolvedValueOnce({ repos: ["api"], isWorkspace: true });
mockRegisterProject.mockResolvedValueOnce({
id: "proj_existing",
name: "workspace",
path: "/tmp/workspace",
status: "active",
isolationMode: "in-process",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/tmp/workspace" },
});
expect(await screen.findByText(/Found 1 repositories/)).toBeDefined();
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
fireEvent.change(screen.getByLabelText("Repository URL"), {
target: { value: "https://github.com/runfusion/fusion.git" },
});
fireEvent.click(screen.getByLabelText("Initialize New Repository"));
expect(screen.queryByLabelText("Repository URL")).toBeNull();
fireEvent.click(screen.getByText("Initialize and Register Project"));
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalledWith(expect.objectContaining({
gitSetupMode: "init",
cloneUrl: undefined,
workspaceMode: false,
}));
});
});
it("register button disabled/enabled logic is mode-aware", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
const registerBtn = screen.getByText("Register Project").closest("button")!;
expect(registerBtn.disabled).toBe(true);
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
fireEvent.change(screen.getByPlaceholderText("/path/for/new-clone"), {
target: { value: "/tmp/repo" },
});
expect(registerBtn.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Repository URL"), {
target: { value: "https://github.com/runfusion/fusion.git" },
});
expect(registerBtn.disabled).toBe(false);
fireEvent.click(screen.getByLabelText("Use Existing Directory"));
expect(registerBtn.disabled).toBe(false);
});
it("auto-suggested name updates until manually edited", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
const destinationInput = screen.getByPlaceholderText("/path/for/new-clone");
fireEvent.change(destinationInput, { target: { value: "/tmp/fusion" } });
expect(nameInput.value).toBe("fusion");
fireEvent.change(destinationInput, { target: { value: "/tmp/fusion-next" } });
expect(nameInput.value).toBe("fusion-next");
fireEvent.change(nameInput, { target: { value: "custom-name" } });
fireEvent.change(destinationInput, { target: { value: "/tmp/fusion-final" } });
expect(nameInput.value).toBe("custom-name");
});
it("shows error state on registration failure", async () => {
mockRegisterProject.mockRejectedValueOnce(new Error("Path does not exist"));
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/bad/path" },
});
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "test-project" },
});
fireEvent.click(screen.getByText("Register Project"));
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalled();
});
expect(await screen.findByText("Path does not exist")).toBeDefined();
});
it("shows optional first-agent step after successful registration", async () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
const onProjectRegistered = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
/>
);
await registerProjectFromWizard();
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalled();
});
expect(screen.getByText(/Agents are optional/)).toBeDefined();
expect(screen.getByRole("radio", { name: "CEO selected" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("Create Agent")).toBeDefined();
expect(screen.getByText("Skip for now")).toBeDefined();
expect(screen.queryByText("AI Interview")).toBeNull();
expect(onProjectRegistered).not.toHaveBeenCalled();
});
it("can register project only when embedded in brand-new onboarding", async () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
const onProjectRegistered = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
includeAgentStep={false}
/>
);
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/home/user/project" },
});
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "test-project" },
});
fireEvent.click(screen.getByText("Register Project"));
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalled();
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
});
expect(screen.queryByText("Create your first agent")).toBeNull();
});
it("can skip first-agent creation and finish setup", async () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
const onProjectRegistered = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
fireEvent.click(screen.getByText("Skip for now"));
expect(await screen.findByText("All Set!")).toBeDefined();
expect(screen.getByText("You can create agents later from the Agents view.")).toBeDefined();
expect(mockCreateAgent).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Get Started"));
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
});
it("does not show an ambiguous close button on the first-agent step", async () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
const onProjectRegistered = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
expect(screen.queryByLabelText("Close wizard")).toBeNull();
fireEvent.click(screen.getByText("Skip for now"));
expect(await screen.findByText("All Set!")).toBeDefined();
expect(screen.getByText("You can create agents later from the Agents view.")).toBeDefined();
expect(onProjectRegistered).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Get Started"));
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
});
it("creates the default CEO agent before finishing setup", async () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
mockCreateAgent.mockResolvedValueOnce(buildMockAgent({
id: "agent_ceo",
projectId: mockProject.id,
name: "CEO",
}) as any);
const onProjectRegistered = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
fireEvent.click(screen.getByText("Create Agent"));
await waitFor(() => {
expect(mockCreateAgent).toHaveBeenCalledWith(
expect.objectContaining({
name: "CEO",
role: "custom",
title: "Oversees project strategy, sets priorities, and coordinates between departments to ensure alignment with business goals.",
}),
mockProject.id,
);
});
expect(await screen.findByText("Your project is registered and your first agent is ready.")).toBeDefined();
fireEvent.click(screen.getByText("Get Started"));
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
});
it("creates the selected non-CEO preset with the shared payload mapping", async () => {
const mockProject = buildMockProject();
const engineerPreset = AGENT_PRESETS.find((preset) => preset.id === "engineer")!;
mockRegisterProject.mockResolvedValueOnce(mockProject);
mockCreateAgent.mockResolvedValueOnce(buildMockAgent({
id: "agent_engineer",
projectId: mockProject.id,
name: engineerPreset.name,
role: engineerPreset.role,
}) as any);
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
fireEvent.click(screen.getByRole("radio", { name: engineerPreset.name }));
expect(screen.getByRole("radio", { name: `${engineerPreset.name} selected` })).toHaveAttribute("aria-checked", "true");
expect(screen.getAllByText(engineerPreset.description!).length).toBeGreaterThanOrEqual(1);
fireEvent.click(screen.getByText("Create Agent"));
await waitFor(() => {
expect(mockCreateAgent).toHaveBeenCalledWith(
buildAgentCreatePayload(mapPresetToAgentDraft(engineerPreset)),
mockProject.id,
);
});
});
it("supports arrow-key navigation in the first-agent template radio group", async () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
const ceoOption = screen.getByRole("radio", { name: "CEO selected" });
ceoOption.focus();
fireEvent.keyDown(ceoOption, { key: "ArrowDown" });
await waitFor(() => {
expect(screen.getByRole("radio", { name: "CTO selected" })).toHaveAttribute("aria-checked", "true");
});
fireEvent.keyDown(screen.getByRole("radio", { name: "CTO selected" }), { key: "End" });
const lastPreset = AGENT_PRESETS[AGENT_PRESETS.length - 1]!;
await waitFor(() => {
expect(screen.getByRole("radio", { name: `${lastPreset.name} selected` })).toHaveAttribute("aria-checked", "true");
});
});
it("keeps first-agent step available when agent creation fails", async () => {
const mockProject = buildMockProject();
const onProjectRegistered = vi.fn();
mockRegisterProject.mockResolvedValueOnce(mockProject);
mockCreateAgent.mockRejectedValueOnce(new Error("Agent quota reached"));
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
fireEvent.click(screen.getByText("Create Agent"));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Agent quota reached");
expect(document.activeElement).toBe(alert);
expect(screen.getByText("Create Agent")).toBeDefined();
expect(screen.getByText("Skip for now")).toBeDefined();
expect(screen.getByRole("radio", { name: "CEO selected" })).toHaveAttribute("aria-checked", "true");
expect(onProjectRegistered).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Skip for now"));
expect(await screen.findByText("You can create agents later from the Agents view.")).toBeDefined();
});
it("applies an AI interview draft and waits for explicit creation", async () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
mockCreateAgent.mockResolvedValueOnce(buildMockAgent({
id: "agent_launch",
projectId: mockProject.id,
name: "Launch Coordinator",
}) as any);
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
agentOnboardingEnabled
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
expect(screen.getByText("AI Interview")).toBeDefined();
fireEvent.click(screen.getByText("AI Interview"));
expect(await screen.findByTestId("agent-interview-modal")).toBeDefined();
fireEvent.click(screen.getByText("Use Draft"));
expect(await screen.findByText("Launch Coordinator")).toBeDefined();
expect(screen.getByText("Launch Planning Agent")).toBeDefined();
const ceoRadio = screen.getByRole("radio", { name: "CEO" });
expect(ceoRadio).toHaveAttribute("tabIndex", "0");
expect(ceoRadio).toHaveAttribute("aria-checked", "false");
expect(mockCreateAgent).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Create Agent"));
await waitFor(() => {
expect(mockCreateAgent).toHaveBeenCalledWith(
expect.objectContaining({
name: "Launch Coordinator",
role: "custom",
title: "Launch Planning Agent",
instructionsText: "Coordinate launch tasks.",
soul: "Strategic launch planner.",
metadata: { skills: ["planning", "review"] },
runtimeConfig: {
runtimeHint: "codex-local",
maxTurns: 24,
thinkingLevel: "medium",
},
}),
mockProject.id,
);
});
});
it("close button calls onClose", () => {
const onClose = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={onClose}
/>
);
fireEvent.click(screen.getByLabelText("Close wizard"));
expect(onClose).toHaveBeenCalled();
});
it("reveals advanced settings on demand", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByText("Runtime Node")).toBeNull();
expect(screen.queryByText("In-Process")).toBeNull();
fireEvent.click(screen.getByText("Advanced settings"));
expect(screen.getByText("Runtime Node")).toBeDefined();
expect(screen.getByText("In-Process")).toBeDefined();
expect(screen.getByText("Child-Process")).toBeDefined();
expect(screen.getByText("Recommended")).toBeDefined();
});
it("can switch isolation mode", () => {
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByText("Advanced settings"));
// Initially in-process is selected
const inProcessRadio = screen.getByDisplayValue("in-process") as HTMLInputElement;
const childProcessRadio = screen.getByDisplayValue("child-process") as HTMLInputElement;
expect(inProcessRadio.checked).toBe(true);
expect(childProcessRadio.checked).toBe(false);
fireEvent.click(childProcessRadio);
expect(childProcessRadio.checked).toBe(true);
});
describe("node selector", () => {
const localNode = {
id: "local-1",
name: "Local Node",
type: "local" as const,
status: "online" as const,
maxConcurrent: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const remoteNode = {
id: "remote-1",
name: "Remote Node",
type: "remote" as const,
url: "http://localhost:3001",
status: "online" as const,
maxConcurrent: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
it("node selector is present when nodes load", () => {
mockUseNodes.mockImplementation(() => ({
nodes: [localNode, remoteNode],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
}));
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByText("Advanced settings"));
expect(screen.getByText("Runtime Node")).toBeDefined();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// Check options directly by querying the select's children
const options = Array.from(select.querySelectorAll("option"));
const optionValues = options.map((opt) => opt.value);
const optionLabels = options.map((opt) => opt.label);
expect(optionValues).toContain("local-1");
expect(optionValues).toContain("remote-1");
});
it("registration includes selected nodeId", async () => {
mockUseNodes.mockImplementation(() => ({
nodes: [localNode, remoteNode],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
}));
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByText("Advanced settings"));
// Select the remote node
const select = screen.getByRole("combobox") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "remote-1" } });
// Fill path and name
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/home/user/project" },
});
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "test-project" },
});
// Register
fireEvent.click(screen.getByText("Register Project"));
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalledWith(
expect.objectContaining({
name: "test-project",
path: "/home/user/project",
isolationMode: "in-process",
nodeId: "remote-1",
})
);
});
});
it("registration includes undefined nodeId when local node selected", async () => {
mockUseNodes.mockImplementation(() => ({
nodes: [localNode],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
}));
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByText("Advanced settings"));
// Local node is selected by default (empty value)
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// Fill path and name
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
target: { value: "/home/user/project" },
});
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "test-project" },
});
// Register
fireEvent.click(screen.getByText("Register Project"));
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalledWith(
expect.objectContaining({
name: "test-project",
path: "/home/user/project",
isolationMode: "in-process",
nodeId: undefined,
})
);
});
});
});
});