diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts index 2ed19967c0..0f763740bd 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts @@ -1,7 +1,7 @@ import { LoroDoc } from 'loro-crdt' import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { BlockEnum } from '@/app/components/workflow/types' -import type { Node } from '@/app/components/workflow/types' +import type { Edge, Node } from '@/app/components/workflow/types' const NODE_ID = '1760342909316' @@ -362,4 +362,167 @@ describe('CollaborationManager syncNodes', () => { const final = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID) expect(getParameters(final!)).toEqual(editedParameters) }) + + it('handles nodes without data gracefully', () => { + const emptyNode: Node = { + id: 'empty-node', + type: 'custom', + position: { x: 0, y: 0 }, + data: undefined as any, + } + + ;(manager as any).syncNodes([], [deepClone(emptyNode)]) + + const stored = (manager.getNodes() as Node[]).find(node => node.id === 'empty-node') + expect(stored).toBeDefined() + expect(stored?.data).toEqual({}) + }) + + it('preserves CRDT list instances when synchronizing parsed state back into the manager', () => { + const promptManager = new CollaborationManager() + const doc = new LoroDoc() + ;(promptManager as any).doc = doc + ;(promptManager as any).nodesMap = doc.getMap('nodes') + ;(promptManager as any).edgesMap = doc.getMap('edges') + + const base = createLLMNodeSnapshot([ + { id: 'system', role: 'system', text: 'base' }, + ]) + ;(promptManager as any).syncNodes([], [deepClone(base)]) + + const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) + const firstTemplate = (storedBefore?.data as any).prompt_template?.[0] + expect(firstTemplate?.text).toBe('base') + + // simulate consumer mutating the plain JSON array and syncing back + const mutatedNode = deepClone(storedBefore!) + mutatedNode.data.prompt_template.push({ + id: 'user', + role: 'user', + text: 'mutated', + }) + + ;(promptManager as any).syncNodes([storedBefore], [mutatedNode]) + + const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) + const templatesAfter = (storedAfter?.data as any).prompt_template + expect(Array.isArray(templatesAfter)).toBe(true) + expect(templatesAfter).toHaveLength(2) + }) + + it('reuses CRDT list when syncing parameters repeatedly', () => { + const parameterManager = new CollaborationManager() + const doc = new LoroDoc() + ;(parameterManager as any).doc = doc + ;(parameterManager as any).nodesMap = doc.getMap('nodes') + ;(parameterManager as any).edgesMap = doc.getMap('edges') + + const initialParameters: ParameterItem[] = [ + { description: 'desc', name: 'param', required: false, type: 'string' }, + ] + const node = createParameterExtractorNodeSnapshot(initialParameters) + ;(parameterManager as any).syncNodes([], [deepClone(node)]) + + const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID)! + const mutatedNode = deepClone(stored) + mutatedNode.data.parameters[0].description = 'updated' + + ;(parameterManager as any).syncNodes([stored], [mutatedNode]) + + const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID)! + const params = (storedAfter.data as any).parameters + expect(params).toHaveLength(1) + expect(params[0].description).toBe('updated') + }) + + it('filters out transient/private data keys while keeping allowlisted ones', () => { + const nodeWithPrivate: Node = { + id: 'private-node', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.Start, + _foo: 'should disappear', + _children: ['child-a'], + selected: true, + variables: [], + }, + } + + ;(manager as any).syncNodes([], [deepClone(nodeWithPrivate)]) + + const stored = (manager.getNodes() as Node[]).find(node => node.id === 'private-node')! + expect((stored.data as any)._foo).toBeUndefined() + expect((stored.data as any)._children).toEqual(['child-a']) + expect((stored.data as any).selected).toBeUndefined() + }) + + it('removes list fields when they are omitted in the update snapshot', () => { + const baseNode = createNodeSnapshot(['alpha']) + ;(manager as any).syncNodes([], [deepClone(baseNode)]) + + const withoutVariables: Node = { + ...deepClone(baseNode), + data: { + ...deepClone(baseNode).data, + }, + } + delete (withoutVariables.data as any).variables + + ;(manager as any).syncNodes([deepClone(baseNode)], [withoutVariables]) + + const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)! + expect((stored.data as any).variables).toBeUndefined() + }) + + it('treats non-array list inputs as empty lists during synchronization', () => { + const promptManager = new CollaborationManager() + const doc = new LoroDoc() + ;(promptManager as any).doc = doc + ;(promptManager as any).nodesMap = doc.getMap('nodes') + ;(promptManager as any).edgesMap = doc.getMap('edges') + + const nodeWithInvalidTemplate = createLLMNodeSnapshot([] as any) + ;(promptManager as any).syncNodes([], [deepClone(nodeWithInvalidTemplate)]) + + const mutated = deepClone(nodeWithInvalidTemplate) + ;(mutated.data as any).prompt_template = 'not-an-array' + + ;(promptManager as any).syncNodes([deepClone(nodeWithInvalidTemplate)], [mutated]) + + const stored = promptManager.getNodes().find(node => node.id === LLM_NODE_ID)! + expect(Array.isArray((stored.data as any).prompt_template)).toBe(true) + expect((stored.data as any).prompt_template).toHaveLength(0) + }) + + it('updates edges map when edges are added, modified, and removed', () => { + const edgeManager = new CollaborationManager() + const doc = new LoroDoc() + ;(edgeManager as any).doc = doc + ;(edgeManager as any).nodesMap = doc.getMap('nodes') + ;(edgeManager as any).edgesMap = doc.getMap('edges') + + const edge: Edge = { + id: 'edge-1', + source: 'node-a', + target: 'node-b', + type: 'default', + data: { label: 'initial' }, + } as Edge + + ;(edgeManager as any).setEdges([], [edge]) + expect(edgeManager.getEdges()).toHaveLength(1) + expect((edgeManager.getEdges()[0].data as any).label).toBe('initial') + + const updatedEdge: Edge = { + ...edge, + data: { label: 'updated' }, + } + ;(edgeManager as any).setEdges([edge], [updatedEdge]) + expect(edgeManager.getEdges()).toHaveLength(1) + expect((edgeManager.getEdges()[0].data as any).label).toBe('updated') + + ;(edgeManager as any).setEdges([updatedEdge], []) + expect(edgeManager.getEdges()).toHaveLength(0) + }) }) diff --git a/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts b/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts new file mode 100644 index 0000000000..c7b26d1bd3 --- /dev/null +++ b/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts @@ -0,0 +1,165 @@ +import type { Socket } from 'socket.io-client' + +const ioMock = jest.fn() + +jest.mock('socket.io-client', () => ({ + io: (...args: any[]) => ioMock(...args), +})) + +const createMockSocket = (id: string): Socket & { + trigger: (event: string, ...args: any[]) => void +} => { + const handlers = new Map void>() + + const socket: any = { + id, + connected: true, + emit: jest.fn(), + disconnect: jest.fn(() => { + socket.connected = false + }), + on: jest.fn((event: string, handler: (...args: any[]) => void) => { + handlers.set(event, handler) + }), + trigger: (event: string, ...args: any[]) => { + const handler = handlers.get(event) + if (handler) + handler(...args) + }, + } + + return socket as Socket & { trigger: (event: string, ...args: any[]) => void } +} + +describe('WebSocketClient', () => { + let originalWindow: typeof window | undefined + + beforeEach(() => { + jest.resetModules() + ioMock.mockReset() + originalWindow = globalThis.window + }) + + afterEach(() => { + if (originalWindow) + globalThis.window = originalWindow + else + delete (globalThis as any).window + }) + + it('connects with fallback url and registers base listeners when window is undefined', async () => { + delete (globalThis as any).window + + const mockSocket = createMockSocket('socket-fallback') + ioMock.mockImplementation(() => mockSocket) + + const { WebSocketClient } = await import('../websocket-manager') + const client = new WebSocketClient() + const socket = client.connect('app-1') + + expect(ioMock).toHaveBeenCalledWith( + 'ws://localhost:5001', + expect.objectContaining({ + path: '/socket.io', + transports: ['websocket'], + withCredentials: true, + }), + ) + expect(socket).toBe(mockSocket) + expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function)) + expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function)) + expect(mockSocket.on).toHaveBeenCalledWith('connect_error', expect.any(Function)) + }) + + it('reuses existing connected socket and avoids duplicate connections', async () => { + const mockSocket = createMockSocket('socket-reuse') + ioMock.mockImplementation(() => mockSocket) + + const { WebSocketClient } = await import('../websocket-manager') + const client = new WebSocketClient() + + const first = client.connect('app-reuse') + const second = client.connect('app-reuse') + + expect(ioMock).toHaveBeenCalledTimes(1) + expect(second).toBe(first) + }) + + it('attaches auth token from localStorage and emits user_connect on connect', async () => { + const mockSocket = createMockSocket('socket-auth') + ioMock.mockImplementation((url, options) => { + expect(options.auth).toEqual({ token: 'secret-token' }) + return mockSocket + }) + + globalThis.window = { + location: { protocol: 'https:', host: 'example.com' }, + localStorage: { + getItem: jest.fn(() => 'secret-token'), + }, + } as unknown as typeof window + + const { WebSocketClient } = await import('../websocket-manager') + const client = new WebSocketClient() + client.connect('app-auth') + + const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1] as () => void + expect(connectHandler).toBeDefined() + connectHandler() + + expect(mockSocket.emit).toHaveBeenCalledWith('user_connect', { workflow_id: 'app-auth' }) + }) + + it('disconnects a specific app and clears internal maps', async () => { + const mockSocket = createMockSocket('socket-disconnect-one') + ioMock.mockImplementation(() => mockSocket) + + const { WebSocketClient } = await import('../websocket-manager') + const client = new WebSocketClient() + client.connect('app-disconnect') + + expect(client.isConnected('app-disconnect')).toBe(true) + client.disconnect('app-disconnect') + + expect(mockSocket.disconnect).toHaveBeenCalled() + expect(client.getSocket('app-disconnect')).toBeNull() + expect(client.isConnected('app-disconnect')).toBe(false) + }) + + it('disconnects all apps when no id is provided', async () => { + const socketA = createMockSocket('socket-a') + const socketB = createMockSocket('socket-b') + ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB) + + const { WebSocketClient } = await import('../websocket-manager') + const client = new WebSocketClient() + client.connect('app-a') + client.connect('app-b') + + client.disconnect() + + expect(socketA.disconnect).toHaveBeenCalled() + expect(socketB.disconnect).toHaveBeenCalled() + expect(client.getConnectedApps()).toEqual([]) + }) + + it('reports connected apps, sockets, and debug info correctly', async () => { + const socketA = createMockSocket('socket-debug-a') + const socketB = createMockSocket('socket-debug-b') + socketB.connected = false + ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB) + + const { WebSocketClient } = await import('../websocket-manager') + const client = new WebSocketClient() + client.connect('app-a') + client.connect('app-b') + + expect(client.getConnectedApps()).toEqual(['app-a']) + + const debugInfo = client.getDebugInfo() + expect(debugInfo).toMatchObject({ + 'app-a': { connected: true, socketId: 'socket-debug-a' }, + 'app-b': { connected: false, socketId: 'socket-debug-b' }, + }) + }) +})