chore: improve codecov

This commit is contained in:
hjlarry 2026-04-11 18:12:27 +08:00
parent 295306a30d
commit 577ab01bbb
4 changed files with 1405 additions and 11 deletions

View File

@ -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<string, { x: number, y: number, userId: string, timestamp: number }>,
isConnected: false,
isEnabled: false,
}))
const collaborationListeners = vi.hoisted(() => ({
varsAndFeaturesUpdate: null as null | ((update: unknown) => void | Promise<void>),
workflowUpdate: null as null | (() => void | Promise<void>),
syncRequest: null as null | (() => void),
}))
let capturedContextProps: Record<string, unknown> | null = null
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate'> & {
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate' | 'cursors' | 'myUserId' | 'onlineUsers'> & {
hooksStore?: Record<string, unknown>
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<void>) => {
collaborationListeners.varsAndFeaturesUpdate = handler
return vi.fn()
}),
onWorkflowUpdate: mockOnWorkflowUpdate.mockImplementation((handler: () => void | Promise<void>) => {
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 (
<div data-testid="workflow-inner-context">
@ -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(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
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(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
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 },
})
})
})
})

View File

@ -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<typeof useStoreApi>,
}))
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<Record<string, unknown>>,
pendingComment: null as null | { elementX: number, elementY: number },
activeComment: null as null | Record<string, unknown>,
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: () => <div data-testid="comment-manager" />,
}))
vi.mock('../comment/cursor', () => ({
CommentCursor: () => <div data-testid="comment-cursor" />,
}))
vi.mock('../comment/comment-input', () => ({
CommentInput: ({ disabled, onCancel }: { disabled?: boolean, onCancel?: () => void }) => (
<button
type="button"
data-testid={disabled ? 'comment-input-preview' : 'comment-input-active'}
onClick={onCancel}
>
comment-input
</button>
),
}))
vi.mock('../comment/comment-icon', () => ({
CommentIcon: ({
comment,
onClick,
onPositionUpdate,
}: {
comment: { id: string }
onClick?: () => void
onPositionUpdate?: (position: { elementX: number, elementY: number }) => void
}) => (
<button
type="button"
data-testid={`comment-icon-${comment.id}`}
onClick={() => {
onClick?.()
onPositionUpdate?.({ elementX: 1, elementY: 2 })
}}
>
icon
</button>
),
}))
vi.mock('../comment/thread', () => ({
CommentThread: ({
onDelete,
onReplyDelete,
onNext,
}: {
onDelete?: () => void
onReplyDelete?: (replyId: string) => void
onNext?: () => void
}) => (
<div data-testid="comment-thread">
<button type="button" onClick={onDelete}>delete-thread</button>
<button type="button" onClick={() => onReplyDelete?.('reply-1')}>delete-reply</button>
<button type="button" onClick={onNext}>next-comment</button>
</div>
),
}))
vi.mock('../hooks/use-workflow-comment', () => ({
useWorkflowComment: () => workflowCommentState,
}))
vi.mock('../base/confirm', () => ({
default: ({
isShow,
onConfirm,
onCancel,
}: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
}) => isShow
? (
<div data-testid="confirm-dialog">
<button type="button" onClick={onConfirm}>confirm</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
)
: 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)
})
})
})

View File

@ -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<typeof vi.fn>
on: ReturnType<typeof vi.fn>
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<string, { x: number, y: number, userId: string, timestamp: number }>
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<string, unknown>,
) => 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<string, (...args: unknown[]) => 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<string, unknown> })
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<string, unknown> | 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<string, unknown>
})
const baseUpdate: Pick<CollaborationUpdate, 'userId' | 'timestamp'> = {
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<string, unknown>,
} satisfies CollaborationUpdate)
socket.trigger('collaboration_update', {
...baseUpdate,
type: 'workflow_restore_intent',
data: { versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } as unknown as Record<string, unknown>,
} satisfies CollaborationUpdate)
socket.trigger('collaboration_update', {
...baseUpdate,
type: 'workflow_restore_complete',
data: { versionId: 'v1', success: true } as unknown as Record<string, unknown>,
} 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)
})
})

View File

@ -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<string, unknown>,
}))
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()
})
})