import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ProjectSetupModal } from "@/components/ProjectSetupModal"; import { ProviderSettings } from "@/types"; // Mock the workflow store const mockSetUseExternalImageStorage = vi.fn(); const mockUpdateProviderApiKey = vi.fn(); const mockToggleProvider = vi.fn(); const mockUseWorkflowStore = vi.fn(); vi.mock("@/store/workflowStore", () => ({ useWorkflowStore: (selector?: (state: unknown) => unknown) => { if (selector) { return mockUseWorkflowStore(selector); } return mockUseWorkflowStore((s: unknown) => s); }, generateWorkflowId: () => "mock-workflow-id", })); // Mock fetch const mockFetch = vi.fn(); global.fetch = mockFetch; // Mock confirm const mockConfirm = vi.fn(() => true); global.confirm = mockConfirm; // Default provider settings const defaultProviderSettings: ProviderSettings = { providers: { gemini: { id: "gemini", name: "Gemini", enabled: true, apiKey: null, apiKeyEnvVar: "GEMINI_API_KEY" }, openai: { id: "openai", name: "OpenAI", enabled: false, apiKey: null }, replicate: { id: "replicate", name: "Replicate", enabled: false, apiKey: null }, fal: { id: "fal", name: "fal.ai", enabled: false, apiKey: null }, }, }; // Default store state factory const createDefaultState = (overrides = {}) => ({ workflowName: "", workflowId: "", saveDirectoryPath: "", useExternalImageStorage: true, providerSettings: defaultProviderSettings, setUseExternalImageStorage: mockSetUseExternalImageStorage, updateProviderApiKey: mockUpdateProviderApiKey, toggleProvider: mockToggleProvider, ...overrides, }); describe("ProjectSetupModal", () => { beforeEach(() => { vi.clearAllMocks(); // Default mock for env-status API (called on modal open) mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } // Default success response for other APIs return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }), }); }); mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState()); }); }); afterEach(() => { vi.restoreAllMocks(); }); describe("Visibility", () => { it("should not render when isOpen is false", () => { render( ); expect(screen.queryByText("New Project")).not.toBeInTheDocument(); expect(screen.queryByText("Project Settings")).not.toBeInTheDocument(); }); it("should render with 'New Project' title when mode is 'new'", () => { render( ); expect(screen.getByText("New Project")).toBeInTheDocument(); }); it("should render with 'Project Settings' title when mode is 'settings'", () => { render( ); expect(screen.getByText("Project Settings")).toBeInTheDocument(); }); }); describe("Tab Navigation", () => { it("should render Project and Providers tabs", () => { render( ); expect(screen.getByText("Project")).toBeInTheDocument(); expect(screen.getByText("Providers")).toBeInTheDocument(); }); it("should start on Project tab in new mode", () => { render( ); // Project tab should show project name input expect(screen.getByPlaceholderText("my-project")).toBeInTheDocument(); }); it("should switch to Providers tab when clicked", () => { render( ); fireEvent.click(screen.getByText("Providers")); // Should show provider names expect(screen.getByText("Google Gemini")).toBeInTheDocument(); expect(screen.getByText("OpenAI")).toBeInTheDocument(); expect(screen.getByText("Replicate")).toBeInTheDocument(); expect(screen.getByText("fal.ai")).toBeInTheDocument(); }); }); describe("Project Tab - New Mode", () => { it("should render empty form for new project", () => { render( ); const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement; const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project") as HTMLInputElement; expect(nameInput.value).toBe(""); expect(directoryInput.value).toBe(""); }); it("should render Create button in new mode", () => { render( ); expect(screen.getByText("Create")).toBeInTheDocument(); }); it("should render project name and directory inputs", () => { render( ); expect(screen.getByText("Project Name")).toBeInTheDocument(); expect(screen.getByText("Project Directory")).toBeInTheDocument(); }); it("should render Browse button for directory selection", () => { render( ); expect(screen.getByText("Browse")).toBeInTheDocument(); }); it("should render embed images checkbox", () => { render( ); expect(screen.getByText("Embed images as base64")).toBeInTheDocument(); }); }); describe("Project Tab - Settings Mode", () => { it("should pre-fill form with existing values in settings mode", () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ workflowName: "My Existing Project", saveDirectoryPath: "/path/to/project", useExternalImageStorage: false, })); }); render( ); const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement; const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project") as HTMLInputElement; expect(nameInput.value).toBe("My Existing Project"); expect(directoryInput.value).toBe("/path/to/project"); }); it("should render Save button in settings mode", () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ workflowName: "My Project", saveDirectoryPath: "/path/to/project", })); }); render( ); expect(screen.getByText("Save")).toBeInTheDocument(); }); }); describe("Form Validation", () => { it("should show error when project name is empty", async () => { const onSave = vi.fn(); render( ); // Fill directory but not name const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project"); fireEvent.change(directoryInput, { target: { value: "/path/to/project" } }); // Click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect(screen.getByText("Project name is required")).toBeInTheDocument(); }); expect(onSave).not.toHaveBeenCalled(); }); it("should show error when project directory is empty", async () => { const onSave = vi.fn(); render( ); // Fill name but not directory const nameInput = screen.getByPlaceholderText("my-project"); fireEvent.change(nameInput, { target: { value: "My Project" } }); // Click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect(screen.getByText("Project directory is required")).toBeInTheDocument(); }); expect(onSave).not.toHaveBeenCalled(); }); it("should show error when directory does not exist", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url.startsWith("/api/workflow")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ exists: false }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); const onSave = vi.fn(); render( ); // Fill both fields fireEvent.change(screen.getByPlaceholderText("my-project"), { target: { value: "My Project" }, }); fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { target: { value: "/nonexistent/path" }, }); // Click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect(screen.getByText("Project directory does not exist")).toBeInTheDocument(); }); expect(onSave).not.toHaveBeenCalled(); }); it("should show error when path is not absolute", async () => { const onSave = vi.fn(); render( ); // Fill name and a hostname-prefixed relative path fireEvent.change(screen.getByPlaceholderText("my-project"), { target: { value: "My Project" }, }); fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { target: { value: "AT-ALGKG9VR/Users/guy/Desktop/AI Project" }, }); // Click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect( screen.getByText("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)") ).toBeInTheDocument(); }); expect(onSave).not.toHaveBeenCalled(); // Validation should fail client-side without making a fetch to /api/workflow expect(mockFetch).not.toHaveBeenCalledWith( expect.stringContaining("/api/workflow") ); }); it("should show error when path is not a directory", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url.startsWith("/api/workflow")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ exists: true, isDirectory: false }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); const onSave = vi.fn(); render( ); // Fill both fields fireEvent.change(screen.getByPlaceholderText("my-project"), { target: { value: "My Project" }, }); fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { target: { value: "/path/to/file.txt" }, }); // Click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect(screen.getByText("Project path is not a directory")).toBeInTheDocument(); }); expect(onSave).not.toHaveBeenCalled(); }); }); describe("Save Behavior", () => { it("should call onSave with project details when form is valid", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url.startsWith("/api/workflow")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ exists: true, isDirectory: true }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); const onSave = vi.fn(); render( ); // Fill both fields fireEvent.change(screen.getByPlaceholderText("my-project"), { target: { value: "My New Project" }, }); fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { target: { value: "/path/to/project" }, }); // Click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect(onSave).toHaveBeenCalledWith( "mock-workflow-id", "My New Project", "/path/to/project" ); }); }); it("should show 'Validating...' while validating directory", async () => { let resolveValidation: ((value: unknown) => void) | undefined; mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url.startsWith("/api/workflow")) { return new Promise((resolve) => { resolveValidation = resolve; }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); // Fill both fields fireEvent.change(screen.getByPlaceholderText("my-project"), { target: { value: "My Project" }, }); fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { target: { value: "/path/to/project" }, }); // Click Create fireEvent.click(screen.getByText("Create")); expect(screen.getByText("Validating...")).toBeInTheDocument(); // Resolve the validation resolveValidation!({ ok: true, json: () => Promise.resolve({ exists: true, isDirectory: true }), }); }); it("should update external storage setting when saved", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url.startsWith("/api/workflow")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ exists: true, isDirectory: true }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); // Fill fields fireEvent.change(screen.getByPlaceholderText("my-project"), { target: { value: "My Project" }, }); fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { target: { value: "/path/to/project" }, }); // Toggle the embed checkbox (click it to enable embed/disable external) const embedCheckbox = screen.getByRole("checkbox"); fireEvent.click(embedCheckbox); // Click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect(mockSetUseExternalImageStorage).toHaveBeenCalledWith(false); }); }); }); describe("Browse Button", () => { it("should call browse-directory API when Browse is clicked", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url === "/api/browse-directory") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, path: "/selected/path" }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); fireEvent.click(screen.getByText("Browse")); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory"); }); }); it("should update directory input when path is selected", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url === "/api/browse-directory") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, path: "/selected/path" }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); fireEvent.click(screen.getByText("Browse")); await waitFor(() => { const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project") as HTMLInputElement; expect(directoryInput.value).toBe("/selected/path"); }); }); it("should show '...' while browsing", async () => { let resolvePromise: ((value: unknown) => void) | undefined; mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url === "/api/browse-directory") { return new Promise((resolve) => { resolvePromise = resolve; }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); fireEvent.click(screen.getByText("Browse")); expect(screen.getByText("...")).toBeInTheDocument(); resolvePromise!({ ok: true, json: () => Promise.resolve({ success: true, cancelled: true }), }); }); it("should handle cancelled browse dialog", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url === "/api/browse-directory") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, cancelled: true }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); // Pre-fill directory const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project") as HTMLInputElement; fireEvent.change(directoryInput, { target: { value: "/original/path" } }); fireEvent.click(screen.getByText("Browse")); await waitFor(() => { // Should keep original value when cancelled expect(directoryInput.value).toBe("/original/path"); }); }); }); describe("Cancel Button", () => { it("should call onClose when Cancel is clicked", () => { const onClose = vi.fn(); render( ); fireEvent.click(screen.getByText("Cancel")); expect(onClose).toHaveBeenCalled(); }); }); describe("Keyboard Shortcuts", () => { it("should close modal when Escape is pressed", () => { const onClose = vi.fn(); const { container } = render( ); const modalDiv = container.querySelector(".bg-neutral-800"); fireEvent.keyDown(modalDiv!, { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); it("should submit form when Enter is pressed", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), }); } if (url.startsWith("/api/workflow")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ exists: true, isDirectory: true }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); const onSave = vi.fn(); const { container } = render( ); // Fill fields fireEvent.change(screen.getByPlaceholderText("my-project"), { target: { value: "My Project" }, }); fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { target: { value: "/path/to/project" }, }); const modalDiv = container.querySelector(".bg-neutral-800"); fireEvent.keyDown(modalDiv!, { key: "Enter" }); await waitFor(() => { expect(onSave).toHaveBeenCalled(); }); }); }); describe("Providers Tab", () => { // The default beforeEach already sets up proper mocks for env-status it("should render all provider sections", async () => { render( ); fireEvent.click(screen.getByText("Providers")); await waitFor(() => { expect(screen.getByText("Google Gemini")).toBeInTheDocument(); expect(screen.getByText("OpenAI")).toBeInTheDocument(); expect(screen.getByText("Replicate")).toBeInTheDocument(); expect(screen.getByText("fal.ai")).toBeInTheDocument(); }); }); it("should show API key inputs for each provider", async () => { render( ); fireEvent.click(screen.getByText("Providers")); await waitFor(() => { // Check for placeholder texts expect(screen.getByPlaceholderText("AIza...")).toBeInTheDocument(); expect(screen.getByPlaceholderText("sk-...")).toBeInTheDocument(); expect(screen.getByPlaceholderText("r8_...")).toBeInTheDocument(); }); }); it("should show 'Configured via .env' when provider has env key", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: true, openai: false, replicate: false, fal: false }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); fireEvent.click(screen.getByText("Providers")); await waitFor(() => { expect(screen.getByText("Configured via .env")).toBeInTheDocument(); }); }); it("should show Override button for env-configured providers", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ ok: true, json: () => Promise.resolve({ gemini: true, openai: false, replicate: false, fal: false }), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); render( ); fireEvent.click(screen.getByText("Providers")); await waitFor(() => { expect(screen.getByText("Override")).toBeInTheDocument(); }); }); it("should toggle Show/Hide for API key visibility", async () => { render( ); fireEvent.click(screen.getByText("Providers")); await waitFor(() => { const showButtons = screen.getAllByText("Show"); expect(showButtons.length).toBeGreaterThan(0); }); // Click Show for first provider fireEvent.click(screen.getAllByText("Show")[0]); await waitFor(() => { expect(screen.getByText("Hide")).toBeInTheDocument(); }); }); it("should call onClose when Save is clicked on Providers tab", async () => { const onClose = vi.fn(); render( ); fireEvent.click(screen.getByText("Providers")); await waitFor(() => { expect(screen.getByText("Google Gemini")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("Save")); expect(onClose).toHaveBeenCalled(); }); }); });