import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { WorkflowCanvas, isPointInQuickAddHotZone } from "@/components/WorkflowCanvas"; import { ReactFlowProvider } from "@xyflow/react"; // Mock the workflow store const mockOnNodesChange = vi.fn(); const mockOnEdgesChange = vi.fn(); const mockOnConnect = vi.fn(); const mockAddNode = vi.fn().mockReturnValue("new-node-id"); const mockUpdateNodeData = vi.fn(); const mockLoadWorkflow = vi.fn(); const mockGetNodeById = vi.fn(); const mockAddToGlobalHistory = vi.fn(); const mockSetNodeGroupId = vi.fn(); const mockExecuteWorkflow = vi.fn(); const mockCopySelectedNodes = vi.fn(); const mockPasteNodes = vi.fn(); const mockClearClipboard = vi.fn(); const mockSetShowQuickstart = vi.fn(); const mockSetHoveredNodeId = vi.fn(); const mockRegenerateNode = vi.fn(); const mockExecuteSelectedNodes = vi.fn(); const mockClearWorkflow = vi.fn(); const mockSelectSingleNode = vi.fn(); const mockUpdateCanvasNavigationSettings = 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: null, falApiKey: null, kieApiKey: null, wavespeedApiKey: null, }), })); // Mock useReactFlow const mockScreenToFlowPosition = vi.fn((pos) => pos); const mockGetViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 })); const mockZoomIn = vi.fn(); const mockZoomOut = vi.fn(); const mockSetViewport = vi.fn(); const mockSetCenter = vi.fn(); const mockSetNodes = vi.fn(); const mockFitView = vi.fn(); vi.mock("@xyflow/react", async () => { const actual = await vi.importActual("@xyflow/react"); return { ...actual, useReactFlow: () => ({ screenToFlowPosition: mockScreenToFlowPosition, getViewport: mockGetViewport, zoomIn: mockZoomIn, zoomOut: mockZoomOut, setViewport: mockSetViewport, setCenter: mockSetCenter, setNodes: mockSetNodes, fitView: mockFitView, }), }; }); // Mock the child components that aren't being tested vi.mock("@/components/ConnectionDropMenu", () => ({ ConnectionDropMenu: ({ handleType, connectionType, mode, sourceNodeType, onSelect }: any) => (
), })); vi.mock("@/components/MultiSelectToolbar", () => ({ MultiSelectToolbar: () =>
, })); vi.mock("@/components/EdgeToolbar", () => ({ EdgeToolbar: () =>
, })); vi.mock("@/components/GlobalImageHistory", () => ({ GlobalImageHistory: () => null, })); vi.mock("@/components/composer/GenerationComposer", () => ({ GenerationComposer: () => null, })); vi.mock("@/components/GroupsOverlay", () => ({ GroupBackgroundsPortal: () => null, GroupControlsOverlay: () => null, })); vi.mock("@/components/quickstart", () => ({ WelcomeModal: () =>
, })); vi.mock("@/utils/gridSplitter", () => ({ detectAndSplitGrid: vi.fn(), })); vi.mock("@/utils/logger", () => ({ logger: { log: vi.fn(), error: vi.fn(), }, })); // Create mock nodes for testing const createMockNode = (id: string, type: string, overrides = {}) => ({ id, type, position: { x: 100, y: 100 }, data: {}, selected: false, ...overrides, }); // Default provider settings const defaultProviderSettings = { 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 }, kie: { id: "kie", name: "Kie.ai", enabled: false, apiKey: null }, wavespeed: { id: "wavespeed", name: "WaveSpeed", enabled: false, apiKey: null }, }, }; // Default store state factory const createDefaultState = (overrides = {}) => ({ nodes: [], edges: [], groups: {}, onNodesChange: mockOnNodesChange, onEdgesChange: mockOnEdgesChange, onConnect: mockOnConnect, addNode: mockAddNode, updateNodeData: mockUpdateNodeData, loadWorkflow: mockLoadWorkflow, getNodeById: mockGetNodeById, addToGlobalHistory: mockAddToGlobalHistory, setNodeGroupId: mockSetNodeGroupId, executeWorkflow: mockExecuteWorkflow, executeSelectedNodes: mockExecuteSelectedNodes, isRunning: false, isModalOpen: false, showQuickstart: false, setShowQuickstart: mockSetShowQuickstart, copySelectedNodes: mockCopySelectedNodes, pasteNodes: mockPasteNodes, clearClipboard: mockClearClipboard, clipboard: null, providerSettings: defaultProviderSettings, edgeStyle: "angular" as const, currentNodeIds: [], navigationTarget: null, setNavigationTarget: vi.fn(), getNodesWithComments: vi.fn(() => []), getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], text: null })), markCommentViewed: vi.fn(), canvasNavigationSettings: { panMode: "space", zoomMode: "altScroll", selectionMode: "click", assistantEnabled: false }, updateCanvasNavigationSettings: mockUpdateCanvasNavigationSettings, dimmedNodeIds: new Set(), skippedNodeIds: new Set(), hoveredNodeId: null, setHoveredNodeId: mockSetHoveredNodeId, captureSnapshot: vi.fn(), applyEditOperations: vi.fn(() => ({ applied: 0, skipped: [] })), setWorkflowMetadata: vi.fn(), regenerateNode: mockRegenerateNode, clearWorkflow: mockClearWorkflow, selectSingleNode: mockSelectSingleNode, ...overrides, }); // Wrapper component for React Flow context function TestWrapper({ children }: { children: React.ReactNode }) { return {children}; } describe("WorkflowCanvas", () => { const originalProviderMode = process.env.NEXT_PUBLIC_PROVIDER_MODE; beforeEach(() => { vi.clearAllMocks(); // Default mock implementation mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState()); }); }); afterEach(() => { vi.unstubAllGlobals(); if (originalProviderMode === undefined) { delete process.env.NEXT_PUBLIC_PROVIDER_MODE; } else { process.env.NEXT_PUBLIC_PROVIDER_MODE = originalProviderMode; } }); describe("Basic Rendering", () => { it("should render ReactFlow component", () => { render( ); // ReactFlow container should be present expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); it("should render Background component", () => { render( ); // Background pattern should be rendered expect(document.querySelector(".react-flow__background")).toBeInTheDocument(); }); it("should render Controls component", () => { render( ); // Controls panel should be rendered expect(document.querySelector(".react-flow__controls")).toBeInTheDocument(); }); it("should render MiniMap component", () => { render( ); // MiniMap should be rendered expect(document.querySelector(".react-flow__minimap")).toBeInTheDocument(); }); it("should render EdgeToolbar component", () => { render( ); expect(screen.getByTestId("edge-toolbar")).toBeInTheDocument(); }); it("should keep the canvas assistant closed and hidden by default", () => { render( ); expect(screen.queryByRole("heading", { name: /Canvas Assistant/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Assistant" })).not.toBeInTheDocument(); }); it("should show the canvas assistant on the right when the canvas setting is enabled", () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ canvasNavigationSettings: { panMode: "space", zoomMode: "altScroll", selectionMode: "click", assistantEnabled: true, }, })); }); render( ); expect(screen.getByRole("heading", { name: /Canvas Assistant/i })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: /Canvas Assistant/i }).closest(".fixed")).toHaveClass("right-0"); }); it("should disable the canvas assistant setting when the right assistant panel is closed", () => { const enabledSettings = { panMode: "space" as const, zoomMode: "altScroll" as const, selectionMode: "click" as const, assistantEnabled: true, }; mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ canvasNavigationSettings: enabledSettings, })); }); render( ); fireEvent.click(screen.getByRole("button", { name: "Close chat" })); expect(mockUpdateCanvasNavigationSettings).toHaveBeenCalledWith({ ...enabledSettings, assistantEnabled: false, }); }); it("should center and zoom a node when the node shell is double-clicked", async () => { mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 0.6 }); mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [ createMockNode("prompt-1", "prompt", { position: { x: 200, y: 120 }, style: { width: 320, height: 220 }, }), ], })); }); const { container } = render( ); const node = await waitFor(() => { const element = container.querySelector('.react-flow__node[data-id="prompt-1"]'); expect(element).toBeInTheDocument(); return element; }); fireEvent.doubleClick(node!); expect(mockSelectSingleNode).toHaveBeenCalledWith("prompt-1"); expect(mockSetCenter).toHaveBeenCalledWith(360, 230, { duration: 300, zoom: 1, }); }); it("should render MultiSelectToolbar component", () => { render( ); expect(screen.getByTestId("multi-select-toolbar")).toBeInTheDocument(); }); it("hides the fallback model button in Popi provider mode", async () => { process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi"; vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, models: [] }), }))); mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [ createMockNode("video-1", "generateVideo", { selected: true, data: { selectedModel: { provider: "newapiwg", modelId: "video-model", displayName: "Video Model", }, }, }), ], })); }); render( ); await waitFor(() => { expect(screen.getAllByText("Video Model").length).toBeGreaterThan(0); }); expect(screen.queryByTitle("Set fallback model (runs if primary fails)")).not.toBeInTheDocument(); }); it("treats the plus affordance region as a quick-add hot zone", () => { const node = createMockNode("image-1", "imageInput", { measured: { width: 300, height: 220 }, }); expect(isPointInQuickAddHotZone(node as any, { x: 418, y: 210 })).toBe(true); expect(isPointInQuickAddHotZone(node as any, { x: 510, y: 210 })).toBe(false); }); it("renders the visible quick-add affordance without taking pointer events", async () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [ createMockNode("image-1", "imageInput", { selected: true, measured: { width: 300, height: 220 }, }), ], })); }); render( ); const affordance = await screen.findByTestId("node-quick-add-affordance"); expect(affordance).toHaveClass("pointer-events-none"); expect(affordance).toHaveAttribute("aria-hidden", "true"); }); it("should open the connection menu from a node quick-add button and create a connected node", async () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [ createMockNode("image-1", "imageInput", { selected: true, measured: { width: 300, height: 220 }, }), ], })); }); render( ); const quickAddButton = screen.getByRole("button", { name: "Add connected node" }); fireEvent.pointerDown(quickAddButton, { clientX: 418, clientY: 210, }); fireEvent.click(quickAddButton, { clientX: 418, clientY: 210, }); const menu = await screen.findByTestId("connection-drop-menu"); expect(menu).toHaveAttribute("data-handle-type", "image"); expect(menu).toHaveAttribute("data-connection-type", "source"); expect(menu).toHaveAttribute("data-mode", "quickAdd"); expect(menu).toHaveAttribute("data-source-node-type", "imageInput"); fireEvent.click(screen.getByText("Create Generate Image")); expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", { x: 560, y: 100 }); expect(mockOnConnect).toHaveBeenCalledWith({ source: "image-1", sourceHandle: "image", target: "new-node-id", targetHandle: "image", }); expect(mockSelectSingleNode).toHaveBeenCalledWith("new-node-id"); }); it("should open the image multi-angle editor from the connection menu and generate a connected image node", async () => { const sourceNode = createMockNode("image-1", "imageInput", { selected: true, measured: { width: 300, height: 220 }, data: { image: "data:image/png;base64,input", filename: "input.png", dimensions: { width: 640, height: 480 }, }, }); mockGetNodeById.mockImplementation((id) => (id === "image-1" ? sourceNode : null)); mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [sourceNode], })); }); render( ); const quickAddButton = screen.getByRole("button", { name: "Add connected node" }); fireEvent.pointerDown(quickAddButton, { clientX: 418, clientY: 210, }); fireEvent.click(quickAddButton, { clientX: 418, clientY: 210, }); fireEvent.click(await screen.findByText("Open Multi Angle")); expect(screen.getByRole("dialog", { name: "Image Multi-Angle Editor" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Fisheye" })); fireEvent.click(screen.getByRole("button", { name: "Generate multi-angle" })); await waitFor(() => { expect(mockRegenerateNode).toHaveBeenCalledWith("new-node-id"); }); expect(mockAddNode).toHaveBeenCalledWith( "nanoBanana", { x: 560, y: 100 }, expect.objectContaining({ inputImages: ["data:image/png;base64,input"], inputPrompt: expect.stringContaining("超近距离鱼眼镜头"), aspectRatio: "16:9", resolution: "2K", }) ); expect(mockOnConnect).toHaveBeenCalledWith({ source: "image-1", sourceHandle: "image", target: "new-node-id", targetHandle: "image", }); expect(mockSelectSingleNode).toHaveBeenCalledWith("new-node-id"); }); }); describe("Welcome Modal", () => { it("should show welcome modal when canvas is empty and showQuickstart is true", () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [], showQuickstart: true, })); }); render( ); expect(screen.getByTestId("welcome-modal")).toBeInTheDocument(); }); it("should not show welcome modal when canvas has nodes", () => { // When a workflow with nodes is loaded, the store sets showQuickstart to false. // The component renders the modal based solely on showQuickstart state. mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [createMockNode("node-1", "prompt")], showQuickstart: false, })); }); render( ); expect(screen.queryByTestId("welcome-modal")).not.toBeInTheDocument(); }); it("should not show welcome modal when showQuickstart is false", () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [], showQuickstart: false, })); }); render( ); expect(screen.queryByTestId("welcome-modal")).not.toBeInTheDocument(); }); }); describe("Node Types Registration", () => { it("should register all required node types for the canvas", () => { // We verify the canvas renders with the node types object defined // The actual node type registration happens at module level // This test confirms the canvas component can be rendered render( ); // Canvas should render and have the ReactFlow component expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); }); describe("Edge Types Registration", () => { it("should register editable and reference edge types for the canvas", () => { // Edge types are registered at module level // This test confirms the canvas renders with edge type config render( ); // Canvas should render with edge types configured expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); }); describe("Drag and Drop", () => { it("should show drop overlay when dragging node type over canvas", () => { render( ); const canvas = document.querySelector(".bg-canvas-bg") as HTMLElement; // Simulate drag over with node type data const mockDataTransfer = { types: ["application/node-type"], items: [], dropEffect: "", effectAllowed: "", getData: vi.fn(), }; fireEvent.dragOver(canvas, { dataTransfer: mockDataTransfer, preventDefault: vi.fn(), }); // Should show "Drop to create node" indicator expect(screen.getByText("Drop to create node")).toBeInTheDocument(); }); it("should show drop overlay when dragging image over canvas", () => { render( ); const canvas = document.querySelector(".bg-canvas-bg") as HTMLElement; // Simulate drag over with image file const mockDataTransfer = { types: [], items: [{ kind: "file", type: "image/png" }], dropEffect: "", effectAllowed: "", getData: vi.fn(), }; fireEvent.dragOver(canvas, { dataTransfer: mockDataTransfer, preventDefault: vi.fn(), }); // Should show "Drop image to create node" indicator expect(screen.getByText("Drop image to create node")).toBeInTheDocument(); }); it("should show drop overlay when dragging workflow JSON over canvas", () => { render( ); const canvas = document.querySelector(".bg-canvas-bg") as HTMLElement; // Simulate drag over with JSON file const mockDataTransfer = { types: [], items: [{ kind: "file", type: "application/json" }], dropEffect: "", effectAllowed: "", getData: vi.fn(), }; fireEvent.dragOver(canvas, { dataTransfer: mockDataTransfer, preventDefault: vi.fn(), }); // Should show "Drop to load workflow" indicator expect(screen.getByText("Drop to load workflow")).toBeInTheDocument(); }); it("should hide drop overlay on drag leave", () => { render( ); const canvas = document.querySelector(".bg-canvas-bg") as HTMLElement; // First drag over const mockDataTransfer = { types: ["application/node-type"], items: [], dropEffect: "", effectAllowed: "", getData: vi.fn(), }; fireEvent.dragOver(canvas, { dataTransfer: mockDataTransfer, preventDefault: vi.fn(), }); expect(screen.getByText("Drop to create node")).toBeInTheDocument(); // Then drag leave fireEvent.dragLeave(canvas, { preventDefault: vi.fn() }); expect(screen.queryByText("Drop to create node")).not.toBeInTheDocument(); }); it("should call addNode when node type is dropped on canvas", () => { render( ); const canvas = document.querySelector(".bg-canvas-bg") as HTMLElement; const mockDataTransfer = { types: ["application/node-type"], items: [], files: [], dropEffect: "", effectAllowed: "", getData: vi.fn().mockReturnValue("prompt"), }; fireEvent.drop(canvas, { dataTransfer: mockDataTransfer, preventDefault: vi.fn(), clientX: 500, clientY: 300, }); expect(mockAddNode).toHaveBeenCalledWith("prompt", expect.any(Object)); }); }); describe("Keyboard Shortcuts", () => { it("should call executeWorkflow on Ctrl+Enter", () => { render( ); fireEvent.keyDown(window, { key: "Enter", ctrlKey: true }); expect(mockExecuteWorkflow).toHaveBeenCalled(); }); it("should call executeWorkflow on Cmd+Enter", () => { render( ); fireEvent.keyDown(window, { key: "Enter", metaKey: true }); expect(mockExecuteWorkflow).toHaveBeenCalled(); }); it("should call copySelectedNodes on Ctrl+C", () => { render( ); fireEvent.keyDown(window, { key: "c", ctrlKey: true }); expect(mockCopySelectedNodes).toHaveBeenCalled(); }); it("should not copy when typing in input field", () => { render( ); const input = screen.getByTestId("test-input"); fireEvent.keyDown(input, { key: "c", ctrlKey: true }); // Should not call copy when target is input expect(mockCopySelectedNodes).not.toHaveBeenCalled(); }); it("should add prompt node on Shift+P", () => { render( ); fireEvent.keyDown(window, { key: "p", shiftKey: true }); expect(mockAddNode).toHaveBeenCalledWith("prompt", expect.any(Object)); }); it("should add imageInput node on Shift+I", () => { render( ); fireEvent.keyDown(window, { key: "i", shiftKey: true }); expect(mockAddNode).toHaveBeenCalledWith("imageInput", expect.any(Object)); }); it("should add nanoBanana node on Shift+G", () => { render( ); fireEvent.keyDown(window, { key: "g", shiftKey: true }); expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", expect.any(Object)); }); it("should add llmGenerate node on Shift+L", () => { render( ); fireEvent.keyDown(window, { key: "l", shiftKey: true }); expect(mockAddNode).toHaveBeenCalledWith("llmGenerate", expect.any(Object)); }); it("should add annotation node on Shift+A", () => { render( ); fireEvent.keyDown(window, { key: "a", shiftKey: true }); expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object)); }); }); describe("Connection Validation", () => { it("should render canvas with isValidConnection callback configured", () => { // The isValidConnection function is passed to ReactFlow to validate connections // This test confirms the canvas is properly configured render( ); // Canvas should have ReactFlow with validation configured expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); }); describe("Group Handling", () => { it("should render canvas with group support enabled", () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ groups: { "group-1": { id: "group-1", position: { x: 100, y: 100 }, size: { width: 400, height: 400 }, locked: false, }, }, })); }); render( ); // The canvas should render with groups functionality enabled expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); }); describe("Canvas Configuration", () => { it("should configure delete key codes for node deletion", () => { render( ); // ReactFlow is configured with deleteKeyCode={["Backspace", "Delete"]} expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); it("should configure multi-selection with Shift key", () => { render( ); // ReactFlow is configured with multiSelectionKeyCode="Shift" expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); it("should configure zoom constraints", () => { render( ); // ReactFlow is configured with minZoom={0.1} maxZoom={4} expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); it("should use editable as default edge type", () => { render( ); // ReactFlow is configured with defaultEdgeOptions={{ type: "editable" }} expect(document.querySelector(".react-flow")).toBeInTheDocument(); }); }); describe("Popiai embed viewport", () => { it("fits the canvas when the embed bridge requests it", () => { vi.useFakeTimers(); try { render( ); window.dispatchEvent(new CustomEvent("popiai:fit-view")); expect(mockFitView).not.toHaveBeenCalled(); vi.advanceTimersByTime(80); expect(mockFitView).toHaveBeenCalledWith({ maxZoom: 1, padding: 0.2, duration: 500, }); } finally { vi.useRealTimers(); } }); }); describe("Clipboard Paste", () => { // Helper to mock clipboard with image data const mockClipboardWithImage = () => { const mockBlob = new Blob(["fake-image-data"], { type: "image/png" }); const mockClipboardItem = { types: ["image/png"], getType: vi.fn().mockResolvedValue(mockBlob), }; const mockRead = vi.fn().mockResolvedValue([mockClipboardItem]); Object.defineProperty(navigator, "clipboard", { value: { read: mockRead }, configurable: true, writable: true, }); return { mockRead, mockClipboardItem }; }; // Helper to mock FileReader const mockFileReader = (dataUrl: string) => { class MockFileReader { onload: ((event: ProgressEvent) => void) | null = null; result: string = dataUrl; readAsDataURL() { setTimeout(() => { this.onload?.({ target: { result: this.result } } as ProgressEvent); }, 0); } } global.FileReader = MockFileReader as unknown as typeof FileReader; }; // Helper to mock Image const mockImage = (width: number, height: number) => { class MockImage { onload: (() => void) | null = null; width: number = width; height: number = height; private _src: string = ""; get src() { return this._src; } set src(value: string) { this._src = value; setTimeout(() => { this.onload?.(); }, 0); } } global.Image = MockImage as unknown as typeof Image; }; it("should update selected imageInput node when pasting image from clipboard", async () => { mockClipboardWithImage(); mockFileReader("data:image/png;base64,test123"); mockImage(1024, 768); const selectedImageNode = createMockNode("image-1", "imageInput", { selected: true, data: { image: null, filename: null, dimensions: null }, }); mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [selectedImageNode], clipboard: null, })); }); render( ); // Trigger Ctrl+V fireEvent.keyDown(window, { key: "v", ctrlKey: true }); await waitFor(() => { expect(mockUpdateNodeData).toHaveBeenCalledWith( "image-1", expect.objectContaining({ image: "data:image/png;base64,test123", filename: expect.stringContaining("pasted-"), dimensions: { width: 1024, height: 768 }, }) ); }); // Should NOT create a new node expect(mockAddNode).not.toHaveBeenCalled(); }); it("should create new imageInput node when pasting image with no selection", async () => { mockClipboardWithImage(); mockFileReader("data:image/png;base64,newimage"); mockImage(800, 600); // No nodes selected mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [], clipboard: null, })); }); render( ); // Trigger Ctrl+V fireEvent.keyDown(window, { key: "v", ctrlKey: true }); await waitFor(() => { expect(mockAddNode).toHaveBeenCalledWith("imageInput", expect.any(Object)); }); await waitFor(() => { expect(mockUpdateNodeData).toHaveBeenCalledWith( "new-node-id", expect.objectContaining({ image: "data:image/png;base64,newimage", dimensions: { width: 800, height: 600 }, }) ); }); }); it("should create new imageInput node when pasting image with non-imageInput node selected", async () => { mockClipboardWithImage(); mockFileReader("data:image/png;base64,anotherimage"); mockImage(640, 480); // Prompt node selected (not imageInput) const selectedPromptNode = createMockNode("prompt-1", "prompt", { selected: true, data: { prompt: "test" }, }); mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [selectedPromptNode], clipboard: null, })); }); render( ); // Trigger Ctrl+V fireEvent.keyDown(window, { key: "v", ctrlKey: true }); await waitFor(() => { expect(mockAddNode).toHaveBeenCalledWith("imageInput", expect.any(Object)); }); await waitFor(() => { expect(mockUpdateNodeData).toHaveBeenCalledWith( "new-node-id", expect.objectContaining({ image: "data:image/png;base64,anotherimage", dimensions: { width: 640, height: 480 }, }) ); }); }); it("should prioritize internal clipboard over system clipboard when nodes are copied", async () => { mockClipboardWithImage(); mockFileReader("data:image/png;base64,ignored"); mockImage(100, 100); // Internal clipboard has nodes mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ nodes: [], clipboard: { nodes: [createMockNode("copied-1", "prompt")], edges: [], }, })); }); render( ); // Trigger Ctrl+V fireEvent.keyDown(window, { key: "v", ctrlKey: true }); // Should paste internal clipboard nodes, not system clipboard image expect(mockPasteNodes).toHaveBeenCalled(); expect(mockClearClipboard).toHaveBeenCalled(); // Should NOT update or add nodes from system clipboard expect(mockUpdateNodeData).not.toHaveBeenCalled(); expect(mockAddNode).not.toHaveBeenCalled(); }); }); });