add ws manager unit tests

This commit is contained in:
hjlarry 2025-10-21 14:09:25 +08:00
parent 122033cadb
commit e299a1fb20
2 changed files with 329 additions and 1 deletions

View File

@ -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)
})
})

View File

@ -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<string, (...args: any[]) => 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' },
})
})
})