mirror of https://github.com/langgenius/dify.git
fix web unittests
This commit is contained in:
parent
a99e70d96e
commit
2d94904241
|
|
@ -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<Record<string, any>> => ({
|
||||
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<StartNodeData> => ({
|
||||
id: NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
|
|
@ -29,7 +102,7 @@ const createNode = (variables: string[]): Node<Record<string, any>> => ({
|
|||
},
|
||||
})
|
||||
|
||||
const createLLMNode = (templates: Array<{ id: string, role: string, text: string }>): Node<Record<string, any>> => ({
|
||||
const createLLMNode = (templates: PromptTemplateItem[]): Node<LLMNodeData> => ({
|
||||
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<Record<string, any>> => ({
|
||||
const createParameterExtractorNode = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({
|
||||
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 = <T>(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<LLMNodeData> | undefined
|
||||
const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | 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<ParameterExtractorNodeData>
|
||||
| undefined
|
||||
const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) as
|
||||
| Node<ParameterExtractorNodeData>
|
||||
| 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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<LLMNodeData, 'prompt_template'> & {
|
||||
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<string>
|
||||
isUndoRedoInProgress: boolean
|
||||
}
|
||||
|
||||
const createVariable = (name: string, overrides: Partial<WorkflowVariable> = {}): WorkflowVariable => ({
|
||||
default: '',
|
||||
hint: '',
|
||||
|
|
@ -47,7 +117,7 @@ const createVariable = (name: string, overrides: Partial<WorkflowVariable> = {})
|
|||
|
||||
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const createNodeSnapshot = (variableNames: string[]): Node<{ variables: WorkflowVariable[] }> => ({
|
||||
const createNodeSnapshot = (variableNames: string[]): Node<StartNodeData> => ({
|
||||
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<Record<string, any>> => ({
|
||||
const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node<LLMNodeData> => ({
|
||||
id: LLM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 200, y: 120 },
|
||||
|
|
@ -107,7 +177,7 @@ const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node<Reco
|
|||
},
|
||||
})
|
||||
|
||||
const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node<Record<string, any>> => ({
|
||||
const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({
|
||||
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<Record<string, never>>,
|
||||
}
|
||||
|
||||
;(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<Record<string, any>> | undefined
|
||||
const firstTemplate = (storedBefore?.data as any).prompt_template?.[0]
|
||||
const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | 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<Record<string, any>>
|
||||
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<LLMNodeData> | 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<Record<string, any>>
|
||||
const mutatedNode = deepClone(stored) as Node<Record<string, any>>
|
||||
const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as Node<ParameterExtractorNodeData>
|
||||
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<ParameterExtractorNodeData>
|
||||
| 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<Record<string, any>> = {
|
||||
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<StartNodeData> = {
|
||||
...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<LLMNodeDataWithUnknownTemplate>
|
||||
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<LLMNodeData>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>
|
||||
import: ReturnType<typeof vi.fn>
|
||||
subscribe: ReturnType<typeof vi.fn>
|
||||
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<string, (...args: any[]) => void>()
|
||||
type MockSocket = {
|
||||
trigger: (event: string, ...args: unknown[]) => void
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const socket: any = {
|
||||
const createMockSocket = (): MockSocket => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,53 @@
|
|||
import type { Socket } from 'socket.io-client'
|
||||
type MockSocket = {
|
||||
trigger: (event: string, ...args: unknown[]) => void
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
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<typeof ioMock>) => ioMock(...args),
|
||||
}))
|
||||
|
||||
type MockSocket = Socket & {
|
||||
trigger: (event: string, ...args: any[]) => void
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const createMockSocket = (id: string): MockSocket => {
|
||||
const handlers = new Map<string, (...args: any[]) => void>()
|
||||
const handlers = new Map<string, (...args: unknown[]) => 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<typeof globalThis> & { 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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue