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(); // Default: auth token is stored so wizard starts on the project form step. // Tests for the auth step explicitly unset this. mockGetAuthToken.mockReturnValue("stored-token"); reloadMock = vi.fn(); vi.stubGlobal("location", { ...window.location, reload: reloadMock }); }); it("renders with auth step when no token is stored", () => { mockGetAuthToken.mockReturnValue(undefined); render( ); // No token stored → shows auth step first expect(screen.getByText("Set Auth Token")).toBeDefined(); expect(screen.getByText("Skip")).toBeDefined(); expect(screen.getByText("Set Token & Continue")).toBeDefined(); expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute( "href", "https://github.com/runfusion/fusion/discussions" ); }); it("skips auth step and shows project form when auth token is already stored", () => { mockGetAuthToken.mockReturnValue("stored-token"); render( ); expect(screen.getByText("Welcome to Fusion")).toBeDefined(); expect(screen.getByText("Project Name")).toBeDefined(); expect(screen.getByLabelText("Fusion logo")).toBeDefined(); expect(screen.getByText("Advanced settings")).toBeDefined(); }); it("auth step advances to project form after setting token", () => { mockGetAuthToken.mockReturnValue(undefined); render( ); // On auth step initially expect(screen.getByText("Set Auth Token")).toBeDefined(); // Set a token fireEvent.change(screen.getByPlaceholderText("Paste the daemon auth token"), { target: { value: "my-daemon-token" }, }); fireEvent.click(screen.getByText("Set Token & Continue")); expect(mockSetAuthToken).toHaveBeenCalledWith("my-daemon-token"); // Should now show the project form expect(screen.getByText("Welcome to Fusion")).toBeDefined(); expect(screen.getByText("Project Name")).toBeDefined(); }); it("auth step can be skipped", () => { mockGetAuthToken.mockReturnValue(undefined); render( ); expect(screen.getByText("Set Auth Token")).toBeDefined(); fireEvent.click(screen.getByText("Skip")); expect(screen.getByText("Welcome to Fusion")).toBeDefined(); }); 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("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( ); fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { target: { value: "/existing/project" }, }); fireEvent.click(screen.getByText("Register Project")); await waitFor(() => { expect(mockRegisterProject).toHaveBeenCalledWith({ name: "project", path: "/existing/project", isolationMode: "in-process", nodeId: undefined, cloneUrl: undefined, }); }); }); it("clone mode renders repository url input and destination picker", () => { render( ); fireEvent.click(screen.getByText("Advanced settings")); fireEvent.click(screen.getByLabelText("Clone Git Repository")); expect(screen.getByLabelText("Repository URL")).toBeDefined(); expect(screen.getByPlaceholderText("/path/for/new-clone")).toBeDefined(); expect(screen.getByText(/Fusion will run git clone into the destination directory/)).toBeDefined(); }); it("clone mode submit sends cloneUrl payload", async () => { mockRegisterProject.mockResolvedValueOnce({ id: "proj_clone", name: "fusion", path: "/tmp/fusion", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); render( ); fireEvent.click(screen.getByText("Advanced settings")); 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("Register Project")); await waitFor(() => { expect(mockRegisterProject).toHaveBeenCalledWith({ name: "fusion", path: "/tmp/fusion", isolationMode: "in-process", nodeId: undefined, cloneUrl: "https://github.com/runfusion/fusion.git", }); }); }); it("register button disabled/enabled logic is mode-aware", () => { render( ); const registerBtn = screen.getByText("Register Project").closest("button")!; expect(registerBtn.disabled).toBe(true); fireEvent.click(screen.getByText("Advanced settings")); 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( ); fireEvent.click(screen.getByText("Advanced settings")); 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( ); 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 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(mockRegisterProject).toHaveBeenCalled(); }); expect(await screen.findByText("All Set!")).toBeDefined(); expect(await screen.findByText("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", () => { mockGetAuthToken.mockReturnValue(undefined); render( ); // Skip auth step to get to the project form fireEvent.click(screen.getByText("Skip")); 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 without reloading", () => { render( ); fireEvent.click(screen.getByText("Advanced settings")); fireEvent.change(screen.getByLabelText("Browser Auth Token"), { target: { value: "daemon-token" }, }); fireEvent.click(screen.getByRole("button", { name: "Update token" })); expect(mockSetAuthToken).toHaveBeenCalledWith("daemon-token"); expect(reloadMock).not.toHaveBeenCalled(); }); 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 without reloading", () => { 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).not.toHaveBeenCalled(); }); 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, }) ); }); }); }); });