From c5c2171b9a20f499059f2c7de14601095c7cd99d Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 13 Jan 2026 13:02:40 +1300 Subject: [PATCH] test(17-08): add ProjectSetupModal and CostDialog tests ProjectSetupModal tests: - Add tests for visibility and tab navigation - Add tests for new project vs settings mode - Add tests for form validation (name, directory) - Add tests for save/create behavior with directory validation - Add tests for browse directory functionality - Add tests for keyboard shortcuts (Enter, Escape) - Add tests for Providers tab API key inputs CostDialog tests: - Add tests for basic rendering and sections - Add tests for cost display formatting - Add tests for per-model cost breakdown - Add tests for empty state handling - Add tests for reset costs functionality - Add tests for close behavior - Add tests for pricing reference display Co-Authored-By: Claude Opus 4.5 --- src/components/__tests__/CostDialog.test.tsx | 401 ++++++++ .../__tests__/ProjectSetupModal.test.tsx | 944 ++++++++++++++++++ 2 files changed, 1345 insertions(+) create mode 100644 src/components/__tests__/CostDialog.test.tsx create mode 100644 src/components/__tests__/ProjectSetupModal.test.tsx diff --git a/src/components/__tests__/CostDialog.test.tsx b/src/components/__tests__/CostDialog.test.tsx new file mode 100644 index 00000000..35faba17 --- /dev/null +++ b/src/components/__tests__/CostDialog.test.tsx @@ -0,0 +1,401 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { CostDialog } from "@/components/CostDialog"; +import { PredictedCostResult } from "@/utils/costCalculator"; + +// Mock the workflow store +const mockResetIncurredCost = 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); + }, +})); + +// Mock confirm +const mockConfirm = vi.fn(() => true); +global.confirm = mockConfirm; + +describe("CostDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + resetIncurredCost: mockResetIncurredCost, + }; + return selector(state); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createPredictedCost = (overrides: Partial = {}): PredictedCostResult => ({ + totalCost: 0.50, + breakdown: [ + { + model: "nano-banana", + resolution: "1K", + count: 5, + unitCost: 0.039, + subtotal: 0.195, + }, + { + model: "nano-banana-pro", + resolution: "2K", + count: 2, + unitCost: 0.134, + subtotal: 0.268, + }, + ], + nodeCount: 7, + ...overrides, + }); + + describe("Basic Rendering", () => { + it("should render dialog with title", () => { + render( + + ); + + expect(screen.getByText("Workflow Costs")).toBeInTheDocument(); + }); + + it("should render close button", () => { + const { container } = render( + + ); + + // Close button has an SVG with X icon + const closeButton = container.querySelector("button"); + expect(closeButton).toBeInTheDocument(); + }); + + it("should render Predicted Cost section", () => { + render( + + ); + + expect(screen.getByText("Predicted Cost")).toBeInTheDocument(); + }); + + it("should render Incurred Cost section", () => { + render( + + ); + + expect(screen.getByText("Incurred Cost")).toBeInTheDocument(); + }); + + it("should render Pricing Reference section", () => { + render( + + ); + + expect(screen.getByText("Pricing Reference:")).toBeInTheDocument(); + }); + }); + + describe("Cost Display", () => { + it("should display formatted predicted cost", () => { + render( + + ); + + expect(screen.getByText("$1.25")).toBeInTheDocument(); + }); + + it("should display formatted incurred cost", () => { + render( + + ); + + expect(screen.getByText("$2.50")).toBeInTheDocument(); + }); + + it("should display $0.00 for zero costs", () => { + render( + + ); + + // Should have two $0.00 - one for predicted, one for incurred + const zeroValues = screen.getAllByText("$0.00"); + expect(zeroValues.length).toBe(2); + }); + }); + + describe("Cost Breakdown", () => { + it("should render per-model cost rows", () => { + render( + + ); + + // Check for model count and name + expect(screen.getByText(/5x Nano Banana/)).toBeInTheDocument(); + expect(screen.getByText(/2x Nano Banana Pro/)).toBeInTheDocument(); + }); + + it("should show resolution for Nano Banana Pro models", () => { + render( + + ); + + // Nano Banana Pro should show resolution + expect(screen.getByText(/Nano Banana Pro.*\(2K\)/)).toBeInTheDocument(); + }); + + it("should display subtotal for each model type", () => { + render( + + ); + + // Check for subtotals + expect(screen.getByText("$0.20")).toBeInTheDocument(); // 0.195 rounded + expect(screen.getByText("$0.27")).toBeInTheDocument(); // 0.268 rounded + }); + }); + + describe("Empty State", () => { + it("should show empty state when no generation nodes exist", () => { + render( + + ); + + expect(screen.getByText("No generation nodes in workflow")).toBeInTheDocument(); + }); + + it("should not show breakdown section when nodeCount is 0", () => { + render( + + ); + + expect(screen.queryByText(/5x Nano Banana/)).not.toBeInTheDocument(); + }); + }); + + describe("Reset Costs Button", () => { + it("should not show reset button when incurredCost is 0", () => { + render( + + ); + + expect(screen.queryByText("Reset to $0.00")).not.toBeInTheDocument(); + }); + + it("should show reset button when incurredCost is greater than 0", () => { + render( + + ); + + expect(screen.getByText("Reset to $0.00")).toBeInTheDocument(); + }); + + it("should show confirmation dialog when reset is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("Reset to $0.00")); + + expect(mockConfirm).toHaveBeenCalledWith("Reset incurred cost to $0.00?"); + }); + + it("should call resetIncurredCost when confirmed", () => { + mockConfirm.mockReturnValue(true); + + render( + + ); + + fireEvent.click(screen.getByText("Reset to $0.00")); + + expect(mockResetIncurredCost).toHaveBeenCalled(); + }); + + it("should not call resetIncurredCost when cancelled", () => { + mockConfirm.mockReturnValue(false); + + render( + + ); + + fireEvent.click(screen.getByText("Reset to $0.00")); + + expect(mockResetIncurredCost).not.toHaveBeenCalled(); + }); + }); + + describe("Close Behavior", () => { + it("should call onClose when close button is clicked", () => { + const onClose = vi.fn(); + + const { container } = render( + + ); + + // Click the close button (first button in the dialog) + const closeButton = container.querySelector("button"); + fireEvent.click(closeButton!); + + expect(onClose).toHaveBeenCalled(); + }); + + it("should call onClose when Escape key is pressed", () => { + const onClose = vi.fn(); + + render( + + ); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("Pricing Reference", () => { + it("should display nano-banana pricing", () => { + render( + + ); + + expect(screen.getByText(/Nano Banana \(Flash\):/)).toBeInTheDocument(); + }); + + it("should display nano-banana-pro pricing tiers", () => { + render( + + ); + + expect(screen.getByText(/Nano Banana Pro 1K\/2K:/)).toBeInTheDocument(); + expect(screen.getByText(/Nano Banana Pro 4K:/)).toBeInTheDocument(); + }); + + it("should display currency note", () => { + render( + + ); + + expect(screen.getByText("All prices in USD")).toBeInTheDocument(); + }); + }); + + describe("Incurred Cost Description", () => { + it("should display description for incurred costs", () => { + render( + + ); + + expect(screen.getByText("Actual API spend from successful generations")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx new file mode 100644 index 00000000..b9b0dffb --- /dev/null +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -0,0 +1,944 @@ +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 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(); + }); + }); +});