import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SetupWizardModal } from "../SetupWizardModal";
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
X: ({ size, ...props }: any) => ×,
Loader2: ({ size, ...props }: any) => ⟳,
Sparkles: ({ size, ...props }: any) => ✨,
CheckCircle: ({ size, ...props }: any) => ✓,
Folder: ({ size, ...props }: any) => 📁,
FolderOpen: ({ size, ...props }: any) => 📂,
ChevronRight: ({ size, ...props }: any) => →,
ChevronUp: ({ size, ...props }: any) => ↑,
Eye: ({ size, ...props }: any) => 👁,
EyeOff: ({ size, ...props }: any) => 🙈,
AlertCircle: ({ size, ...props }: any) => ⚠,
};
});
// 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
vi.mock("../../api", () => ({
registerProject: vi.fn(),
browseDirectory: vi.fn().mockResolvedValue({
currentPath: "/home/user",
parentPath: "/home",
entries: [],
}),
}));
vi.mock("../../auth", () => ({
getAuthToken: vi.fn(() => undefined),
setAuthToken: vi.fn(),
clearAuthToken: vi.fn(),
}));
import { registerProject } from "../../api";
import { getAuthToken, setAuthToken, clearAuthToken } from "../../auth";
import { useNodes } from "../../hooks/useNodes";
const mockRegisterProject = vi.mocked(registerProject);
const mockGetAuthToken = vi.mocked(getAuthToken);
const mockSetAuthToken = vi.mocked(setAuthToken);
const mockClearAuthToken = vi.mocked(clearAuthToken);
const mockUseNodes = vi.mocked(useNodes);
describe("SetupWizardModal", () => {
let reloadMock: ReturnType;
beforeEach(() => {
vi.clearAllMocks();
mockGetAuthToken.mockReturnValue(undefined);
reloadMock = vi.fn();
vi.stubGlobal("location", { ...window.location, reload: reloadMock });
});
it("renders with welcome message", () => {
render(
);
expect(screen.getByText("Welcome to Fusion")).toBeDefined();
expect(screen.getByText(/Let's set up your first project/)).toBeDefined();
expect(screen.getByLabelText("Fusion logo")).toBeDefined();
expect(screen.getByText("Advanced settings")).toBeDefined();
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
"href",
"https://github.com/runfusion/fusion/discussions"
);
});
it("has DirectoryPicker for path selection", () => {
render(
);
// 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(
);
// 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(
);
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(
);
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("shows error state on registration failure", async () => {
mockRegisterProject.mockRejectedValueOnce(new Error("Path does not exist"));
render(
);
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(screen.getByText("Path does not exist")).toBeDefined();
});
});
it("shows completion state after successful registration", async () => {
const mockProject = {
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(),
};
mockRegisterProject.mockResolvedValueOnce(mockProject);
const onProjectRegistered = vi.fn();
render(
);
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(screen.getByText("All Set!")).toBeDefined();
expect(screen.getByText("Get Started")).toBeDefined();
});
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
});
it("close button calls onClose", () => {
const onClose = vi.fn();
render(
);
fireEvent.click(screen.getByLabelText("Close wizard"));
expect(onClose).toHaveBeenCalled();
});
it("reveals advanced settings on demand", () => {
render(
);
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(
);
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);
});
it("shows a set token action when no browser auth token is stored", () => {
render(
);
fireEvent.click(screen.getByText("Advanced settings"));
expect(screen.getByLabelText("Browser Auth Token")).toBeDefined();
expect(screen.getByRole("button", { name: "Set token" })).toBeDefined();
expect(screen.queryByRole("button", { name: "Reset token" })).toBeNull();
});
it("stores a browser auth token and reloads the page", () => {
render(
);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.change(screen.getByLabelText("Browser Auth Token"), {
target: { value: "daemon-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Set token" }));
expect(mockSetAuthToken).toHaveBeenCalledWith("daemon-token");
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it("shows reset when a browser auth token is already stored", () => {
mockGetAuthToken.mockReturnValue("stored-token");
render(
);
fireEvent.click(screen.getByText("Advanced settings"));
expect(screen.getByRole("button", { name: "Update token" })).toBeDefined();
expect(screen.getByRole("button", { name: "Reset token" })).toBeDefined();
});
it("resets the stored browser auth token and reloads the page", () => {
mockGetAuthToken.mockReturnValue("stored-token");
render(
);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.click(screen.getByRole("button", { name: "Reset token" }));
expect(mockClearAuthToken).toHaveBeenCalledTimes(1);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
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(
);
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 = {
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(),
};
mockRegisterProject.mockResolvedValueOnce(mockProject);
render(
);
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 = {
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(),
};
mockRegisterProject.mockResolvedValueOnce(mockProject);
render(
);
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,
})
);
});
});
});
});