mirror of
https://github.com/langgenius/dify.git
synced 2026-04-15 09:57:03 +08:00
chore: improve codecov
This commit is contained in:
parent
295306a30d
commit
577ab01bbb
@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user