import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ReactFlowProvider } from "@xyflow/react"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { ProviderSettings } from "@/types"; import { ProviderModel } from "@/lib/providers/types"; // Mock deduplicatedFetch to pass through to global fetch (avoids caching issues in tests) vi.mock("@/utils/deduplicatedFetch", () => ({ deduplicatedFetch: (...args: Parameters) => fetch(...args), clearFetchCache: vi.fn(), })); // Mock the workflow store const mockAddNode = vi.fn(); const mockIncrementModalCount = vi.fn(); const mockDecrementModalCount = vi.fn(); const mockTrackModelUsage = 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); }, useProviderApiKeys: () => ({ replicateApiKey: "test-replicate-key", falApiKey: "test-fal-key", kieApiKey: null, wavespeedApiKey: null, replicateEnabled: true, kieEnabled: false, }), })); // Mock useReactFlow const mockScreenToFlowPosition = vi.fn((pos) => pos); vi.mock("@xyflow/react", async () => { const actual = await vi.importActual("@xyflow/react"); return { ...actual, useReactFlow: () => ({ screenToFlowPosition: mockScreenToFlowPosition, }), }; }); // Mock createPortal for dialog vi.mock("react-dom", async () => { const actual = await vi.importActual("react-dom"); return { ...actual, createPortal: (node: React.ReactNode) => node, }; }); // Mock fetch const mockFetch = vi.fn(); global.fetch = mockFetch; // Wrapper component for React Flow context function TestWrapper({ children }: { children: React.ReactNode }) { return {children}; } // 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: true, apiKey: "test-replicate-key" }, fal: { id: "fal", name: "fal.ai", enabled: true, apiKey: "test-fal-key" }, kie: { id: "kie", name: "Kie.ai", enabled: false, apiKey: null }, wavespeed: { id: "wavespeed", name: "WaveSpeed", enabled: false, apiKey: null }, }, }; // Sample models for testing const sampleModels: ProviderModel[] = [ { id: "flux/dev", name: "FLUX.1 Dev", description: "High quality image generation model", provider: "fal", capabilities: ["text-to-image", "image-to-image"], coverImage: "https://example.com/flux.jpg", }, { id: "stability-ai/sdxl", name: "SDXL", description: "Stable Diffusion XL", provider: "replicate", capabilities: ["text-to-image"], coverImage: "https://example.com/sdxl.jpg", }, { id: "kling-video/v1.6/pro", name: "Kling Video Pro", description: "AI video generation", provider: "fal", capabilities: ["text-to-video", "image-to-video"], coverImage: "https://example.com/kling.jpg", }, { id: "fal-ai/triposr", name: "TripoSR", description: "3D model generation from images", provider: "fal", capabilities: ["image-to-3d"], coverImage: "https://example.com/triposr.jpg", }, ]; describe("ModelSearchDialog", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); vi.useFakeTimers({ shouldAdvanceTime: true }); // Default mock fetch response mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, models: sampleModels }), }); // Default store mock mockUseWorkflowStore.mockImplementation((selector) => { const state = { providerSettings: defaultProviderSettings, addNode: mockAddNode, incrementModalCount: mockIncrementModalCount, decrementModalCount: mockDecrementModalCount, recentModels: [], trackModelUsage: mockTrackModelUsage, }; return selector(state); }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe("Visibility", () => { it("should not render when isOpen is false", () => { render( ); expect(screen.queryByText("Browse Models")).not.toBeInTheDocument(); }); it("should render with title when isOpen is true", async () => { render( ); expect(screen.getByText("Browse Models")).toBeInTheDocument(); }); it("should register and unregister modal count", async () => { const { unmount } = render( ); expect(mockIncrementModalCount).toHaveBeenCalled(); unmount(); expect(mockDecrementModalCount).toHaveBeenCalled(); }); }); describe("Search Functionality", () => { it("should render search input", async () => { render( ); const searchInput = screen.getByPlaceholderText("Search models..."); expect(searchInput).toBeInTheDocument(); }); it("should debounce search input and refetch models", async () => { render( ); // Clear initial fetch calls await vi.advanceTimersByTimeAsync(100); mockFetch.mockClear(); const searchInput = screen.getByPlaceholderText("Search models..."); fireEvent.change(searchInput, { target: { value: "flux" } }); // Before debounce timeout, should not fetch await vi.advanceTimersByTimeAsync(200); expect(mockFetch).not.toHaveBeenCalled(); // After debounce timeout (300ms), should fetch await vi.advanceTimersByTimeAsync(150); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); const fetchCall = mockFetch.mock.calls[0][0] as string; expect(fetchCall).toContain("search=flux"); }); }); it("should focus search input when dialog opens", async () => { render( ); const searchInput = screen.getByPlaceholderText("Search models..."); await waitFor(() => { expect(document.activeElement).toBe(searchInput); }); }); }); describe("Provider Filter", () => { it("should render provider filter buttons", async () => { render( ); // Provider filter is now buttons - check for "All" button with title "All Providers" const allButton = screen.getByTitle("All Providers"); expect(allButton).toBeInTheDocument(); expect(allButton).toHaveTextContent("All"); }); it("should filter by provider when button is clicked", async () => { render( ); await vi.advanceTimersByTimeAsync(100); mockFetch.mockClear(); // Click the Replicate button (has title "Replicate") const replicateButton = screen.getByTitle("Replicate"); fireEvent.click(replicateButton); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); const fetchCall = mockFetch.mock.calls[0][0] as string; expect(fetchCall).toContain("provider=replicate"); }); }); it("should use initialProvider when provided", async () => { render( ); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); const fetchCall = mockFetch.mock.calls[0][0] as string; expect(fetchCall).toContain("provider=fal"); }); }); }); describe("Capability Filter", () => { it("should render capability filter dropdown", async () => { render( ); const capabilitySelect = screen.getByDisplayValue("All Types"); expect(capabilitySelect).toBeInTheDocument(); }); it("should filter by image capabilities when selected", async () => { render( ); await vi.advanceTimersByTimeAsync(100); mockFetch.mockClear(); const capabilitySelect = screen.getByDisplayValue("All Types"); fireEvent.change(capabilitySelect, { target: { value: "image" } }); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); const fetchCall = mockFetch.mock.calls[0][0] as string; // URL encodes comma as %2C expect(fetchCall).toMatch(/capabilities=text-to-image[,%].*image-to-image/); }); }); it("should filter by video capabilities when selected", async () => { render( ); await vi.advanceTimersByTimeAsync(100); mockFetch.mockClear(); const capabilitySelect = screen.getByDisplayValue("All Types"); fireEvent.change(capabilitySelect, { target: { value: "video" } }); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); const fetchCall = mockFetch.mock.calls[0][0] as string; // URL encodes comma as %2C expect(fetchCall).toMatch(/capabilities=text-to-video[,%].*image-to-video/); }); }); it("should use initialCapabilityFilter when provided", async () => { render( ); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); const fetchCall = mockFetch.mock.calls[0][0] as string; // URL encodes comma as %2C expect(fetchCall).toMatch(/capabilities=text-to-video[,%].*image-to-video/); }); }); }); describe("Model Card Rendering", () => { it("should render model cards with name and description", async () => { render( ); await waitFor(() => { expect(screen.getByText("FLUX.1 Dev")).toBeInTheDocument(); expect(screen.getByText("High quality image generation model")).toBeInTheDocument(); expect(screen.getByText("SDXL")).toBeInTheDocument(); expect(screen.getByText("Stable Diffusion XL")).toBeInTheDocument(); }); }); it("should render provider badges on model cards", async () => { const { container } = render( ); await waitFor(() => { // Check provider badges in the model cards grid const modelGrid = container.querySelector(".grid"); expect(modelGrid).toBeInTheDocument(); // fal.ai appears for 2 models (FLUX.1 Dev and Kling Video) const falBadges = modelGrid!.querySelectorAll('span[class*="bg-yellow"]'); expect(falBadges.length).toBeGreaterThanOrEqual(2); // Replicate appears for 1 model (SDXL) const replicateBadges = modelGrid!.querySelectorAll('span[class*="bg-blue"]'); expect(replicateBadges.length).toBeGreaterThanOrEqual(1); }); }); it("should render capability badges on model cards", async () => { render( ); await waitFor(() => { // Check for capability badges (using short form labels) const txtImgBadges = screen.getAllByText("txt\u2192img"); const imgImgBadges = screen.getAllByText("img\u2192img"); const txtVidBadges = screen.getAllByText("txt\u2192vid"); const imgVidBadges = screen.getAllByText("img\u2192vid"); expect(txtImgBadges.length).toBeGreaterThanOrEqual(2); // FLUX and SDXL expect(imgImgBadges.length).toBeGreaterThanOrEqual(1); // FLUX expect(txtVidBadges.length).toBeGreaterThanOrEqual(1); // Kling Video expect(imgVidBadges.length).toBeGreaterThanOrEqual(1); // Kling Video }); }); it("should render model count in footer", async () => { render( ); await waitFor(() => { expect(screen.getByText(/4 models? found/)).toBeInTheDocument(); }); }); }); describe("Model Selection", () => { it("should call onModelSelected when a model card is clicked (callback mode)", async () => { const onModelSelected = vi.fn(); const onClose = vi.fn(); render( ); await waitFor(() => { expect(screen.getByText("FLUX.1 Dev")).toBeInTheDocument(); }); // Click on the FLUX.1 Dev model card const modelCard = screen.getByText("FLUX.1 Dev").closest("button"); fireEvent.click(modelCard!); expect(onModelSelected).toHaveBeenCalledWith( expect.objectContaining({ id: "flux/dev", name: "FLUX.1 Dev", provider: "fal", }) ); expect(onClose).toHaveBeenCalled(); }); it("should call addNode when a model card is clicked (create node mode)", async () => { const onClose = vi.fn(); render( ); await waitFor(() => { expect(screen.getByText("SDXL")).toBeInTheDocument(); }); // Click on the SDXL model card const modelCard = screen.getByText("SDXL").closest("button"); fireEvent.click(modelCard!); expect(mockAddNode).toHaveBeenCalledWith( "nanoBanana", expect.any(Object), expect.objectContaining({ selectedModel: { provider: "replicate", modelId: "stability-ai/sdxl", displayName: "SDXL", capabilities: ["text-to-image"], }, }) ); expect(onClose).toHaveBeenCalled(); }); it("should create generateVideo node for video models", async () => { const onClose = vi.fn(); render( ); await waitFor(() => { expect(screen.getByText("Kling Video Pro")).toBeInTheDocument(); }); // Click on the Kling Video model card const modelCard = screen.getByText("Kling Video Pro").closest("button"); fireEvent.click(modelCard!); expect(mockAddNode).toHaveBeenCalledWith( "generateVideo", expect.any(Object), expect.objectContaining({ selectedModel: { provider: "fal", modelId: "kling-video/v1.6/pro", displayName: "Kling Video Pro", capabilities: ["text-to-video", "image-to-video"], }, }) ); }); it("should create generate3d node for 3D models", async () => { const onClose = vi.fn(); render( ); await waitFor(() => { expect(screen.getByText("TripoSR")).toBeInTheDocument(); }); // Click on the TripoSR 3D model card const modelCard = screen.getByText("TripoSR").closest("button"); fireEvent.click(modelCard!); expect(mockAddNode).toHaveBeenCalledWith( "generate3d", expect.any(Object), expect.objectContaining({ selectedModel: { provider: "fal", modelId: "fal-ai/triposr", displayName: "TripoSR", capabilities: ["image-to-3d"], }, }) ); }); }); describe("Close Behavior", () => { it("should call onClose when close button is clicked", async () => { const onClose = vi.fn(); const { container } = render( ); // Find close button in the header (first button after title) const headerCloseButton = container.querySelector("button.p-1\\.5"); fireEvent.click(headerCloseButton!); expect(onClose).toHaveBeenCalled(); }); it("should call onClose when Escape key is pressed", async () => { const onClose = vi.fn(); render( ); fireEvent.keyDown(window, { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); it("should call onClose when backdrop is clicked", async () => { const onClose = vi.fn(); const { container } = render( ); // Click on the backdrop (the outer div with bg-black/60) const backdrop = container.querySelector(".bg-black\\/60"); fireEvent.click(backdrop!); expect(onClose).toHaveBeenCalled(); }); it("should not close when clicking inside the dialog", async () => { const onClose = vi.fn(); render( ); // Click on the dialog title (inside the dialog) fireEvent.click(screen.getByText("Browse Models")); expect(onClose).not.toHaveBeenCalled(); }); }); describe("Loading State", () => { it("should show loading spinner while fetching models", async () => { // Create a promise that won't resolve immediately let resolvePromise: ((value: unknown) => void) | undefined; mockFetch.mockReturnValue( new Promise((resolve) => { resolvePromise = resolve; }) ); const { container } = render( ); // Check for loading spinner await waitFor(() => { const spinner = container.querySelector(".animate-spin"); expect(spinner).toBeInTheDocument(); }); expect(screen.getByText("Loading models...")).toBeInTheDocument(); // Resolve the promise resolvePromise!({ ok: true, json: () => Promise.resolve({ success: true, models: [] }), }); }); }); describe("Error State", () => { it("should show error message when fetch fails", async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: false, error: "API error occurred" }), }); render( ); await waitFor(() => { expect(screen.getByText("API error occurred")).toBeInTheDocument(); }); }); it("should show error message when network request fails", async () => { mockFetch.mockRejectedValue(new Error("Network error")); render( ); await waitFor(() => { expect(screen.getByText("Network error")).toBeInTheDocument(); }); }); it("should show Try Again button on error", async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: false, error: "API error" }), }); render( ); await waitFor(() => { expect(screen.getByText("Try Again")).toBeInTheDocument(); }); }); it("should refetch when Try Again button is clicked", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: false, error: "API error" }), }); render( ); await waitFor(() => { expect(screen.getByText("Try Again")).toBeInTheDocument(); }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, models: sampleModels }), }); fireEvent.click(screen.getByText("Try Again")); await waitFor(() => { expect(screen.getByText("FLUX.1 Dev")).toBeInTheDocument(); }); }); }); describe("Empty State", () => { it("should show empty state when no models match search", async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, models: [] }), }); render( ); await waitFor(() => { expect(screen.getByText("No models found")).toBeInTheDocument(); expect(screen.getByText("Try adjusting your search or filters")).toBeInTheDocument(); }); }); }); describe("API Headers", () => { it("should include API keys in request headers", async () => { render( ); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); const fetchCall = mockFetch.mock.calls[0]; const options = fetchCall[1] as RequestInit; expect(options.headers).toEqual({ "X-Replicate-Key": "test-replicate-key", "X-Fal-Key": "test-fal-key", }); }); }); }); });