You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

768 lines
23 KiB

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<typeof fetch>) => 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 <ReactFlowProvider>{children}</ReactFlowProvider>;
}
// 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(
<TestWrapper>
<ModelSearchDialog isOpen={false} onClose={vi.fn()} />
</TestWrapper>
);
expect(screen.queryByText("Browse Models")).not.toBeInTheDocument();
});
it("should render with title when isOpen is true", async () => {
render(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
expect(screen.getByText("Browse Models")).toBeInTheDocument();
});
it("should register and unregister modal count", async () => {
const { unmount } = render(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
expect(mockIncrementModalCount).toHaveBeenCalled();
unmount();
expect(mockDecrementModalCount).toHaveBeenCalled();
});
});
describe("Search Functionality", () => {
it("should render search input", async () => {
render(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
const searchInput = screen.getByPlaceholderText("Search models...");
expect(searchInput).toBeInTheDocument();
});
it("should debounce search input and refetch models", async () => {
render(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
// 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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
const searchInput = screen.getByPlaceholderText("Search models...");
await waitFor(() => {
expect(document.activeElement).toBe(searchInput);
});
});
});
describe("Provider Filter", () => {
it("should render provider filter buttons", async () => {
render(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
// 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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} initialProvider="fal" />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
const capabilitySelect = screen.getByDisplayValue("All Types");
expect(capabilitySelect).toBeInTheDocument();
});
it("should filter by image capabilities when selected", async () => {
render(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} initialCapabilityFilter="video" />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog
isOpen={true}
onClose={onClose}
onModelSelected={onModelSelected}
/>
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
// 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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
fireEvent.keyDown(window, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("should call onClose when backdrop is clicked", async () => {
const onClose = vi.fn();
const { container } = render(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
// 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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
// 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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
// 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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={vi.fn()} />
</TestWrapper>
);
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",
});
});
});
});
});