diff --git a/src/components/__tests__/GenerateImageNode.test.tsx b/src/components/__tests__/GenerateImageNode.test.tsx
new file mode 100644
index 00000000..f899fe8f
--- /dev/null
+++ b/src/components/__tests__/GenerateImageNode.test.tsx
@@ -0,0 +1,738 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { GenerateImageNode } from "@/components/nodes/GenerateImageNode";
+import { ReactFlowProvider } from "@xyflow/react";
+import { NanoBananaNodeData, ProviderSettings } from "@/types";
+
+// Mock the workflow store
+const mockUpdateNodeData = vi.fn();
+const mockRegenerateNode = vi.fn();
+const mockAddNode = vi.fn();
+const mockIncrementModalCount = vi.fn();
+const mockDecrementModalCount = vi.fn();
+const mockUseWorkflowStore = vi.fn();
+
+vi.mock("@/store/workflowStore", () => ({
+ useWorkflowStore: (selector?: (state: unknown) => unknown) => {
+ if (selector) {
+ return mockUseWorkflowStore(selector);
+ }
+ // When called without selector (destructuring pattern), return the full state object
+ return mockUseWorkflowStore((s: unknown) => s);
+ },
+ saveNanoBananaDefaults: vi.fn(),
+}));
+
+// Mock useReactFlow
+const mockSetNodes = vi.fn();
+const mockScreenToFlowPosition = vi.fn((pos) => pos);
+
+vi.mock("@xyflow/react", async () => {
+ const actual = await vi.importActual("@xyflow/react");
+ return {
+ ...actual,
+ useReactFlow: () => ({
+ getNodes: vi.fn(() => []),
+ setNodes: mockSetNodes,
+ screenToFlowPosition: mockScreenToFlowPosition,
+ }),
+ };
+});
+
+// Mock Toast
+vi.mock("@/components/Toast", () => ({
+ useToast: {
+ getState: () => ({
+ show: vi.fn(),
+ }),
+ },
+}));
+
+// Mock createPortal for ModelSearchDialog
+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: false, apiKey: null },
+ fal: { id: "fal", name: "fal.ai", enabled: true, apiKey: null },
+ },
+};
+
+describe("GenerateImageNode", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ models: [], success: true }),
+ });
+
+ // Default mock implementation
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ const state = {
+ updateNodeData: mockUpdateNodeData,
+ regenerateNode: mockRegenerateNode,
+ addNode: mockAddNode,
+ incrementModalCount: mockIncrementModalCount,
+ decrementModalCount: mockDecrementModalCount,
+ providerSettings: defaultProviderSettings,
+ generationsPath: "/test/generations",
+ isRunning: false,
+ currentNodeId: null,
+ groups: {},
+ nodes: [],
+ };
+ return selector(state);
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const createNodeData = (overrides: Partial = {}): NanoBananaNodeData => ({
+ inputImages: [],
+ inputPrompt: null,
+ outputImage: null,
+ aspectRatio: "1:1",
+ resolution: "1K",
+ model: "nano-banana-pro",
+ useGoogleSearch: false,
+ status: "idle",
+ error: null,
+ imageHistory: [],
+ selectedHistoryIndex: 0,
+ ...overrides,
+ });
+
+ const createNodeProps = (data: Partial = {}) => ({
+ id: "test-node-1",
+ type: "nanoBanana" as const,
+ data: createNodeData(data),
+ selected: false,
+ });
+
+ describe("Basic Rendering", () => {
+ it("should render with Gemini provider badge by default", () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Gemini badge SVG should be present (star shape)
+ const geminiBadge = container.querySelector('svg[viewBox="0 0 65 65"]');
+ expect(geminiBadge).toBeInTheDocument();
+ });
+
+ it("should render the model name as title", () => {
+ render(
+
+
+
+ );
+
+ // The title should show the display name
+ expect(screen.getByText("Nano Banana Pro")).toBeInTheDocument();
+ });
+
+ it("should render display name from selectedModel when provided", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("FLUX.1 Dev")).toBeInTheDocument();
+ });
+
+ it("should render image and text input handles", () => {
+ const { container } = render(
+
+
+
+ );
+
+ const imageHandle = container.querySelector('[data-handletype="image"][class*="target"]');
+ const textHandle = container.querySelector('[data-handletype="text"]');
+ expect(imageHandle).toBeInTheDocument();
+ expect(textHandle).toBeInTheDocument();
+ });
+
+ it("should render image output handle", () => {
+ const { container } = render(
+
+
+
+ );
+
+ const outputHandle = container.querySelector('[data-handletype="image"][class*="source"]');
+ expect(outputHandle).toBeInTheDocument();
+ });
+
+ it("should render handle labels", () => {
+ render(
+
+
+
+ );
+
+ // Should have Image and Prompt labels for inputs, Image for output
+ const imageLabels = screen.getAllByText("Image");
+ const promptLabels = screen.getAllByText("Prompt");
+ expect(imageLabels.length).toBeGreaterThanOrEqual(1);
+ expect(promptLabels.length).toBe(1);
+ });
+ });
+
+ describe("Idle State", () => {
+ it("should show 'Run to generate' message when idle and no output", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Run to generate")).toBeInTheDocument();
+ });
+
+ it("should render a dashed border placeholder when no output image", () => {
+ const { container } = render(
+
+
+
+ );
+
+ const placeholder = container.querySelector(".border-dashed");
+ expect(placeholder).toBeInTheDocument();
+ });
+ });
+
+ describe("Loading State", () => {
+ it("should show loading spinner when status is loading and no output", () => {
+ const { container } = render(
+
+
+
+ );
+
+ const spinner = container.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it("should show loading overlay when status is loading with existing output", () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Should show the spinner overlay on top of the image
+ const spinner = container.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+
+ // Should still show the image
+ const img = screen.getByAltText("Generated");
+ expect(img).toBeInTheDocument();
+ });
+ });
+
+ describe("Error State", () => {
+ it("should show error message when status is error and no output", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("API error occurred")).toBeInTheDocument();
+ });
+
+ it("should show error overlay when status is error with existing output", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Generation failed")).toBeInTheDocument();
+ expect(screen.getByText("See toast for details")).toBeInTheDocument();
+ });
+
+ it("should show 'Failed' when error message is null", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Failed")).toBeInTheDocument();
+ });
+ });
+
+ describe("Output Image Display", () => {
+ it("should render output image when data.outputImage exists", () => {
+ render(
+
+
+
+ );
+
+ const img = screen.getByAltText("Generated");
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute("src", "data:image/png;base64,abc123");
+ });
+
+ it("should render clear button when output image exists", () => {
+ render(
+
+
+
+ );
+
+ const clearButton = screen.getByTitle("Clear image");
+ expect(clearButton).toBeInTheDocument();
+ });
+
+ it("should call updateNodeData to clear image when clear button is clicked", () => {
+ render(
+
+
+
+ );
+
+ const clearButton = screen.getByTitle("Clear image");
+ fireEvent.click(clearButton);
+
+ expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", {
+ outputImage: null,
+ status: "idle",
+ error: null,
+ });
+ });
+ });
+
+ describe("Image History Carousel", () => {
+ it("should not show carousel controls when history has only one item", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTitle("Previous image")).not.toBeInTheDocument();
+ expect(screen.queryByTitle("Next image")).not.toBeInTheDocument();
+ });
+
+ it("should show carousel controls when history has multiple items", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTitle("Previous image")).toBeInTheDocument();
+ expect(screen.getByTitle("Next image")).toBeInTheDocument();
+ });
+
+ it("should show current position in carousel", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("2 / 3")).toBeInTheDocument();
+ });
+ });
+
+ describe("Run Button", () => {
+ it("should render run button", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTitle("Run this node")).toBeInTheDocument();
+ });
+
+ it("should call regenerateNode when run button is clicked", () => {
+ render(
+
+
+
+ );
+
+ const runButton = screen.getByTitle("Run this node");
+ fireEvent.click(runButton);
+
+ expect(mockRegenerateNode).toHaveBeenCalledWith("test-node-1");
+ });
+
+ it("should disable run button when workflow is running", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ const state = {
+ updateNodeData: mockUpdateNodeData,
+ regenerateNode: mockRegenerateNode,
+ addNode: mockAddNode,
+ incrementModalCount: mockIncrementModalCount,
+ decrementModalCount: mockDecrementModalCount,
+ providerSettings: defaultProviderSettings,
+ generationsPath: "/test/generations",
+ isRunning: true,
+ currentNodeId: null,
+ groups: {},
+ nodes: [],
+ };
+ return selector(state);
+ });
+
+ render(
+
+
+
+ );
+
+ const runButton = screen.getByTitle("Run this node");
+ expect(runButton).toBeDisabled();
+ });
+ });
+
+ describe("Browse Button", () => {
+ it("should render Browse button when external providers are available", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Browse")).toBeInTheDocument();
+ });
+
+ it("should open ModelSearchDialog when Browse button is clicked", async () => {
+ render(
+
+
+
+ );
+
+ const browseButton = screen.getByText("Browse");
+ fireEvent.click(browseButton);
+
+ // ModelSearchDialog should open - look for the dialog title
+ await waitFor(() => {
+ expect(screen.getByText("Browse Models")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Provider Badge Display", () => {
+ it("should show Gemini badge for Gemini provider", () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Gemini badge uses viewBox="0 0 65 65"
+ const geminiBadge = container.querySelector('svg[viewBox="0 0 65 65"]');
+ expect(geminiBadge).toBeInTheDocument();
+ });
+
+ it("should show fal.ai badge for fal provider", async () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Wait for effects to run
+ await waitFor(() => {
+ // fal.ai badge uses viewBox="0 0 1855 1855"
+ const falBadge = container.querySelector('svg[viewBox="0 0 1855 1855"]');
+ expect(falBadge).toBeInTheDocument();
+ });
+ });
+
+ it("should show Replicate badge for Replicate provider", async () => {
+ // Enable Replicate provider
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ const state = {
+ updateNodeData: mockUpdateNodeData,
+ regenerateNode: mockRegenerateNode,
+ addNode: mockAddNode,
+ incrementModalCount: mockIncrementModalCount,
+ decrementModalCount: mockDecrementModalCount,
+ providerSettings: {
+ providers: {
+ ...defaultProviderSettings.providers,
+ replicate: { id: "replicate", name: "Replicate", enabled: true, apiKey: "test-key" },
+ },
+ },
+ generationsPath: "/test/generations",
+ isRunning: false,
+ currentNodeId: null,
+ groups: {},
+ nodes: [],
+ };
+ return selector(state);
+ });
+
+ const { container } = render(
+
+
+
+ );
+
+ // Wait for effects to run
+ await waitFor(() => {
+ // Replicate badge uses viewBox="0 0 1000 1000"
+ const replicateBadge = container.querySelector('svg[viewBox="0 0 1000 1000"]');
+ expect(replicateBadge).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Legacy Data Migration", () => {
+ it("should migrate legacy model field to selectedModel", async () => {
+ render(
+
+
+
+ );
+
+ // The migration effect should call updateNodeData
+ await waitFor(() => {
+ expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", {
+ selectedModel: {
+ provider: "gemini",
+ modelId: "nano-banana",
+ displayName: "Nano Banana",
+ },
+ });
+ });
+ });
+ });
+
+ describe("Dynamic Input Handles (External Providers)", () => {
+ it("should render dynamic handles when inputSchema is provided", () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Should have handles rendered
+ const imageHandle = container.querySelector('[data-handletype="image"]');
+ const textHandle = container.querySelector('[data-handletype="text"]');
+ expect(imageHandle).toBeInTheDocument();
+ expect(textHandle).toBeInTheDocument();
+ });
+
+ it("should show placeholder handles when schema lacks image or text inputs", () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Should still have both image and text handles (image as placeholder)
+ const imageHandle = container.querySelector('[data-handletype="image"]');
+ const textHandle = container.querySelector('[data-handletype="text"]');
+ expect(imageHandle).toBeInTheDocument();
+ expect(textHandle).toBeInTheDocument();
+ });
+ });
+
+ describe("Custom Title and Comment", () => {
+ it("should display custom title when provided", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/My Generator/)).toBeInTheDocument();
+ });
+
+ it("should call updateNodeData when custom title is changed", () => {
+ render(
+
+
+
+ );
+
+ // Click on title to edit
+ const title = screen.getByText("Nano Banana Pro");
+ fireEvent.click(title);
+
+ // Type new title
+ const input = screen.getByPlaceholderText("Custom title...");
+ fireEvent.change(input, { target: { value: "New Title" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { customTitle: "New Title" });
+ });
+ });
+
+ describe("ModelParameters Component", () => {
+ it("should render ModelParameters when external provider model is selected", async () => {
+ render(
+
+
+
+ );
+
+ // ModelParameters should attempt to load schema
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalled();
+ });
+ });
+
+ it("should not render ModelParameters for Gemini provider", async () => {
+ mockFetch.mockClear();
+
+ render(
+
+
+
+ );
+
+ // Give time for any effects to run
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // ModelParameters component should not fetch for Gemini
+ const fetchCalls = mockFetch.mock.calls.filter(call =>
+ typeof call[0] === 'string' && call[0].includes('/api/models/')
+ );
+ expect(fetchCalls.length).toBe(0);
+ });
+ });
+
+ describe("Fetch Models on Provider Change", () => {
+ it("should fetch models when provider is fal", async () => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ const fetchCalls = mockFetch.mock.calls.filter(call =>
+ typeof call[0] === 'string' && call[0].includes('/api/models?')
+ );
+ expect(fetchCalls.length).toBeGreaterThan(0);
+ });
+ });
+
+ it("should not fetch models when provider is gemini", async () => {
+ mockFetch.mockClear();
+
+ render(
+
+
+
+ );
+
+ // Give time for any effects to run
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // Should not fetch models for Gemini (those are hardcoded)
+ const fetchCalls = mockFetch.mock.calls.filter(call =>
+ typeof call[0] === 'string' && call[0].includes('/api/models?provider=gemini')
+ );
+ expect(fetchCalls.length).toBe(0);
+ });
+ });
+});