diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts index 2ad919fae2..93d1c6b746 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts @@ -1,3 +1,4 @@ +import type { LoroMap } from 'loro-crdt' import type { Node } from '@/app/components/workflow/types' import { LoroDoc } from 'loro-crdt' import { BlockEnum } from '@/app/components/workflow/types' @@ -7,7 +8,79 @@ const NODE_ID = 'node-1' const LLM_NODE_ID = 'llm-node' const PARAM_NODE_ID = 'parameter-node' -const createNode = (variables: string[]): Node> => ({ +type WorkflowVariable = { + variable: string + label: string + type: string + required: boolean + default: string + max_length: number + placeholder: string + options: string[] + hint: string +} + +type PromptTemplateItem = { + id: string + role: string + text: string +} + +type ParameterItem = { + description: string + name: string + required: boolean + type: string +} + +type StartNodeData = { + variables: WorkflowVariable[] +} + +type LLMNodeData = { + model: { + mode: string + name: string + provider: string + completion_params: { + temperature: number + } + } + context: { + enabled: boolean + variable_selector: string[] + } + vision: { + enabled: boolean + } + prompt_template: PromptTemplateItem[] +} + +type ParameterExtractorNodeData = { + model: { + mode: string + name: string + provider: string + completion_params: { + temperature: number + } + } + parameters: ParameterItem[] + query: unknown[] + reasoning_mode: string + vision: { + enabled: boolean + } +} + +type CollaborationManagerInternals = { + doc: LoroDoc + nodesMap: LoroMap + edgesMap: LoroMap + syncNodes: (oldNodes: Node[], newNodes: Node[]) => void +} + +const createNode = (variables: string[]): Node => ({ id: NODE_ID, type: 'custom', position: { x: 0, y: 0 }, @@ -29,7 +102,7 @@ const createNode = (variables: string[]): Node> => ({ }, }) -const createLLMNode = (templates: Array<{ id: string, role: string, text: string }>): Node> => ({ +const createLLMNode = (templates: PromptTemplateItem[]): Node => ({ id: LLM_NODE_ID, type: 'custom', position: { x: 200, y: 200 }, @@ -57,7 +130,7 @@ const createLLMNode = (templates: Array<{ id: string, role: string, text: string }, }) -const createParameterExtractorNode = (parameters: Array<{ description: string, name: string, required: boolean, type: string }>): Node> => ({ +const createParameterExtractorNode = (parameters: ParameterItem[]): Node => ({ id: PARAM_NODE_ID, type: 'custom', position: { x: 400, y: 120 }, @@ -83,18 +156,23 @@ const createParameterExtractorNode = (parameters: Array<{ description: string, n }, }) +const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals => + manager as unknown as CollaborationManagerInternals + const getManager = (doc: LoroDoc) => { const manager = new CollaborationManager() - ;(manager as any).doc = doc - ;(manager as any).nodesMap = doc.getMap('nodes') - ;(manager as any).edgesMap = doc.getMap('edges') + const internals = getManagerInternals(manager) + internals.doc = doc + internals.nodesMap = doc.getMap('nodes') + internals.edgesMap = doc.getMap('edges') return manager } const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) const syncNodes = (manager: CollaborationManager, previous: Node[], next: Node[]) => { - ;(manager as any).syncNodes(previous, next) + const internals = getManagerInternals(manager) + internals.syncNodes(previous, next) } const exportNodes = (manager: CollaborationManager) => manager.getNodes() @@ -121,10 +199,6 @@ describe('Loro merge behavior smoke test', () => { const finalA = exportNodes(managerA) const finalB = exportNodes(managerB) - - console.log('Final nodes on docA:', JSON.stringify(finalA, null, 2)) - - console.log('Final nodes on docB:', JSON.stringify(finalB, null, 2)) expect(finalA.length).toBe(1) expect(finalB.length).toBe(1) }) @@ -171,8 +245,8 @@ describe('Loro merge behavior smoke test', () => { const updateForB = docA.export({ mode: 'update', from: docB.version() }) docB.import(updateForB) - const finalA = exportNodes(managerA).find(node => node.id === LLM_NODE_ID) - const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) + const finalA = exportNodes(managerA).find(node => node.id === LLM_NODE_ID) as Node | undefined + const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) as Node | undefined expect(finalA).toBeDefined() expect(finalB).toBeDefined() @@ -190,8 +264,8 @@ describe('Loro merge behavior smoke test', () => { }, ] - expect((finalA!.data as any).prompt_template).toEqual(expectedTemplates) - expect((finalB!.data as any).prompt_template).toEqual(expectedTemplates) + expect(finalA!.data.prompt_template).toEqual(expectedTemplates) + expect(finalB!.data.prompt_template).toEqual(expectedTemplates) }) it('converges when parameter lists are edited concurrently', () => { @@ -213,13 +287,21 @@ describe('Loro merge behavior smoke test', () => { { description: 'dd', name: 'cc', required: false, type: 'string' }, { description: 'new from A', name: 'ee', required: false, type: 'number' }, ] - syncNodes(managerA, [createParameterExtractorNode(deepClone(baseParameters))], [createParameterExtractorNode(deepClone(docAUpdate))]) + syncNodes( + managerA, + [createParameterExtractorNode(deepClone(baseParameters))], + [createParameterExtractorNode(deepClone(docAUpdate))], + ) const docBUpdate = [ { description: 'bb', name: 'aa', required: false, type: 'string' }, { description: 'dd updated by B', name: 'cc', required: true, type: 'string' }, ] - syncNodes(managerB, [createParameterExtractorNode(deepClone(baseParameters))], [createParameterExtractorNode(deepClone(docBUpdate))]) + syncNodes( + managerB, + [createParameterExtractorNode(deepClone(baseParameters))], + [createParameterExtractorNode(deepClone(docBUpdate))], + ) const updateForA = docB.export({ mode: 'update', from: docA.version() }) docA.import(updateForA) @@ -227,8 +309,12 @@ describe('Loro merge behavior smoke test', () => { const updateForB = docA.export({ mode: 'update', from: docB.version() }) docB.import(updateForB) - const finalA = exportNodes(managerA).find(node => node.id === PARAM_NODE_ID) - const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) + const finalA = exportNodes(managerA).find(node => node.id === PARAM_NODE_ID) as + | Node + | undefined + const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) as + | Node + | undefined expect(finalA).toBeDefined() expect(finalB).toBeDefined() @@ -239,7 +325,7 @@ describe('Loro merge behavior smoke test', () => { { description: 'new from A', name: 'ee', required: false, type: 'number' }, ] - expect((finalA!.data as any).parameters).toEqual(expectedParameters) - expect((finalB!.data as any).parameters).toEqual(expectedParameters) + expect(finalA!.data.parameters).toEqual(expectedParameters) + expect(finalB!.data.parameters).toEqual(expectedParameters) }) }) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts index b7ee604028..1728bcad55 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts @@ -1,5 +1,9 @@ -import type { NodePanelPresenceMap, NodePanelPresenceUser } from '@/app/components/workflow/collaboration/types/collaboration' -import type { Edge, Node } from '@/app/components/workflow/types' +import type { LoroMap } from 'loro-crdt' +import type { + NodePanelPresenceMap, + NodePanelPresenceUser, +} from '@/app/components/workflow/collaboration/types/collaboration' +import type { CommonNodeType, Edge, Node } from '@/app/components/workflow/types' import { LoroDoc } from 'loro-crdt' import { Position } from 'reactflow' import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' @@ -32,6 +36,72 @@ type ParameterItem = { type: string } +type NodePanelPresenceEventData = { + nodeId: string + action: 'open' | 'close' + user: NodePanelPresenceUser + clientId: string + timestamp?: number +} + +type StartNodeData = { + variables: WorkflowVariable[] +} + +type LLMNodeData = { + context: { + enabled: boolean + variable_selector: string[] + } + model: { + mode: string + name: string + provider: string + completion_params: { + temperature: number + } + } + prompt_template: PromptTemplateItem[] + vision: { + enabled: boolean + } +} + +type ParameterExtractorNodeData = { + model: { + mode: string + name: string + provider: string + completion_params: { + temperature: number + } + } + parameters: ParameterItem[] + query: unknown[] + reasoning_mode: string + vision: { + enabled: boolean + } +} + +type LLMNodeDataWithUnknownTemplate = Omit & { + prompt_template: unknown +} + +type ManagerDoc = LoroDoc | { commit: () => void } + +type CollaborationManagerInternals = { + doc: ManagerDoc + nodesMap: LoroMap + edgesMap: LoroMap + syncNodes: (oldNodes: Node[], newNodes: Node[]) => void + syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void + applyNodePanelPresenceUpdate: (update: NodePanelPresenceEventData) => void + forceDisconnect: () => void + activeConnections: Set + isUndoRedoInProgress: boolean +} + const createVariable = (name: string, overrides: Partial = {}): WorkflowVariable => ({ default: '', hint: '', @@ -47,7 +117,7 @@ const createVariable = (name: string, overrides: Partial = {}) const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) -const createNodeSnapshot = (variableNames: string[]): Node<{ variables: WorkflowVariable[] }> => ({ +const createNodeSnapshot = (variableNames: string[]): Node => ({ id: NODE_ID, type: 'custom', position: { x: 0, y: 24 }, @@ -71,7 +141,7 @@ const createNodeSnapshot = (variableNames: string[]): Node<{ variables: Workflow const LLM_NODE_ID = 'llm-node' const PARAM_NODE_ID = 'param-extractor-node' -const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node> => ({ +const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node => ({ id: LLM_NODE_ID, type: 'custom', position: { x: 200, y: 120 }, @@ -107,7 +177,7 @@ const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node> => ({ +const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node => ({ id: PARAM_NODE_ID, type: 'custom', position: { x: 420, y: 220 }, @@ -142,43 +212,58 @@ const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node }) const getVariables = (node: Node): string[] => { - const variables = (node.data as any)?.variables ?? [] - return variables.map((item: WorkflowVariable) => item.variable) + const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }> + const variables = data.variables ?? [] + return variables.map(item => item.variable) } const getVariableObject = (node: Node, name: string): WorkflowVariable | undefined => { - const variables = (node.data as any)?.variables ?? [] - return variables.find((item: WorkflowVariable) => item.variable === name) + const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }> + const variables = data.variables ?? [] + return variables.find(item => item.variable === name) } const getPromptTemplates = (node: Node): PromptTemplateItem[] => { - return ((node.data as any)?.prompt_template ?? []) as PromptTemplateItem[] + const data = node.data as CommonNodeType<{ prompt_template?: PromptTemplateItem[] }> + return data.prompt_template ?? [] } const getParameters = (node: Node): ParameterItem[] => { - return ((node.data as any)?.parameters ?? []) as ParameterItem[] + const data = node.data as CommonNodeType<{ parameters?: ParameterItem[] }> + return data.parameters ?? [] +} + +const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals => + manager as unknown as CollaborationManagerInternals + +const setupManager = (): { manager: CollaborationManager, internals: CollaborationManagerInternals } => { + 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 syncNodes', () => { let manager: CollaborationManager + let internals: CollaborationManagerInternals beforeEach(() => { - manager = new CollaborationManager() - // Bypass private guards for targeted unit testing - const doc = new LoroDoc() - ;(manager as any).doc = doc - ;(manager as any).nodesMap = doc.getMap('nodes') - ;(manager as any).edgesMap = doc.getMap('edges') + const setup = setupManager() + manager = setup.manager + internals = setup.internals const initialNode = createNodeSnapshot(['a']) - ;(manager as any).syncNodes([], [deepClone(initialNode)]) + internals.syncNodes([], [deepClone(initialNode)]) }) it('updates collaborators map when a single client adds a variable', () => { const base = [createNodeSnapshot(['a'])] const next = [createNodeSnapshot(['a', 'b'])] - ;(manager as any).syncNodes(base, next) + internals.syncNodes(base, next) const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) expect(stored).toBeDefined() @@ -190,12 +275,12 @@ describe('CollaborationManager syncNodes', () => { const userA = [createNodeSnapshot(['a', 'b'])] const userB = [createNodeSnapshot(['a', 'c'])] - ;(manager as any).syncNodes(base, userA) + internals.syncNodes(base, userA) const afterUserA = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) expect(getVariables(afterUserA!)).toEqual(['a', 'b']) - ;(manager as any).syncNodes(base, userB) + internals.syncNodes(base, userB) const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) const finalVariables = getVariables(finalNode!) @@ -228,8 +313,8 @@ describe('CollaborationManager syncNodes', () => { }, ] - ;(manager as any).syncNodes(base, userA) - ;(manager as any).syncNodes(base, userB) + internals.syncNodes(base, userA) + internals.syncNodes(base, userB) const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) const finalVariable = getVariableObject(finalNode!, 'a') @@ -240,7 +325,7 @@ describe('CollaborationManager syncNodes', () => { it('reflects the last writer when concurrent removal and edits happen', () => { const base = [createNodeSnapshot(['a', 'b'])] - ;(manager as any).syncNodes([], [deepClone(base[0])]) + internals.syncNodes([], [deepClone(base[0])]) const userA = [ { ...createNodeSnapshot(['a']), @@ -265,8 +350,8 @@ describe('CollaborationManager syncNodes', () => { }, ] - ;(manager as any).syncNodes(base, userA) - ;(manager as any).syncNodes(base, userB) + internals.syncNodes(base, userA) + internals.syncNodes(base, userB) const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) const finalVariables = getVariables(finalNode!) @@ -275,11 +360,7 @@ describe('CollaborationManager syncNodes', () => { }) it('synchronizes prompt_template list updates across collaborators', () => { - 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 { manager: promptManager, internals: promptInternals } = setupManager() const baseTemplate = [ { @@ -290,7 +371,7 @@ describe('CollaborationManager syncNodes', () => { ] const baseNode = createLLMNodeSnapshot(baseTemplate) - ;(promptManager as any).syncNodes([], [deepClone(baseNode)]) + promptInternals.syncNodes([], [deepClone(baseNode)]) const updatedTemplates = [ ...baseTemplate, @@ -302,7 +383,7 @@ describe('CollaborationManager syncNodes', () => { ] const updatedNode = createLLMNodeSnapshot(updatedTemplates) - ;(promptManager as any).syncNodes([deepClone(baseNode)], [deepClone(updatedNode)]) + promptInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)]) const stored = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID) expect(stored).toBeDefined() @@ -321,7 +402,7 @@ describe('CollaborationManager syncNodes', () => { ] const editedNode = createLLMNodeSnapshot(editedTemplates) - ;(promptManager as any).syncNodes([deepClone(updatedNode)], [deepClone(editedNode)]) + promptInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)]) const final = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID) const finalTemplates = getPromptTemplates(final!) @@ -330,11 +411,7 @@ describe('CollaborationManager syncNodes', () => { }) it('keeps parameter list in sync when nodes add, edit, or remove parameters', () => { - 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 { manager: parameterManager, internals: parameterInternals } = setupManager() const baseParameters: ParameterItem[] = [ { description: 'bb', name: 'aa', required: false, type: 'string' }, @@ -342,7 +419,7 @@ describe('CollaborationManager syncNodes', () => { ] const baseNode = createParameterExtractorNodeSnapshot(baseParameters) - ;(parameterManager as any).syncNodes([], [deepClone(baseNode)]) + parameterInternals.syncNodes([], [deepClone(baseNode)]) const updatedParameters: ParameterItem[] = [ ...baseParameters, @@ -350,7 +427,7 @@ describe('CollaborationManager syncNodes', () => { ] const updatedNode = createParameterExtractorNodeSnapshot(updatedParameters) - ;(parameterManager as any).syncNodes([deepClone(baseNode)], [deepClone(updatedNode)]) + parameterInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)]) const stored = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID) expect(stored).toBeDefined() @@ -361,7 +438,7 @@ describe('CollaborationManager syncNodes', () => { ] const editedNode = createParameterExtractorNodeSnapshot(editedParameters) - ;(parameterManager as any).syncNodes([deepClone(updatedNode)], [deepClone(editedNode)]) + parameterInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)]) const final = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID) expect(getParameters(final!)).toEqual(editedParameters) @@ -372,10 +449,10 @@ describe('CollaborationManager syncNodes', () => { id: 'empty-node', type: 'custom', position: { x: 0, y: 0 }, - data: undefined as any, + data: undefined as unknown as CommonNodeType>, } - ;(manager as any).syncNodes([], [deepClone(emptyNode)]) + internals.syncNodes([], [deepClone(emptyNode)]) const stored = (manager.getNodes() as Node[]).find(node => node.id === 'empty-node') expect(stored).toBeDefined() @@ -383,64 +460,60 @@ describe('CollaborationManager syncNodes', () => { }) 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 { manager: promptManager, internals: promptInternals } = setupManager() const base = createLLMNodeSnapshot([ { id: 'system', role: 'system', text: 'base' }, ]) - ;(promptManager as any).syncNodes([], [deepClone(base)]) + promptInternals.syncNodes([], [deepClone(base)]) - const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node> | undefined - const firstTemplate = (storedBefore?.data as any).prompt_template?.[0] + const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node | undefined + expect(storedBefore).toBeDefined() + const firstTemplate = storedBefore?.data.prompt_template?.[0] expect(firstTemplate?.text).toBe('base') // simulate consumer mutating the plain JSON array and syncing back - const mutatedNode = deepClone(storedBefore!) as Node> + const baseNode = storedBefore! + const mutatedNode = deepClone(baseNode) mutatedNode.data.prompt_template.push({ id: 'user', role: 'user', text: 'mutated', }) - ;(promptManager as any).syncNodes([storedBefore], [mutatedNode]) + promptInternals.syncNodes([baseNode], [mutatedNode]) - const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) - const templatesAfter = (storedAfter?.data as any).prompt_template + const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node | undefined + const templatesAfter = storedAfter?.data.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 { manager: parameterManager, internals: parameterInternals } = setupManager() const initialParameters: ParameterItem[] = [ { description: 'desc', name: 'param', required: false, type: 'string' }, ] const node = createParameterExtractorNodeSnapshot(initialParameters) - ;(parameterManager as any).syncNodes([], [deepClone(node)]) + parameterInternals.syncNodes([], [deepClone(node)]) - const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as Node> - const mutatedNode = deepClone(stored) as Node> + const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as Node + const mutatedNode = deepClone(stored) mutatedNode.data.parameters[0].description = 'updated' - ;(parameterManager as any).syncNodes([stored], [mutatedNode]) + parameterInternals.syncNodes([stored], [mutatedNode]) - const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID)! - const params = (storedAfter.data as any).parameters + const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as + | Node + | undefined + const params = storedAfter?.data.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> = { + const nodeWithPrivate: Node<{ _foo: string, variables: WorkflowVariable[] }> = { id: 'private-node', type: 'custom', position: { x: 0, y: 0 }, @@ -455,58 +528,52 @@ describe('CollaborationManager syncNodes', () => { }, } - ;(manager as any).syncNodes([], [deepClone(nodeWithPrivate)]) + internals.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([{ nodeId: 'child-a', nodeType: BlockEnum.Start }]) - expect((stored.data as any).selected).toBeUndefined() + const storedData = stored.data as CommonNodeType<{ _foo?: string }> + expect(storedData._foo).toBeUndefined() + expect(storedData._children).toEqual([{ nodeId: 'child-a', nodeType: BlockEnum.Start }]) + expect(storedData.selected).toBeUndefined() }) it('removes list fields when they are omitted in the update snapshot', () => { const baseNode = createNodeSnapshot(['alpha']) - ;(manager as any).syncNodes([], [deepClone(baseNode)]) + internals.syncNodes([], [deepClone(baseNode)]) - const withoutVariables: Node = { + const withoutVariables: Node = { ...deepClone(baseNode), data: { ...deepClone(baseNode).data, }, } - delete (withoutVariables.data as any).variables + delete (withoutVariables.data as CommonNodeType<{ variables?: WorkflowVariable[] }>).variables - ;(manager as any).syncNodes([deepClone(baseNode)], [withoutVariables]) + internals.syncNodes([deepClone(baseNode)], [withoutVariables]) const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)! - expect((stored.data as any).variables).toBeUndefined() + const storedData = stored.data as CommonNodeType<{ variables?: WorkflowVariable[] }> + expect(storedData.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 { manager: promptManager, internals: promptInternals } = setupManager() - const nodeWithInvalidTemplate = createLLMNodeSnapshot([] as any) - ;(promptManager as any).syncNodes([], [deepClone(nodeWithInvalidTemplate)]) + const nodeWithInvalidTemplate = createLLMNodeSnapshot([]) + promptInternals.syncNodes([], [deepClone(nodeWithInvalidTemplate)]) - const mutated = deepClone(nodeWithInvalidTemplate) - ;(mutated.data as any).prompt_template = 'not-an-array' + const mutated = deepClone(nodeWithInvalidTemplate) as Node + mutated.data.prompt_template = 'not-an-array' - ;(promptManager as any).syncNodes([deepClone(nodeWithInvalidTemplate)], [mutated]) + promptInternals.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) + const stored = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node + expect(Array.isArray(stored.data.prompt_template)).toBe(true) + expect(stored.data.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 { manager: edgeManager } = setupManager() const edge: Edge = { id: 'edge-1', @@ -520,7 +587,7 @@ describe('CollaborationManager syncNodes', () => { }, } - ;(edgeManager as any).setEdges([], [edge]) + edgeManager.setEdges([], [edge]) expect(edgeManager.getEdges()).toHaveLength(1) const storedEdge = edgeManager.getEdges()[0]! expect(storedEdge.data).toBeDefined() @@ -534,19 +601,20 @@ describe('CollaborationManager syncNodes', () => { _waitingRun: true, }, } - ;(edgeManager as any).setEdges([edge], [updatedEdge]) + edgeManager.setEdges([edge], [updatedEdge]) expect(edgeManager.getEdges()).toHaveLength(1) const updatedStoredEdge = edgeManager.getEdges()[0]! expect(updatedStoredEdge.data).toBeDefined() expect(updatedStoredEdge.data!._waitingRun).toBe(true) - ;(edgeManager as any).setEdges([updatedEdge], []) + edgeManager.setEdges([updatedEdge], []) expect(edgeManager.getEdges()).toHaveLength(0) }) }) describe('CollaborationManager public API wrappers', () => { let manager: CollaborationManager + let internals: CollaborationManagerInternals const baseNodes: Node[] = [] const updatedNodes: Node[] = [ { @@ -576,12 +644,13 @@ describe('CollaborationManager public API wrappers', () => { beforeEach(() => { manager = new CollaborationManager() + internals = getManagerInternals(manager) }) it('setNodes delegates to syncNodes and commits the CRDT document', () => { const commit = vi.fn() - ;(manager as any).doc = { commit } - const syncSpy = vi.spyOn(manager as any, 'syncNodes').mockImplementation(() => undefined) + internals.doc = { commit } + const syncSpy = vi.spyOn(internals, 'syncNodes').mockImplementation(() => undefined) manager.setNodes(baseNodes, updatedNodes) @@ -592,9 +661,9 @@ describe('CollaborationManager public API wrappers', () => { it('setNodes skips syncing when undo/redo replay is running', () => { const commit = vi.fn() - ;(manager as any).doc = { commit } - ;(manager as any).isUndoRedoInProgress = true - const syncSpy = vi.spyOn(manager as any, 'syncNodes').mockImplementation(() => undefined) + internals.doc = { commit } + internals.isUndoRedoInProgress = true + const syncSpy = vi.spyOn(internals, 'syncNodes').mockImplementation(() => undefined) manager.setNodes(baseNodes, updatedNodes) @@ -605,8 +674,8 @@ describe('CollaborationManager public API wrappers', () => { it('setEdges delegates to syncEdges and commits the CRDT document', () => { const commit = vi.fn() - ;(manager as any).doc = { commit } - const syncSpy = vi.spyOn(manager as any, 'syncEdges').mockImplementation(() => undefined) + internals.doc = { commit } + const syncSpy = vi.spyOn(internals, 'syncEdges').mockImplementation(() => undefined) manager.setEdges(baseEdges, updatedEdges) @@ -616,9 +685,9 @@ describe('CollaborationManager public API wrappers', () => { }) it('disconnect tears down the collaboration state only when last connection closes', () => { - const forceSpy = vi.spyOn(manager as any, 'forceDisconnect').mockImplementation(() => undefined) - ;(manager as any).activeConnections.add('conn-a') - ;(manager as any).activeConnections.add('conn-b') + const forceSpy = vi.spyOn(internals, 'forceDisconnect').mockImplementation(() => undefined) + internals.activeConnections.add('conn-a') + internals.activeConnections.add('conn-b') manager.disconnect('conn-a') expect(forceSpy).not.toHaveBeenCalled() @@ -636,7 +705,7 @@ describe('CollaborationManager public API wrappers', () => { const user: NodePanelPresenceUser = { userId: 'user-1', username: 'Dana' } - ;(manager as any).applyNodePanelPresenceUpdate({ + internals.applyNodePanelPresenceUpdate({ nodeId: 'node-a', action: 'open', user, @@ -644,7 +713,7 @@ describe('CollaborationManager public API wrappers', () => { timestamp: 100, }) - ;(manager as any).applyNodePanelPresenceUpdate({ + internals.applyNodePanelPresenceUpdate({ nodeId: 'node-b', action: 'open', user, @@ -673,7 +742,7 @@ describe('CollaborationManager public API wrappers', () => { const user: NodePanelPresenceUser = { userId: 'user-2', username: 'Kai' } - ;(manager as any).applyNodePanelPresenceUpdate({ + internals.applyNodePanelPresenceUpdate({ nodeId: 'node-a', action: 'open', user, @@ -681,7 +750,7 @@ describe('CollaborationManager public API wrappers', () => { timestamp: 300, }) - ;(manager as any).applyNodePanelPresenceUpdate({ + internals.applyNodePanelPresenceUpdate({ nodeId: 'node-a', action: 'close', user, diff --git a/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts b/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts index 336d87479a..5b07c032ab 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts @@ -1,47 +1,63 @@ +import type { LoroDoc } from 'loro-crdt' import type { Socket } from 'socket.io-client' import { CRDTProvider } from '../crdt-provider' +type FakeDocEvent = { + by: string +} + type FakeDoc = { export: ReturnType import: ReturnType subscribe: ReturnType - trigger: (event: any) => void + trigger: (event: FakeDocEvent) => void } const createFakeDoc = (): FakeDoc => { - let handler: ((payload: any) => void) | null = null + let handler: ((payload: FakeDocEvent) => void) | null = null + + const exportFn = vi.fn(() => new Uint8Array([1, 2, 3])) + const importFn = vi.fn() + const subscribeFn = vi.fn((cb: (payload: FakeDocEvent) => void) => { + handler = cb + }) return { - export: vi.fn(() => new Uint8Array([1, 2, 3])), - import: vi.fn(), - subscribe: vi.fn((cb: (payload: any) => void) => { - handler = cb - }), - trigger: (event: any) => { + export: exportFn, + import: importFn, + subscribe: subscribeFn, + trigger: (event: FakeDocEvent) => { handler?.(event) }, } } -const createMockSocket = () => { - const handlers = new Map void>() +type MockSocket = { + trigger: (event: string, ...args: unknown[]) => void + emit: ReturnType + on: ReturnType + off: ReturnType +} - const socket: any = { +const createMockSocket = (): MockSocket => { + const handlers = new Map void>() + + const socket: MockSocket = { emit: vi.fn(), - on: vi.fn((event: string, handler: (...args: any[]) => void) => { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { handlers.set(event, handler) }), off: vi.fn((event: string) => { handlers.delete(event) }), - trigger: (event: string, ...args: any[]) => { + trigger: (event: string, ...args: unknown[]) => { const handler = handlers.get(event) if (handler) handler(...args) }, } - return socket as Socket & { trigger: (event: string, ...args: any[]) => void } + return socket } describe('CRDTProvider', () => { @@ -49,7 +65,7 @@ describe('CRDTProvider', () => { const doc = createFakeDoc() const socket = createMockSocket() - const provider = new CRDTProvider(socket, doc as unknown as any) + const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc) expect(provider).toBeInstanceOf(CRDTProvider) doc.trigger({ by: 'local' }) @@ -65,7 +81,7 @@ describe('CRDTProvider', () => { const doc = createFakeDoc() const socket = createMockSocket() - const provider = new CRDTProvider(socket, doc as unknown as any) + const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc) doc.trigger({ by: 'remote' }) @@ -77,7 +93,7 @@ describe('CRDTProvider', () => { const doc = createFakeDoc() const socket = createMockSocket() - const provider = new CRDTProvider(socket, doc as unknown as any) + const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc) const payload = new Uint8Array([9, 9, 9]) socket.trigger('graph_update', payload) @@ -91,7 +107,7 @@ describe('CRDTProvider', () => { const doc = createFakeDoc() const socket = createMockSocket() - const provider = new CRDTProvider(socket, doc as unknown as any) + const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc) provider.destroy() expect(socket.off).toHaveBeenCalledWith('graph_update') @@ -104,7 +120,7 @@ describe('CRDTProvider', () => { throw new Error('boom') }) - const provider = new CRDTProvider(socket, doc as unknown as any) + const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) 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 index 7006e72174..b41845702a 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts @@ -1,39 +1,53 @@ -import type { Socket } from 'socket.io-client' +type MockSocket = { + trigger: (event: string, ...args: unknown[]) => void + emit: ReturnType + on: ReturnType + disconnect: ReturnType + connected: boolean +} + +type IoOptions = { + auth?: { token?: string } + path?: string + transports?: string[] + withCredentials?: boolean +} const ioMock = vi.hoisted(() => vi.fn()) vi.mock('socket.io-client', () => ({ - io: (...args: any[]) => ioMock(...args), + io: (...args: Parameters) => ioMock(...args), })) -type MockSocket = Socket & { - trigger: (event: string, ...args: any[]) => void - emit: ReturnType - on: ReturnType - disconnect: ReturnType -} - const createMockSocket = (id: string): MockSocket => { - const handlers = new Map void>() + const handlers = new Map void>() - const socket: any = { + const socket: MockSocket & { id: string } = { id, connected: true, emit: vi.fn(), disconnect: vi.fn(() => { socket.connected = false }), - on: vi.fn((event: string, handler: (...args: any[]) => void) => { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { handlers.set(event, handler) }), - trigger: (event: string, ...args: any[]) => { + trigger: (event: string, ...args: unknown[]) => { const handler = handlers.get(event) if (handler) handler(...args) }, } - return socket as MockSocket + return socket +} + +const setGlobalWindow = (value?: typeof window): void => { + const globalWithWindow = globalThis as Partial & { window?: typeof window } + if (value) + globalWithWindow.window = value + else + delete globalWithWindow.window } describe('WebSocketClient', () => { @@ -47,13 +61,13 @@ describe('WebSocketClient', () => { afterEach(() => { if (originalWindow) - globalThis.window = originalWindow + setGlobalWindow(originalWindow) else - delete (globalThis as any).window + setGlobalWindow(undefined) }) it('connects with fallback url and registers base listeners when window is undefined', async () => { - delete (globalThis as any).window + setGlobalWindow(undefined) const mockSocket = createMockSocket('socket-fallback') ioMock.mockImplementation(() => mockSocket) @@ -92,17 +106,17 @@ describe('WebSocketClient', () => { it('attaches auth token from localStorage and emits user_connect on connect', async () => { const mockSocket = createMockSocket('socket-auth') - ioMock.mockImplementation((url: string, options: { auth?: { token?: string } }) => { + ioMock.mockImplementation((url: string, options: IoOptions) => { expect(options.auth).toEqual({ token: 'secret-token' }) return mockSocket }) - globalThis.window = { + setGlobalWindow({ location: { protocol: 'https:', host: 'example.com' }, localStorage: { getItem: vi.fn(() => 'secret-token'), }, - } as unknown as typeof window + } as unknown as typeof window) const { WebSocketClient } = await import('../websocket-manager') const client = new WebSocketClient()