diff --git a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx index a50a614968..d16da63b93 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx @@ -1,12 +1,17 @@ import type { ReactNode } from 'react' import type { WorkflowProps } from '@/app/components/workflow' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' import WorkflowMain from '../workflow-main' const mockSetFeatures = vi.fn() const mockSetConversationVariables = vi.fn() const mockSetEnvironmentVariables = vi.fn() +const mockHandleUpdateWorkflowCanvas = vi.hoisted(() => vi.fn()) +const mockFetchWorkflowDraft = vi.hoisted(() => vi.fn()) +const mockOnVarsAndFeaturesUpdate = vi.hoisted(() => vi.fn()) +const mockOnWorkflowUpdate = vi.hoisted(() => vi.fn()) +const mockOnSyncRequest = vi.hoisted(() => vi.fn()) const hookFns = { doSyncWorkflowDraft: vi.fn(), @@ -44,9 +49,24 @@ const hookFns = { invalidateConversationVarValues: vi.fn(), } +const collaborationRuntime = vi.hoisted(() => ({ + startCursorTracking: vi.fn(), + stopCursorTracking: vi.fn(), + onlineUsers: [] as Array<{ user_id: string, username: string, avatar: string, sid: string }>, + cursors: {} as Record, + isConnected: false, + isEnabled: false, +})) + +const collaborationListeners = vi.hoisted(() => ({ + varsAndFeaturesUpdate: null as null | ((update: unknown) => void | Promise), + workflowUpdate: null as null | (() => void | Promise), + syncRequest: null as null | (() => void), +})) + let capturedContextProps: Record | null = null -type MockWorkflowWithInnerContextProps = Pick & { +type MockWorkflowWithInnerContextProps = Pick & { hooksStore?: Record children?: ReactNode } @@ -82,21 +102,42 @@ vi.mock('reactflow', () => ({ vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => ({ useCollaboration: () => ({ - startCursorTracking: vi.fn(), - stopCursorTracking: vi.fn(), - onlineUsers: [], - cursors: {}, - isConnected: false, - isEnabled: false, + startCursorTracking: collaborationRuntime.startCursorTracking, + stopCursorTracking: collaborationRuntime.stopCursorTracking, + onlineUsers: collaborationRuntime.onlineUsers, + cursors: collaborationRuntime.cursors, + isConnected: collaborationRuntime.isConnected, + isEnabled: collaborationRuntime.isEnabled, }), })) vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({ useWorkflowUpdate: () => ({ - handleUpdateWorkflowCanvas: vi.fn(), + handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, }), })) +vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + onVarsAndFeaturesUpdate: mockOnVarsAndFeaturesUpdate.mockImplementation((handler: (update: unknown) => void | Promise) => { + collaborationListeners.varsAndFeaturesUpdate = handler + return vi.fn() + }), + onWorkflowUpdate: mockOnWorkflowUpdate.mockImplementation((handler: () => void | Promise) => { + collaborationListeners.workflowUpdate = handler + return vi.fn() + }), + onSyncRequest: mockOnSyncRequest.mockImplementation((handler: () => void) => { + collaborationListeners.syncRequest = handler + return vi.fn() + }), + }, +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args), +})) + vi.mock('@/app/components/workflow', () => ({ WorkflowWithInnerContext: ({ nodes, @@ -104,6 +145,9 @@ vi.mock('@/app/components/workflow', () => ({ viewport, onWorkflowDataUpdate, hooksStore, + cursors, + myUserId, + onlineUsers, children, }: MockWorkflowWithInnerContextProps) => { capturedContextProps = { @@ -111,6 +155,9 @@ vi.mock('@/app/components/workflow', () => ({ edges, viewport, hooksStore, + cursors, + myUserId, + onlineUsers, } return (
@@ -221,6 +268,16 @@ describe('WorkflowMain', () => { beforeEach(() => { vi.clearAllMocks() capturedContextProps = null + collaborationRuntime.startCursorTracking.mockReset() + collaborationRuntime.stopCursorTracking.mockReset() + collaborationRuntime.onlineUsers = [] + collaborationRuntime.cursors = {} + collaborationRuntime.isConnected = false + collaborationRuntime.isEnabled = false + collaborationListeners.varsAndFeaturesUpdate = null + collaborationListeners.workflowUpdate = null + collaborationListeners.syncRequest = null + mockFetchWorkflowDraft.mockReset() }) it('should render the inner workflow context with children and forwarded graph props', () => { @@ -328,4 +385,79 @@ describe('WorkflowMain', () => { configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }, }) }) + + it('passes collaboration props and tracks cursors when collaboration is enabled', () => { + collaborationRuntime.isEnabled = true + collaborationRuntime.isConnected = true + collaborationRuntime.onlineUsers = [{ user_id: 'u-1', username: 'Alice', avatar: '', sid: 'sid-1' }] + collaborationRuntime.cursors = { + 'current-user': { x: 1, y: 2, userId: 'current-user', timestamp: 1 }, + 'user-other': { x: 20, y: 30, userId: 'user-other', timestamp: 2 }, + } + + const { unmount } = render( + , + ) + + expect(collaborationRuntime.startCursorTracking).toHaveBeenCalled() + expect(capturedContextProps).toMatchObject({ + myUserId: 'current-user', + onlineUsers: [{ user_id: 'u-1' }], + cursors: { + 'user-other': expect.objectContaining({ userId: 'user-other' }), + }, + }) + + unmount() + expect(collaborationRuntime.stopCursorTracking).toHaveBeenCalled() + }) + + it('subscribes collaboration listeners and handles sync/workflow update callbacks', async () => { + collaborationRuntime.isEnabled = true + mockFetchWorkflowDraft.mockResolvedValue({ + features: { + file_upload: { enabled: true }, + opening_statement: 'hello', + }, + conversation_variables: [], + environment_variables: [], + graph: { + nodes: [{ id: 'n-1' }], + edges: [{ id: 'e-1' }], + viewport: { x: 3, y: 4, zoom: 1.2 }, + }, + }) + + render( + , + ) + + expect(mockOnVarsAndFeaturesUpdate).toHaveBeenCalled() + expect(mockOnWorkflowUpdate).toHaveBeenCalled() + expect(mockOnSyncRequest).toHaveBeenCalled() + + collaborationListeners.syncRequest?.() + expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalled() + + await collaborationListeners.varsAndFeaturesUpdate?.({}) + await collaborationListeners.workflowUpdate?.() + + await waitFor(() => { + expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft') + expect(mockSetFeatures).toHaveBeenCalled() + expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ + nodes: [{ id: 'n-1' }], + edges: [{ id: 'e-1' }], + viewport: { x: 3, y: 4, zoom: 1.2 }, + }) + }) + }) }) diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 935fa42eb9..811c942d3d 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -5,6 +5,7 @@ import { BaseEdge, internalsSymbol, Position, ReactFlowProvider, useStoreApi } f import { FlowType } from '@/types/common' import { WORKFLOW_DATA_UPDATE } from '../constants' import { Workflow } from '../index' +import { ControlMode } from '../types' import { renderWorkflowComponent } from './workflow-test-env' type WorkflowUpdateEvent = { @@ -23,6 +24,32 @@ const reactFlowBridge = vi.hoisted(() => ({ store: null as null | ReturnType, })) +const collaborationBridge = vi.hoisted(() => ({ + graphImportHandler: null as null | ((payload: { nodes: Node[], edges: Edge[] }) => void), + historyActionHandler: null as null | ((payload: unknown) => void), +})) + +const toastInfoMock = vi.hoisted(() => vi.fn()) + +const workflowCommentState = vi.hoisted(() => ({ + comments: [] as Array>, + pendingComment: null as null | { elementX: number, elementY: number }, + activeComment: null as null | Record, + activeCommentLoading: false, + replySubmitting: false, + replyUpdating: false, + handleCommentSubmit: vi.fn(), + handleCommentCancel: vi.fn(), + handleCommentIconClick: vi.fn(), + handleActiveCommentClose: vi.fn(), + handleCommentResolve: vi.fn(), + handleCommentDelete: vi.fn(async () => {}), + handleCommentReply: vi.fn(), + handleCommentReplyUpdate: vi.fn(), + handleCommentReplyDelete: vi.fn(async () => {}), + handleCommentPositionUpdate: vi.fn(), +})) + const workflowHookMocks = vi.hoisted(() => ({ handleNodeDragStart: vi.fn(), handleNodeDrag: vi.fn(), @@ -137,6 +164,109 @@ vi.mock('@/service/workflow', () => ({ fetchAllInspectVars: vi.fn().mockResolvedValue([]), })) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + info: toastInfoMock, + }, +})) + +vi.mock('../collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + onGraphImport: (handler: (payload: { nodes: Node[], edges: Edge[] }) => void) => { + collaborationBridge.graphImportHandler = handler + return vi.fn() + }, + onHistoryAction: (handler: (payload: unknown) => void) => { + collaborationBridge.historyActionHandler = handler + return vi.fn() + }, + }, +})) + +vi.mock('../comment-manager', () => ({ + default: () =>
, +})) + +vi.mock('../comment/cursor', () => ({ + CommentCursor: () =>
, +})) + +vi.mock('../comment/comment-input', () => ({ + CommentInput: ({ disabled, onCancel }: { disabled?: boolean, onCancel?: () => void }) => ( + + ), +})) + +vi.mock('../comment/comment-icon', () => ({ + CommentIcon: ({ + comment, + onClick, + onPositionUpdate, + }: { + comment: { id: string } + onClick?: () => void + onPositionUpdate?: (position: { elementX: number, elementY: number }) => void + }) => ( + + ), +})) + +vi.mock('../comment/thread', () => ({ + CommentThread: ({ + onDelete, + onReplyDelete, + onNext, + }: { + onDelete?: () => void + onReplyDelete?: (replyId: string) => void + onNext?: () => void + }) => ( +
+ + + +
+ ), +})) + +vi.mock('../hooks/use-workflow-comment', () => ({ + useWorkflowComment: () => workflowCommentState, +})) + +vi.mock('../base/confirm', () => ({ + default: ({ + isShow, + onConfirm, + onCancel, + }: { + isShow: boolean + onConfirm: () => void + onCancel: () => void + }) => isShow + ? ( +
+ + +
+ ) + : null, +})) + vi.mock('../candidate-node', () => ({ default: () => null, })) @@ -336,6 +466,14 @@ describe('Workflow edge event wiring', () => { vi.clearAllMocks() eventEmitterState.subscription = null reactFlowBridge.store = null + collaborationBridge.graphImportHandler = null + collaborationBridge.historyActionHandler = null + workflowCommentState.comments = [] + workflowCommentState.pendingComment = null + workflowCommentState.activeComment = null + workflowCommentState.activeCommentLoading = false + workflowCommentState.replySubmitting = false + workflowCommentState.replyUpdating = false }) it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => { @@ -415,4 +553,98 @@ describe('Workflow edge event wiring', () => { expect(store.getState().edgeMenu).toBeUndefined() }) + + it('should sync graph import events and show history action toast', async () => { + renderSubject() + + const importedNodes = [createInitializedNode('node-3', 480, 'Workflow node node-3')] as unknown as Node[] + + act(() => { + collaborationBridge.graphImportHandler?.({ + nodes: importedNodes, + edges: [], + }) + collaborationBridge.historyActionHandler?.({ action: 'undo' }) + }) + + await waitFor(() => { + expect(screen.getByText('Workflow node node-3')).toBeInTheDocument() + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) + }) + + it('should render comment overlays and execute comment actions in comment mode', async () => { + workflowCommentState.comments = [ + { id: 'comment-1', resolved: false }, + { id: 'comment-2', resolved: false }, + ] + workflowCommentState.activeComment = { id: 'comment-1', resolved: false } + workflowCommentState.pendingComment = { elementX: 20, elementY: 30 } + + const { container, store } = renderSubject({ + initialStoreState: { + controlMode: ControlMode.Comment, + showUserComments: true, + showResolvedComments: false, + isCommentPlacing: true, + pendingComment: null, + isCommentPreviewHovering: true, + mousePosition: { + pageX: 100, + pageY: 120, + elementX: 40, + elementY: 60, + }, + }, + }) + + const pane = getPane(container) + act(() => { + fireEvent.mouseMove(pane, { clientX: 150, clientY: 180 }) + }) + + expect(screen.getByTestId('comment-cursor')).toBeInTheDocument() + expect(screen.getByTestId('comment-input-preview')).toBeInTheDocument() + expect(screen.getByTestId('comment-input-active')).toBeInTheDocument() + expect(screen.getByTestId('comment-icon-comment-1')).toBeInTheDocument() + expect(screen.getByTestId('comment-icon-comment-2')).toBeInTheDocument() + expect(screen.getByTestId('comment-thread')).toBeInTheDocument() + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'next-comment' })) + }) + expect(workflowCommentState.handleCommentIconClick).toHaveBeenCalledWith({ id: 'comment-2', resolved: false }) + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'delete-thread' })) + }) + expect(store.getState().showConfirm).toBeDefined() + + await act(async () => { + await store.getState().showConfirm?.onConfirm() + }) + expect(workflowCommentState.handleCommentDelete).toHaveBeenCalledWith('comment-1') + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'delete-reply' })) + }) + expect(store.getState().showConfirm).toBeDefined() + await act(async () => { + await store.getState().showConfirm?.onConfirm() + }) + expect(workflowCommentState.handleCommentReplyDelete).toHaveBeenCalledWith('comment-1', 'reply-1') + + const wheelEvent = new WheelEvent('wheel', { + cancelable: true, + ctrlKey: true, + }) + act(() => { + window.dispatchEvent(wheelEvent) + }) + + const gestureEvent = new Event('gesturestart', { cancelable: true }) + act(() => { + window.dispatchEvent(gestureEvent) + }) + }) }) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts new file mode 100644 index 0000000000..5ad0788349 --- /dev/null +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts @@ -0,0 +1,560 @@ +import type { LoroMap } from 'loro-crdt' +import type { Socket } from 'socket.io-client' +import type { + CollaborationUpdate, + NodePanelPresenceMap, + OnlineUser, + RestoreCompleteData, + RestoreIntentData, +} from '../../types/collaboration' +import type { Edge, Node } from '@/app/components/workflow/types' +import { LoroDoc } from 'loro-crdt' +import { BlockEnum } from '@/app/components/workflow/types' +import { CollaborationManager } from '../collaboration-manager' +import { webSocketClient } from '../websocket-manager' + +type ReactFlowStore = { + getState: () => { + getNodes: () => Node[] + setNodes: (nodes: Node[]) => void + getEdges: () => Edge[] + setEdges: (edges: Edge[]) => void + } +} + +type LoroSubscribeEvent = { + by?: string +} + +type MockSocket = { + id: string + connected: boolean + emit: ReturnType + on: ReturnType + trigger: (event: string, ...args: unknown[]) => void +} + +type CollaborationManagerInternals = { + doc: LoroDoc | null + nodesMap: LoroMap | null + edgesMap: LoroMap | null + currentAppId: string | null + reactFlowStore: ReactFlowStore | null + isLeader: boolean + leaderId: string | null + pendingInitialSync: boolean + pendingGraphImportEmit: boolean + rejoinInProgress: boolean + onlineUsers: OnlineUser[] + nodePanelPresence: NodePanelPresenceMap + cursors: Record + graphSyncDiagnostics: unknown[] + setNodesAnomalyLogs: unknown[] + handleSessionUnauthorized: () => void + forceDisconnect: () => void + setupSocketEventListeners: (socket: Socket) => void + setupSubscriptions: () => void + scheduleGraphImportEmit: () => void + emitGraphResyncRequest: () => void + broadcastCurrentGraph: () => void + recordGraphSyncDiagnostic: ( + stage: 'nodes_subscribe' | 'edges_subscribe' | 'nodes_import_apply' | 'edges_import_apply' | 'schedule_graph_import_emit' | 'graph_import_emit' | 'start_import_log' | 'finalize_import_log', + status: 'triggered' | 'skipped' | 'applied' | 'queued' | 'emitted' | 'snapshot', + reason?: string, + details?: Record, + ) => void + captureSetNodesAnomaly: (oldNodes: Node[], newNodes: Node[], source: string) => void +} + +const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals => + manager as unknown as CollaborationManagerInternals + +const createNode = (id: string, title = `Node-${id}`): Node => ({ + id, + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.Start, + title, + desc: '', + }, +}) + +const createEdge = (id: string, source: string, target: string): Edge => ({ + id, + source, + target, + type: 'custom', + data: { + sourceType: BlockEnum.Start, + targetType: BlockEnum.End, + }, +}) + +const createMockSocket = (id = 'socket-1'): MockSocket => { + const handlers = new Map void>() + + return { + id, + connected: true, + emit: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler) + }), + trigger: (event: string, ...args: unknown[]) => { + const handler = handlers.get(event) + if (handler) + handler(...args) + }, + } +} + +const setupManagerWithDoc = () => { + const manager = new CollaborationManager() + const doc = new LoroDoc() + const internals = getManagerInternals(manager) + internals.doc = doc + internals.nodesMap = doc.getMap('nodes') + internals.edgesMap = doc.getMap('edges') + return { manager, internals } +} + +describe('CollaborationManager socket and subscription behavior', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('emits cursor/sync/workflow events via collaboration_event when connected', () => { + const { manager, internals } = setupManagerWithDoc() + const socket = createMockSocket('socket-connected') + + internals.currentAppId = 'app-1' + vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(true) + vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket) + + manager.emitCursorMove({ x: 11, y: 22, userId: 'u-1', timestamp: Date.now() }) + manager.emitSyncRequest() + manager.emitWorkflowUpdate('wf-1') + + expect(socket.emit).toHaveBeenCalledTimes(3) + const payloads = socket.emit.mock.calls.map(call => call[1] as { type: string, data: Record }) + expect(payloads.map(item => item.type)).toEqual(['mouse_move', 'sync_request', 'workflow_update']) + expect(payloads[0]?.data).toMatchObject({ x: 11, y: 22 }) + expect(payloads[2]?.data).toMatchObject({ appId: 'wf-1' }) + }) + + it('tries to rejoin on unauthorized and forces disconnect on unauthorized ack', () => { + const { internals } = setupManagerWithDoc() + const socket = createMockSocket('socket-rejoin') + const getSocketSpy = vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket) + const forceDisconnectSpy = vi.spyOn(internals, 'forceDisconnect').mockImplementation(() => undefined) + + internals.currentAppId = 'app-rejoin' + internals.rejoinInProgress = true + internals.handleSessionUnauthorized() + expect(socket.emit).not.toHaveBeenCalled() + + internals.rejoinInProgress = false + internals.handleSessionUnauthorized() + expect(socket.emit).toHaveBeenCalledWith( + 'user_connect', + { workflow_id: 'app-rejoin' }, + expect.any(Function), + ) + + const ack = socket.emit.mock.calls[0]?.[2] as ((...ackArgs: unknown[]) => void) | undefined + expect(ack).toBeDefined() + ack?.({ msg: 'unauthorized' }) + + expect(forceDisconnectSpy).toHaveBeenCalledTimes(1) + expect(internals.rejoinInProgress).toBe(false) + expect(getSocketSpy).toHaveBeenCalled() + }) + + it('routes collaboration_update payloads to corresponding event channels', () => { + const { manager, internals } = setupManagerWithDoc() + const socket = createMockSocket('socket-events') + + const broadcastSpy = vi.spyOn(internals, 'broadcastCurrentGraph').mockImplementation(() => undefined) + internals.isLeader = true + internals.setupSocketEventListeners(socket as unknown as Socket) + + const varsFeatureHandler = vi.fn() + const appStateHandler = vi.fn() + const appMetaHandler = vi.fn() + const appPublishHandler = vi.fn() + const mcpHandler = vi.fn() + const workflowUpdateHandler = vi.fn() + const commentsHandler = vi.fn() + const restoreRequestHandler = vi.fn() + const restoreIntentHandler = vi.fn() + const restoreCompleteHandler = vi.fn() + const historyHandler = vi.fn() + const syncRequestHandler = vi.fn() + let latestPresence: NodePanelPresenceMap | null = null + let latestCursors: Record | null = null + + manager.onVarsAndFeaturesUpdate(varsFeatureHandler) + manager.onAppStateUpdate(appStateHandler) + manager.onAppMetaUpdate(appMetaHandler) + manager.onAppPublishUpdate(appPublishHandler) + manager.onMcpServerUpdate(mcpHandler) + manager.onWorkflowUpdate(workflowUpdateHandler) + manager.onCommentsUpdate(commentsHandler) + manager.onRestoreRequest(restoreRequestHandler) + manager.onRestoreIntent(restoreIntentHandler) + manager.onRestoreComplete(restoreCompleteHandler) + manager.onHistoryAction(historyHandler) + manager.onSyncRequest(syncRequestHandler) + manager.onNodePanelPresenceUpdate((presence) => { + latestPresence = presence + }) + manager.onCursorUpdate((cursors) => { + latestCursors = cursors as Record + }) + + const baseUpdate: Pick = { + userId: 'u-1', + timestamp: 1000, + } + + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'mouse_move', + data: { x: 1, y: 2 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'vars_and_features_update', + data: { value: 1 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'app_state_update', + data: { value: 2 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'app_meta_update', + data: { value: 3 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'app_publish_update', + data: { value: 4 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'mcp_server_update', + data: { value: 5 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'workflow_update', + data: { appId: 'wf', timestamp: 9 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'comments_update', + data: { appId: 'wf', timestamp: 10 }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'node_panel_presence', + data: { + nodeId: 'n-1', + action: 'open', + user: { userId: 'u-1', username: 'Alice' }, + clientId: 'socket-events', + timestamp: 11, + }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'sync_request', + data: {}, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'graph_resync_request', + data: {}, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'workflow_restore_request', + data: { versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice', graphData: { nodes: [], edges: [] } } as unknown as Record, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'workflow_restore_intent', + data: { versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } as unknown as Record, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'workflow_restore_complete', + data: { versionId: 'v1', success: true } as unknown as Record, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'workflow_history_action', + data: { action: 'undo' }, + } satisfies CollaborationUpdate) + socket.trigger('collaboration_update', { + ...baseUpdate, + type: 'workflow_history_action', + data: {}, + } satisfies CollaborationUpdate) + + expect(latestCursors).toMatchObject({ + 'u-1': { x: 1, y: 2, userId: 'u-1' }, + }) + expect(varsFeatureHandler).toHaveBeenCalledTimes(1) + expect(appStateHandler).toHaveBeenCalledTimes(1) + expect(appMetaHandler).toHaveBeenCalledTimes(1) + expect(appPublishHandler).toHaveBeenCalledTimes(1) + expect(mcpHandler).toHaveBeenCalledTimes(1) + expect(workflowUpdateHandler).toHaveBeenCalledWith({ appId: 'wf', timestamp: 9 }) + expect(commentsHandler).toHaveBeenCalledWith({ appId: 'wf', timestamp: 10 }) + expect(latestPresence).toMatchObject({ 'n-1': { 'socket-events': { userId: 'u-1' } } }) + expect(syncRequestHandler).toHaveBeenCalledTimes(1) + expect(broadcastSpy).toHaveBeenCalledTimes(1) + expect(restoreRequestHandler).toHaveBeenCalledTimes(1) + expect(restoreIntentHandler).toHaveBeenCalledWith({ versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } satisfies RestoreIntentData) + expect(restoreCompleteHandler).toHaveBeenCalledWith({ versionId: 'v1', success: true } satisfies RestoreCompleteData) + expect(historyHandler).toHaveBeenCalledWith({ action: 'undo', userId: 'u-1' }) + }) + + it('processes online_users/status/connect/disconnect/error socket events', () => { + const { manager, internals } = setupManagerWithDoc() + const socket = createMockSocket('socket-state') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const emitGraphResyncRequestSpy = vi.spyOn(internals, 'emitGraphResyncRequest').mockImplementation(() => undefined) + + internals.cursors = { + stale: { + x: 1, + y: 1, + userId: 'offline-user', + timestamp: 1, + }, + } + internals.nodePanelPresence = { + 'n-1': { + 'offline-client': { + userId: 'offline-user', + username: 'Offline', + clientId: 'offline-client', + timestamp: 1, + }, + }, + } + + internals.setupSocketEventListeners(socket as unknown as Socket) + + const onlineUsersHandler = vi.fn() + const leaderChangeHandler = vi.fn() + const stateChanges: Array<{ isConnected: boolean, disconnectReason?: string, error?: string }> = [] + manager.onOnlineUsersUpdate(onlineUsersHandler) + manager.onLeaderChange(leaderChangeHandler) + manager.onStateChange((state) => { + stateChanges.push(state as { isConnected: boolean, disconnectReason?: string, error?: string }) + }) + + socket.trigger('online_users', { users: 'invalid-structure' }) + expect(warnSpy).toHaveBeenCalled() + + socket.trigger('online_users', { + users: [{ + user_id: 'online-user', + username: 'Alice', + avatar: '', + sid: 'socket-state', + }], + leader: 'leader-1', + }) + + expect(onlineUsersHandler).toHaveBeenCalledWith([{ + user_id: 'online-user', + username: 'Alice', + avatar: '', + sid: 'socket-state', + } satisfies OnlineUser]) + expect(internals.cursors).toEqual({}) + expect(internals.nodePanelPresence).toEqual({}) + expect(internals.leaderId).toBe('leader-1') + + socket.trigger('status', { isLeader: 'invalid' }) + expect(warnSpy).toHaveBeenCalled() + + internals.pendingInitialSync = true + internals.isLeader = false + socket.trigger('status', { isLeader: false }) + expect(emitGraphResyncRequestSpy).toHaveBeenCalledTimes(1) + expect(internals.pendingInitialSync).toBe(false) + + socket.trigger('status', { isLeader: true }) + expect(leaderChangeHandler).toHaveBeenCalledWith(true) + + socket.trigger('connect') + socket.trigger('disconnect', 'transport close') + socket.trigger('connect_error', new Error('connect failed')) + socket.trigger('error', new Error('generic socket error')) + + expect(stateChanges).toEqual([ + { isConnected: true }, + { isConnected: false, disconnectReason: 'transport close' }, + { isConnected: false, error: 'connect failed' }, + ]) + expect(errorSpy).toHaveBeenCalled() + }) + + it('setupSubscriptions applies import updates and emits merged graph payload', () => { + const { manager, internals } = setupManagerWithDoc() + const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + const initialNode = { + ...createNode('n-1', 'Initial'), + data: { + ...createNode('n-1', 'Initial').data, + selected: false, + }, + } + const remoteNode = { + ...initialNode, + data: { + ...initialNode.data, + title: 'RemoteTitle', + }, + } + const edge = createEdge('e-1', 'n-1', 'n-2') + + manager.setNodes([], [initialNode]) + manager.setEdges([], [edge]) + manager.setNodes([initialNode], [remoteNode]) + + let reactFlowNodes: Node[] = [{ + ...initialNode, + data: { + ...initialNode.data, + selected: true, + _localMeta: 'keep-me', + }, + }] + let reactFlowEdges: Edge[] = [edge] + const setNodesSpy = vi.fn((nodes: Node[]) => { + reactFlowNodes = nodes + }) + const setEdgesSpy = vi.fn((edges: Edge[]) => { + reactFlowEdges = edges + }) + internals.reactFlowStore = { + getState: () => ({ + getNodes: () => reactFlowNodes, + setNodes: setNodesSpy, + getEdges: () => reactFlowEdges, + setEdges: setEdgesSpy, + }), + } + + let nodesSubscribeHandler: ((event: LoroSubscribeEvent) => void) | null = null + let edgesSubscribeHandler: ((event: LoroSubscribeEvent) => void) | null = null + vi.spyOn(internals.nodesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe') + .mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { + nodesSubscribeHandler = handler + }) + vi.spyOn(internals.edgesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe') + .mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { + edgesSubscribeHandler = handler + }) + + let importedGraph: { nodes: Node[], edges: Edge[] } | null = null + manager.onGraphImport((payload) => { + importedGraph = payload + }) + + internals.setupSubscriptions() + nodesSubscribeHandler?.({ by: 'local' }) + nodesSubscribeHandler?.({ by: 'import' }) + edgesSubscribeHandler?.({ by: 'import' }) + + expect(setNodesSpy).toHaveBeenCalled() + expect(setEdgesSpy).toHaveBeenCalled() + expect(importedGraph).not.toBeNull() + expect(importedGraph?.nodes[0]?.data).toMatchObject({ + title: 'RemoteTitle', + selected: true, + _localMeta: 'keep-me', + }) + + internals.pendingGraphImportEmit = true + internals.scheduleGraphImportEmit() + expect(internals.pendingGraphImportEmit).toBe(true) + + internals.reactFlowStore = null + nodesSubscribeHandler?.({ by: 'import' }) + + rafSpy.mockRestore() + }) + + it('respects diagnostic and anomaly log limits', () => { + const { internals } = setupManagerWithDoc() + const oldNode = createNode('old') + + for (let i = 0; i < 401; i += 1) { + internals.recordGraphSyncDiagnostic('nodes_subscribe', 'triggered', undefined, { index: i }) + } + for (let i = 0; i < 101; i += 1) { + internals.captureSetNodesAnomaly([oldNode], [], `source-${i}`) + } + + expect(internals.graphSyncDiagnostics).toHaveLength(400) + expect(internals.setNodesAnomalyLogs).toHaveLength(100) + + // no anomaly should be recorded when node count and start node invariants are unchanged + const beforeLength = internals.setNodesAnomalyLogs.length + internals.captureSetNodesAnomaly([oldNode], [createNode('old')], 'no-op') + expect(internals.setNodesAnomalyLogs).toHaveLength(beforeLength) + }) + + it('guards graph resync emission and graph snapshot broadcast', () => { + const { manager, internals } = setupManagerWithDoc() + const socket = createMockSocket('socket-resync') + const sendGraphEventSpy = vi.spyOn( + manager as unknown as { sendGraphEvent: (payload: Uint8Array) => void }, + 'sendGraphEvent', + ).mockImplementation(() => undefined) + + internals.currentAppId = null + vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false) + vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket) + internals.emitGraphResyncRequest() + expect(socket.emit).not.toHaveBeenCalled() + + internals.currentAppId = 'app-graph' + vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(true) + internals.emitGraphResyncRequest() + expect(socket.emit).toHaveBeenCalledWith( + 'collaboration_event', + expect.objectContaining({ type: 'graph_resync_request' }), + expect.any(Function), + ) + + internals.doc = null + internals.broadcastCurrentGraph() + expect(sendGraphEventSpy).not.toHaveBeenCalled() + + const doc = new LoroDoc() + internals.doc = doc + internals.nodesMap = doc.getMap('nodes') + internals.edgesMap = doc.getMap('edges') + internals.broadcastCurrentGraph() + expect(sendGraphEventSpy).not.toHaveBeenCalled() + + manager.setNodes([], [createNode('n-broadcast')]) + internals.broadcastCurrentGraph() + expect(sendGraphEventSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index 35a309902e..f7f05c62c8 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -3,13 +3,17 @@ import { act } from '@testing-library/react' import { createEdge, createNode } from '../../__tests__/fixtures' import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' import { renderWorkflowHook } from '../../__tests__/workflow-test-env' -import { BlockEnum } from '../../types' +import { collaborationManager } from '../../collaboration/core/collaboration-manager' +import { BlockEnum, ControlMode } from '../../types' import { useNodesInteractions } from '../use-nodes-interactions' const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) const mockUndo = vi.hoisted(() => vi.fn()) const mockRedo = vi.hoisted(() => vi.fn()) +const runtimeNodesMetaDataMap = vi.hoisted(() => ({ + value: {} as Record, +})) const runtimeState = vi.hoisted(() => ({ nodesReadOnly: false, @@ -45,7 +49,7 @@ vi.mock('../use-helpline', () => ({ vi.mock('../use-nodes-meta-data', () => ({ useNodesMetaData: () => ({ - nodesMap: {}, + nodesMap: runtimeNodesMetaDataMap.value, }), })) @@ -114,6 +118,7 @@ describe('useNodesInteractions', () => { ] rfState.nodes = currentNodes as unknown as typeof rfState.nodes rfState.edges = currentEdges as unknown as typeof rfState.edges + runtimeNodesMetaDataMap.value = {} }) it('persists node drags only when the node position actually changes', () => { @@ -202,4 +207,469 @@ describe('useNodesInteractions', () => { expect(rfState.setNodes).not.toHaveBeenCalled() expect(rfState.setEdges).not.toHaveBeenCalled() }) + + it('broadcasts history undo/redo actions to collaborators when connected', () => { + const historyNodes = [ + createNode({ + id: 'history-node-2', + data: { + type: BlockEnum.End, + title: 'History End', + desc: '', + }, + }), + ] + const historyEdges = [ + createEdge({ + id: 'history-edge-2', + source: 'history-node-2', + target: 'node-1', + }), + ] + const isConnectedSpy = vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true) + const emitHistoryActionSpy = vi.spyOn(collaborationManager, 'emitHistoryAction').mockImplementation(() => undefined) + const collabSetNodesSpy = vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => undefined) + const collabSetEdgesSpy = vi.spyOn(collaborationManager, 'setEdges').mockImplementation(() => undefined) + + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: historyNodes, + edges: historyEdges, + }, + }) + + act(() => { + result.current.handleHistoryBack() + result.current.handleHistoryForward() + }) + + expect(collabSetNodesSpy).toHaveBeenCalledTimes(2) + expect(collabSetEdgesSpy).toHaveBeenCalledTimes(2) + expect(emitHistoryActionSpy).toHaveBeenNthCalledWith(1, 'undo') + expect(emitHistoryActionSpy).toHaveBeenNthCalledWith(2, 'redo') + expect(isConnectedSpy).toHaveBeenCalled() + }) + + it('does not broadcast history changes when collaboration is disconnected', () => { + const historyNodes = [ + createNode({ + id: 'history-node-3', + data: { + type: BlockEnum.End, + title: 'History End', + desc: '', + }, + }), + ] + const historyEdges = [ + createEdge({ + id: 'history-edge-3', + source: 'history-node-3', + target: 'node-1', + }), + ] + vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(false) + const emitHistoryActionSpy = vi.spyOn(collaborationManager, 'emitHistoryAction').mockImplementation(() => undefined) + const collabSetNodesSpy = vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => undefined) + const collabSetEdgesSpy = vi.spyOn(collaborationManager, 'setEdges').mockImplementation(() => undefined) + + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: historyNodes, + edges: historyEdges, + }, + }) + + act(() => { + result.current.handleHistoryBack() + result.current.handleHistoryForward() + }) + + expect(collabSetNodesSpy).not.toHaveBeenCalled() + expect(collabSetEdgesSpy).not.toHaveBeenCalled() + expect(emitHistoryActionSpy).not.toHaveBeenCalled() + }) + + it('ignores node click selection in comment control mode', () => { + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + initialStoreState: { + controlMode: ControlMode.Comment, + }, + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + act(() => { + result.current.handleNodeClick({} as never, currentNodes[0] as Node) + }) + + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('updates entering states on node enter and clears them on leave using collaborative workflow state', () => { + currentNodes = [ + createNode({ + id: 'from-node', + data: { + type: BlockEnum.Code, + title: 'From', + desc: '', + }, + }), + createNode({ + id: 'to-node', + position: { x: 120, y: 120 }, + data: { + type: BlockEnum.Code, + title: 'To', + desc: '', + }, + }), + ] + currentEdges = [ + createEdge({ + id: 'edge-from-to', + source: 'from-node', + target: 'to-node', + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + store.setState({ + connectingNodePayload: { + nodeId: 'from-node', + handleType: 'source', + handleId: 'source', + } as never, + }) + + act(() => { + result.current.handleNodeEnter({} as never, currentNodes[1] as Node) + result.current.handleNodeLeave({} as never, currentNodes[1] as Node) + }) + + expect(rfState.setNodes).toHaveBeenCalled() + expect(rfState.setEdges).toHaveBeenCalled() + }) + + it('stores connecting payload from collaborative nodes when connect starts', () => { + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + act(() => { + result.current.handleNodeConnectStart( + {} as never, + { + nodeId: 'node-1', + handleType: 'source', + handleId: 'source', + } as never, + ) + }) + + expect(store.getState().connectingNodePayload).toMatchObject({ + nodeId: 'node-1', + handleType: 'source', + handleId: 'source', + }) + }) + + it('returns early for node add/change when metadata for node type is missing', () => { + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + act(() => { + result.current.handleNodeAdd( + { + nodeType: BlockEnum.Code, + }, + { prevNodeId: 'node-1' }, + ) + result.current.handleNodeChange('node-1', BlockEnum.Answer, 'source') + }) + + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('cancels selection state with collaborative nodes snapshot', () => { + currentNodes = [ + createNode({ + id: 'selected-node', + data: { + type: BlockEnum.Code, + title: 'Selected', + desc: '', + selected: true, + }, + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + act(() => { + result.current.handleNodesCancelSelected() + }) + + expect(rfState.setNodes).toHaveBeenCalledTimes(1) + const nodesArg = rfState.setNodes.mock.calls[0]?.[0] as Node[] + expect(nodesArg[0]?.data.selected).toBe(false) + }) + + it('skips clipboard copy when bundled/selected nodes have no metadata', () => { + currentNodes = [ + createNode({ + id: 'bundled-node', + data: { + type: BlockEnum.Code, + title: 'Bundled', + desc: '', + _isBundled: true, + }, + }), + createNode({ + id: 'selected-node', + position: { x: 100, y: 0 }, + data: { + type: BlockEnum.Code, + title: 'Selected', + desc: '', + selected: true, + }, + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + act(() => { + result.current.handleNodesCopy() + }) + + expect(store.getState().clipboardElements).toEqual([]) + }) + + it('loads nodes/edges from collaborative state for delete, disconnect, dim and undim actions', () => { + currentNodes = [ + createNode({ + id: 'node-a', + data: { + type: BlockEnum.Code, + title: 'A', + desc: '', + selected: true, + }, + }), + createNode({ + id: 'node-b', + position: { x: 160, y: 0 }, + data: { + type: BlockEnum.Code, + title: 'B', + desc: '', + }, + }), + ] + currentEdges = [ + createEdge({ + id: 'edge-a-b', + source: 'node-a', + target: 'node-b', + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + act(() => { + result.current.handleNodesDelete() + result.current.handleNodeDisconnect('node-a') + result.current.dimOtherNodes() + result.current.undimAllNodes() + }) + + expect(rfState.setNodes).toHaveBeenCalled() + expect(rfState.setEdges).toHaveBeenCalled() + }) + + it('reads collaborative nodes when dragging/selecting/connecting nodes', () => { + currentNodes = [ + createNode({ + id: 'drag-node-1', + data: { + type: BlockEnum.Code, + title: 'Drag1', + desc: '', + selected: true, + }, + }), + createNode({ + id: 'drag-node-2', + position: { x: 180, y: 0 }, + data: { + type: BlockEnum.Code, + title: 'Drag2', + desc: '', + }, + }), + ] + currentEdges = [ + createEdge({ + id: 'drag-edge', + source: 'drag-node-1', + target: 'drag-node-2', + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + store.setState({ + connectingNodePayload: { + nodeId: 'drag-node-1', + handleType: 'source', + handleId: 'source', + } as never, + enteringNodePayload: { + nodeId: 'drag-node-2', + } as never, + }) + + act(() => { + result.current.handleNodeDrag( + { stopPropagation: vi.fn() } as never, + currentNodes[0] as Node, + ) + result.current.handleNodeSelect('drag-node-2') + result.current.handleNodeConnect({ + source: 'drag-node-1', + target: 'drag-node-2', + sourceHandle: 'source', + targetHandle: 'target', + }) + result.current.handleNodeConnectEnd({ clientX: 0, clientY: 0 } as never) + }) + + expect(rfState.setNodes).toHaveBeenCalled() + expect(rfState.setEdges).toHaveBeenCalled() + }) + + it('uses metadata defaults during add/change/paste and reads collaborative nodes on resize', () => { + runtimeNodesMetaDataMap.value = { + [BlockEnum.Code]: { + defaultValue: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + }, + metaData: { + isSingleton: false, + }, + }, + [BlockEnum.Answer]: { + defaultValue: { + type: BlockEnum.Answer, + title: 'Answer', + desc: '', + }, + metaData: { + isSingleton: false, + }, + }, + } + + currentNodes = [ + createNode({ + id: 'meta-node-1', + data: { + type: BlockEnum.Code, + title: 'Meta', + desc: '', + selected: false, + }, + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = [] + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: [], + }, + }) + store.setState({ + clipboardElements: [ + createNode({ + id: 'clipboard-node', + data: { + type: BlockEnum.Code, + title: 'Clipboard', + desc: '', + }, + }), + ] as never, + mousePosition: { + pageX: 60, + pageY: 80, + } as never, + }) + + act(() => { + result.current.handleNodeAdd( + { nodeType: BlockEnum.Code }, + { prevNodeId: 'meta-node-1' }, + ) + result.current.handleNodeChange('meta-node-1', BlockEnum.Answer, 'source') + result.current.handleNodesPaste() + result.current.handleNodeResize('meta-node-1', { + x: 0, + y: 0, + width: 260, + height: 140, + } as never) + }) + + expect(rfState.setNodes).toHaveBeenCalled() + }) })