chore: improve codecov

This commit is contained in:
hjlarry 2026-04-11 16:29:23 +08:00
parent 026af49ec2
commit aea20a576f
8 changed files with 1804 additions and 0 deletions

View File

@ -0,0 +1,348 @@
import type { LoroMap } from 'loro-crdt'
import type { OnlineUser, RestoreRequestData } from '../../types/collaboration'
import type { Edge, Node } from '@/app/components/workflow/types'
import { LoroDoc } from 'loro-crdt'
import { BlockEnum } from '@/app/components/workflow/types'
import { CollaborationManager } from '../collaboration-manager'
import { webSocketClient } from '../websocket-manager'
type ReactFlowStore = {
getState: () => {
getNodes: () => Node[]
setNodes: (nodes: Node[]) => void
getEdges: () => Edge[]
setEdges: (edges: Edge[]) => void
}
}
type UndoManagerLike = {
canUndo: () => boolean
canRedo: () => boolean
undo: () => boolean
redo: () => boolean
clear: () => void
}
type CollaborationManagerInternals = {
doc: LoroDoc | null
nodesMap: LoroMap | null
edgesMap: LoroMap | null
undoManager: UndoManagerLike | null
currentAppId: string | null
reactFlowStore: ReactFlowStore | null
leaderId: string | null
isLeader: boolean
graphViewActive: boolean | null
pendingInitialSync: boolean
onlineUsers: OnlineUser[]
graphImportLogs: unknown[]
setNodesAnomalyLogs: unknown[]
graphSyncDiagnostics: unknown[]
pendingImportLog: unknown | null
}
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
manager as unknown as CollaborationManagerInternals
const createNode = (id: string): Node => ({
id,
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: `Node-${id}`,
desc: '',
},
})
const createEdge = (id: string, source: string, target: string): Edge => ({
id,
source,
target,
type: 'default',
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.End,
},
})
const createRestoreRequestData = (): RestoreRequestData => ({
versionId: 'version-1',
versionName: 'Version One',
initiatorUserId: 'user-1',
initiatorName: 'Alice',
graphData: {
nodes: [createNode('n-restore')],
edges: [],
viewport: { x: 1, y: 2, zoom: 0.5 },
},
})
const setupManagerWithDoc = () => {
const manager = new CollaborationManager()
const doc = new LoroDoc()
const internals = getManagerInternals(manager)
internals.doc = doc
internals.nodesMap = doc.getMap('nodes')
internals.edgesMap = doc.getMap('edges')
return { manager, internals }
}
describe('CollaborationManager logs and event helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('refreshGraphSynchronously emits merged graph with local selected node state', () => {
const { manager, internals } = setupManagerWithDoc()
const node = createNode('n-1')
const edge = createEdge('e-1', 'n-1', 'n-2')
manager.setNodes([], [node])
manager.setEdges([], [edge])
internals.reactFlowStore = {
getState: () => ({
getNodes: () => [{
...node,
data: {
...node.data,
selected: true,
},
}],
setNodes: vi.fn(),
getEdges: () => [edge],
setEdges: vi.fn(),
}),
}
let payload: { nodes: Node[], edges: Edge[] } | null = null
manager.onGraphImport((graph) => {
payload = graph
})
manager.refreshGraphSynchronously()
expect(payload).not.toBeNull()
expect(payload?.nodes).toHaveLength(1)
expect(payload?.edges).toHaveLength(1)
expect(payload?.nodes[0]?.data.selected).toBe(true)
})
it('clearGraphImportLog clears logs and pending import snapshot', () => {
const { manager, internals } = setupManagerWithDoc()
internals.graphImportLogs = [{ id: 1 }]
internals.setNodesAnomalyLogs = [{ id: 2 }]
internals.graphSyncDiagnostics = [{ id: 3 }]
internals.pendingImportLog = { id: 4 }
manager.clearGraphImportLog()
expect(manager.getGraphImportLog()).toEqual([])
expect(internals.setNodesAnomalyLogs).toEqual([])
expect(internals.graphSyncDiagnostics).toEqual([])
expect(internals.pendingImportLog).toBeNull()
})
it('downloadGraphImportLog exports a JSON snapshot and triggers browser download', async () => {
const { manager, internals } = setupManagerWithDoc()
const node = createNode('n-export')
const edge = createEdge('e-export', 'n-export', 'n-target')
manager.setNodes([], [node])
manager.setEdges([], [edge])
internals.currentAppId = 'app-export'
internals.leaderId = 'leader-1'
internals.isLeader = true
internals.graphViewActive = true
internals.pendingInitialSync = false
internals.onlineUsers = [{ user_id: 'u-1', username: 'Alice', avatar: '', sid: 'sid-1' }]
internals.graphImportLogs = [{ timestamp: 1 }]
internals.setNodesAnomalyLogs = [{ timestamp: 2 }]
internals.graphSyncDiagnostics = [{ timestamp: 3 }]
internals.reactFlowStore = {
getState: () => ({
getNodes: () => [createNode('rf-1'), createNode('rf-2')],
setNodes: vi.fn(),
getEdges: () => [createEdge('rf-e', 'rf-1', 'rf-2')],
setEdges: vi.fn(),
}),
}
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:workflow-log')
const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const anchor = document.createElement('a')
const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(() => {})
const originalCreateElement = document.createElement.bind(document)
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string): HTMLElement => {
if (tagName === 'a')
return anchor
return originalCreateElement(tagName)
})
manager.downloadGraphImportLog()
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(anchor.download).toContain('workflow-graph-import-log-app-export-')
expect(anchor.download).toMatch(/\.json$/)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:workflow-log')
const blobArg = createObjectURLSpy.mock.calls[0]?.[0]
expect(blobArg).toBeInstanceOf(Blob)
const payload = JSON.parse(await (blobArg as Blob).text()) as {
appId: string | null
summary: {
logCount: number
setNodesAnomalyCount: number
syncDiagnosticCount: number
onlineUsersCount: number
crdtCounts: { nodes: number, edges: number }
reactFlowCounts: { nodes: number, edges: number }
}
}
expect(payload.appId).toBe('app-export')
expect(payload.summary.logCount).toBe(1)
expect(payload.summary.setNodesAnomalyCount).toBe(1)
expect(payload.summary.syncDiagnosticCount).toBe(1)
expect(payload.summary.onlineUsersCount).toBe(1)
expect(payload.summary.crdtCounts).toEqual({ nodes: 1, edges: 1 })
expect(payload.summary.reactFlowCounts).toEqual({ nodes: 2, edges: 1 })
createElementSpy.mockRestore()
clickSpy.mockRestore()
})
it('emits collaboration events only when current app is connected', () => {
const { manager, internals } = setupManagerWithDoc()
const sendSpy = vi.spyOn(
manager as unknown as { sendCollaborationEvent: (payload: unknown) => void },
'sendCollaborationEvent',
).mockImplementation(() => {})
const isConnectedSpy = vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false)
manager.emitCommentsUpdate('app-1')
manager.emitHistoryAction('undo')
manager.emitRestoreRequest(createRestoreRequestData())
manager.emitRestoreIntent({
versionId: 'version-1',
versionName: 'Version One',
initiatorUserId: 'u-1',
initiatorName: 'Alice',
})
manager.emitRestoreComplete({ versionId: 'version-1', success: true })
expect(sendSpy).not.toHaveBeenCalled()
internals.currentAppId = 'app-1'
manager.emitCommentsUpdate('app-1')
manager.emitHistoryAction('undo')
manager.emitRestoreRequest(createRestoreRequestData())
expect(sendSpy).not.toHaveBeenCalled()
isConnectedSpy.mockReturnValue(true)
manager.emitCommentsUpdate('app-1')
manager.emitHistoryAction('redo')
manager.emitRestoreRequest(createRestoreRequestData())
manager.emitRestoreIntent({
versionId: 'version-2',
initiatorUserId: 'u-2',
initiatorName: 'Bob',
})
manager.emitRestoreComplete({ versionId: 'version-2', success: false, error: 'failed' })
const eventTypes = sendSpy.mock.calls.map(call => (
(call[0] as { type: string }).type
))
expect(eventTypes).toEqual([
'comments_update',
'workflow_history_action',
'workflow_restore_request',
'workflow_restore_intent',
'workflow_restore_complete',
])
})
it('returns leader state through public getters', () => {
const { manager, internals } = setupManagerWithDoc()
internals.leaderId = 'leader-123'
internals.isLeader = true
expect(manager.getLeaderId()).toBe('leader-123')
expect(manager.getIsLeader()).toBe(true)
})
it('undo and redo apply CRDT graph to ReactFlow store and emit undo/redo state', () => {
const { manager, internals } = setupManagerWithDoc()
const updatedNode = createNode('n-after-undo-redo')
const updatedEdge = createEdge('e-after-undo-redo', 'n-after-undo-redo', 'n-target')
internals.nodesMap?.set(updatedNode.id, updatedNode as unknown as Record<string, unknown>)
internals.edgesMap?.set(updatedEdge.id, updatedEdge as unknown as Record<string, unknown>)
const setNodesSpy = vi.fn()
const setEdgesSpy = vi.fn()
internals.reactFlowStore = {
getState: () => ({
getNodes: () => [createNode('old-node')],
setNodes: setNodesSpy,
getEdges: () => [createEdge('old-edge', 'old-node', 'old-target')],
setEdges: setEdgesSpy,
}),
}
const undoManager: UndoManagerLike = {
canUndo: vi.fn(() => true),
canRedo: vi.fn(() => true),
undo: vi.fn(() => true),
redo: vi.fn(() => true),
clear: vi.fn(),
}
internals.undoManager = undoManager
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
callback(0)
return 1
})
const historyStates: Array<{ canUndo: boolean, canRedo: boolean }> = []
manager.onUndoRedoStateChange((state) => {
historyStates.push(state)
})
expect(manager.undo()).toBe(true)
expect(manager.redo()).toBe(true)
expect(setNodesSpy).toHaveBeenCalledTimes(2)
expect(setEdgesSpy).toHaveBeenCalledTimes(2)
expect(historyStates).toEqual([
{ canUndo: true, canRedo: true },
{ canUndo: true, canRedo: true },
])
rafSpy.mockRestore()
})
it('exposes undo stack state helpers and supports clearing the stack', () => {
const { manager, internals } = setupManagerWithDoc()
expect(manager.canUndo()).toBe(false)
expect(manager.canRedo()).toBe(false)
expect(manager.undo()).toBe(false)
expect(manager.redo()).toBe(false)
const undoManager: UndoManagerLike = {
canUndo: vi.fn(() => false),
canRedo: vi.fn(() => true),
undo: vi.fn(() => false),
redo: vi.fn(() => false),
clear: vi.fn(),
}
internals.undoManager = undoManager
expect(manager.canUndo()).toBe(false)
expect(manager.canRedo()).toBe(true)
manager.clearUndoStack()
expect(undoManager.clear).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,151 @@
import type { CursorPosition, NodePanelPresenceMap, OnlineUser } from '../../types/collaboration'
import { renderHook, waitFor } from '@testing-library/react'
import { useCollaboration } from '../use-collaboration'
type HookReactFlowStore = NonNullable<Parameters<typeof useCollaboration>[1]>
type HookReactFlowInstance = Parameters<ReturnType<typeof useCollaboration>['startCursorTracking']>[1]
const mockConnect = vi.hoisted(() => vi.fn())
const mockDisconnect = vi.hoisted(() => vi.fn())
const mockIsConnected = vi.hoisted(() => vi.fn(() => true))
const mockEmitCursorMove = vi.hoisted(() => vi.fn())
const mockGetLeaderId = vi.hoisted(() => vi.fn(() => 'leader-1'))
let onStateChangeCallback: ((state: { isConnected?: boolean, disconnectReason?: string, error?: string }) => void) | null = null
let onCursorCallback: ((cursors: Record<string, CursorPosition>) => void) | null = null
let onUsersCallback: ((users: OnlineUser[]) => void) | null = null
let onPresenceCallback: ((presence: NodePanelPresenceMap) => void) | null = null
let onLeaderCallback: ((isLeader: boolean) => void) | null = null
const unsubscribeState = vi.hoisted(() => vi.fn())
const unsubscribeCursor = vi.hoisted(() => vi.fn())
const unsubscribeUsers = vi.hoisted(() => vi.fn())
const unsubscribePresence = vi.hoisted(() => vi.fn())
const unsubscribeLeader = vi.hoisted(() => vi.fn())
let isCollaborationEnabled = true
const mockStartTracking = vi.hoisted(() => vi.fn())
const mockStopTracking = vi.hoisted(() => vi.fn())
const cursorServiceInstances: Array<{ startTracking: typeof mockStartTracking, stopTracking: typeof mockStopTracking }> = []
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => boolean) =>
selector({ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled } }),
}))
vi.mock('../../core/collaboration-manager', () => ({
collaborationManager: {
connect: (...args: unknown[]) => mockConnect(...args),
disconnect: (...args: unknown[]) => mockDisconnect(...args),
isConnected: (...args: unknown[]) => mockIsConnected(...args),
emitCursorMove: (...args: unknown[]) => mockEmitCursorMove(...args),
getLeaderId: (...args: unknown[]) => mockGetLeaderId(...args),
onStateChange: (callback: (state: { isConnected?: boolean, disconnectReason?: string, error?: string }) => void) => {
onStateChangeCallback = callback
return unsubscribeState
},
onCursorUpdate: (callback: (cursors: Record<string, CursorPosition>) => void) => {
onCursorCallback = callback
return unsubscribeCursor
},
onOnlineUsersUpdate: (callback: (users: OnlineUser[]) => void) => {
onUsersCallback = callback
return unsubscribeUsers
},
onNodePanelPresenceUpdate: (callback: (presence: NodePanelPresenceMap) => void) => {
onPresenceCallback = callback
return unsubscribePresence
},
onLeaderChange: (callback: (isLeader: boolean) => void) => {
onLeaderCallback = callback
return unsubscribeLeader
},
},
}))
vi.mock('../../services/cursor-service', () => ({
CursorService: class {
startTracking = mockStartTracking
stopTracking = mockStopTracking
constructor() {
cursorServiceInstances.push({ startTracking: this.startTracking, stopTracking: this.stopTracking })
}
},
}))
describe('useCollaboration', () => {
beforeEach(() => {
vi.clearAllMocks()
onStateChangeCallback = null
onCursorCallback = null
onUsersCallback = null
onPresenceCallback = null
onLeaderCallback = null
isCollaborationEnabled = true
cursorServiceInstances.length = 0
mockConnect.mockResolvedValue('conn-1')
mockIsConnected.mockReturnValue(true)
})
it('connects, reacts to manager updates, and disconnects on unmount', async () => {
const reactFlowStore: HookReactFlowStore = {
getState: vi.fn(),
}
const { result, unmount } = renderHook(() => useCollaboration('app-1', reactFlowStore))
await waitFor(() => {
expect(mockConnect).toHaveBeenCalledWith('app-1', reactFlowStore)
})
onStateChangeCallback?.({ isConnected: true })
onUsersCallback?.([{ user_id: 'u1', user_name: 'U1', avatar_url: '', sid: 'sid-1' } as OnlineUser])
onCursorCallback?.({ u1: { x: 10, y: 20, userId: 'u1', timestamp: 1 } })
onPresenceCallback?.({ nodeA: { sid1: { userId: 'u1', username: 'U1', clientId: 'sid1', timestamp: 1 } } })
onLeaderCallback?.(true)
await waitFor(() => {
expect(result.current.isConnected).toBe(true)
expect(result.current.onlineUsers).toHaveLength(1)
expect(result.current.cursors.u1?.x).toBe(10)
expect(result.current.nodePanelPresence.nodeA).toBeDefined()
expect(result.current.isLeader).toBe(true)
expect(result.current.leaderId).toBe('leader-1')
})
const ref = { current: document.createElement('div') }
const reactFlowInstance: HookReactFlowInstance = {
getZoom: () => 1,
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
} as HookReactFlowInstance
result.current.startCursorTracking(ref, reactFlowInstance)
expect(mockStartTracking).toHaveBeenCalledTimes(1)
const emitPosition = mockStartTracking.mock.calls[0]?.[1] as ((position: CursorPosition) => void)
emitPosition({ x: 1, y: 2, userId: 'u1', timestamp: 2 })
expect(mockEmitCursorMove).toHaveBeenCalledWith({ x: 1, y: 2, userId: 'u1', timestamp: 2 })
result.current.stopCursorTracking()
expect(mockStopTracking).toHaveBeenCalled()
unmount()
expect(unsubscribeState).toHaveBeenCalled()
expect(unsubscribeCursor).toHaveBeenCalled()
expect(unsubscribeUsers).toHaveBeenCalled()
expect(unsubscribePresence).toHaveBeenCalled()
expect(unsubscribeLeader).toHaveBeenCalled()
expect(mockDisconnect).toHaveBeenCalledWith('conn-1')
})
it('does not connect or start cursor tracking when collaboration is disabled', async () => {
isCollaborationEnabled = false
const { result } = renderHook(() => useCollaboration('app-1'))
await waitFor(() => {
expect(mockConnect).not.toHaveBeenCalled()
expect(result.current.isEnabled).toBe(false)
})
result.current.startCursorTracking({ current: document.createElement('div') })
expect(mockStartTracking).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,86 @@
import type { ReactFlowInstance } from 'reactflow'
import { CursorService } from '../cursor-service'
describe('CursorService', () => {
let service: CursorService
let container: HTMLDivElement
let now = 0
beforeEach(() => {
service = new CursorService()
container = document.createElement('div')
document.body.appendChild(container)
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: 10,
y: 20,
top: 20,
left: 10,
right: 410,
bottom: 220,
width: 400,
height: 200,
toJSON: () => ({}),
} as DOMRect)
now = 1000
vi.spyOn(Date, 'now').mockImplementation(() => now)
})
afterEach(() => {
vi.restoreAllMocks()
container.remove()
})
it('emits transformed positions with throttle and distance guard', () => {
const onEmit = vi.fn()
const reactFlow = {
getViewport: () => ({ x: 5, y: 10, zoom: 2 }),
getZoom: () => 2,
} as unknown as ReactFlowInstance
service.startTracking({ current: container }, onEmit, reactFlow)
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 30, clientY: 50 }))
expect(onEmit).toHaveBeenCalledTimes(1)
expect(onEmit).toHaveBeenLastCalledWith(expect.objectContaining({
x: 7.5,
y: 10,
timestamp: 1000,
}))
now = 1100
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 60 }))
expect(onEmit).toHaveBeenCalledTimes(1)
now = 1401
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 33, clientY: 53 }))
expect(onEmit).toHaveBeenCalledTimes(1)
now = 1800
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 60, clientY: 90 }))
expect(onEmit).toHaveBeenCalledTimes(2)
expect(onEmit).toHaveBeenLastCalledWith(expect.objectContaining({
x: 22.5,
y: 30,
timestamp: 1800,
}))
})
it('stops tracking and forwards cursor updates to registered handler', () => {
const onEmit = vi.fn()
const onCursorUpdate = vi.fn()
service.startTracking({ current: container }, onEmit)
service.setCursorUpdateHandler(onCursorUpdate)
service.updateCursors({
u1: { x: 1, y: 2, userId: 'u1', timestamp: 1 },
})
expect(onCursorUpdate).toHaveBeenCalledWith({
u1: { x: 1, y: 2, userId: 'u1', timestamp: 1 },
})
service.stopTracking()
now = 2000
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 60 }))
expect(onEmit).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,151 @@
import type { UserProfile } from '@/service/workflow-comment'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { MentionInput } from './mention-input'
const mockFetchMentionableUsers = vi.hoisted(() => vi.fn())
const mockSetMentionableUsersLoading = vi.hoisted(() => vi.fn())
const mockSetMentionableUsersCache = vi.hoisted(() => vi.fn())
const mentionStoreState = vi.hoisted(() => ({
mentionableUsersCache: {} as Record<string, UserProfile[]>,
mentionableUsersLoading: {} as Record<string, boolean>,
setMentionableUsersLoading: (appId: string, loading: boolean) => {
mockSetMentionableUsersLoading(appId, loading)
mentionStoreState.mentionableUsersLoading[appId] = loading
},
setMentionableUsersCache: (appId: string, users: UserProfile[]) => {
mockSetMentionableUsersCache(appId, users)
mentionStoreState.mentionableUsersCache[appId] = users
},
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/service/workflow-comment', () => ({
fetchMentionableUsers: (...args: unknown[]) => mockFetchMentionableUsers(...args),
}))
vi.mock('../store', () => ({
useStore: (selector: (state: typeof mentionStoreState) => unknown) => selector(mentionStoreState),
useWorkflowStore: () => ({
getState: () => mentionStoreState,
}),
}))
vi.mock('@/app/components/base/ui/avatar', () => ({
Avatar: ({ name }: { name: string }) => <div data-testid="mention-avatar">{name}</div>,
}))
const mentionUsers: UserProfile[] = [
{
id: 'user-2',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
{
id: 'user-3',
name: 'Bob',
email: 'bob@example.com',
avatar_url: 'bob.png',
},
]
function ControlledMentionInput({
onSubmit,
}: {
onSubmit: (content: string, mentionedUserIds: string[]) => void
}) {
const [value, setValue] = useState('')
return (
<MentionInput
value={value}
onChange={setValue}
onSubmit={onSubmit}
/>
)
}
describe('MentionInput', () => {
beforeEach(() => {
vi.clearAllMocks()
mentionStoreState.mentionableUsersCache = {}
mentionStoreState.mentionableUsersLoading = {}
mockFetchMentionableUsers.mockResolvedValue(mentionUsers)
})
it('loads mentionable users when cache is empty', async () => {
render(
<MentionInput
value=""
onChange={vi.fn()}
onSubmit={vi.fn()}
/>,
)
await waitFor(() => {
expect(mockFetchMentionableUsers).toHaveBeenCalledWith('app-1')
})
expect(mockSetMentionableUsersLoading).toHaveBeenCalledWith('app-1', true)
expect(mockSetMentionableUsersCache).toHaveBeenCalledWith('app-1', mentionUsers)
expect(mockSetMentionableUsersLoading).toHaveBeenCalledWith('app-1', false)
})
it('selects a mention and submits with mentioned user ids', async () => {
mentionStoreState.mentionableUsersCache['app-1'] = mentionUsers
const onSubmit = vi.fn()
render(<ControlledMentionInput onSubmit={onSubmit} />)
const textarea = screen.getByPlaceholderText('workflow.comments.placeholder.add') as HTMLTextAreaElement
textarea.focus()
textarea.setSelectionRange(4, 4)
fireEvent.change(textarea, { target: { value: '@Ali' } })
await waitFor(() => {
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('alice@example.com'))
fireEvent.change(textarea, { target: { value: '@Alice hi' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith('@Alice hi', ['user-2'])
})
})
it('supports editing mode cancel and save actions', async () => {
mentionStoreState.mentionableUsersCache['app-1'] = mentionUsers
const onSubmit = vi.fn()
const onCancel = vi.fn()
render(
<MentionInput
value=" updated reply "
onChange={vi.fn()}
onSubmit={onSubmit}
onCancel={onCancel}
isEditing
/>,
)
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(onCancel).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith('updated reply', [])
})
})
})

View File

@ -0,0 +1,244 @@
import type { WorkflowCommentDetail } from '@/service/workflow-comment'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { CommentThread } from './thread'
const mockSetCommentPreviewHovering = vi.hoisted(() => vi.fn())
const mockFlowToScreenPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x, y })))
const storeState = vi.hoisted(() => ({
mentionableUsersCache: {
'app-1': [
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', avatar_url: 'alice.png' },
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', avatar_url: 'bob.png' },
],
} as Record<string, Array<{ id: string, name: string, email: string, avatar_url: string }>>,
setCommentPreviewHovering: (...args: unknown[]) => mockSetCommentPreviewHovering(...args),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: () => 'just now',
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
id: 'user-1',
name: 'Alice',
avatar_url: 'alice.png',
},
}),
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
flowToScreenPosition: mockFlowToScreenPosition,
}),
useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
}))
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}))
vi.mock('@/app/components/workflow/collaboration/utils/user-color', () => ({
getUserColor: () => '#22c55e',
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
vi.mock('@/app/components/base/inline-delete-confirm', () => ({
default: ({ onConfirm }: { onConfirm: () => void }) => (
<button type="button" data-testid="confirm-delete-reply" onClick={onConfirm}>
confirm delete
</button>
),
}))
vi.mock('@/app/components/base/ui/avatar', () => ({
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children, ...props }: React.ComponentProps<'button'>) => (
<button type="button" {...props}>{children}</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('./mention-input', () => ({
MentionInput: ({
placeholder,
value,
onSubmit,
onCancel,
}: {
placeholder?: string
value: string
onSubmit: (content: string, mentionedUserIds: string[]) => void
onCancel?: () => void
}) => (
<div>
<button
type="button"
onClick={() => onSubmit(value || `content:${placeholder ?? 'default'}`, ['user-2'])}
>
{`submit-${placeholder ?? 'default'}`}
</button>
{onCancel && (
<button type="button" onClick={onCancel}>
{`cancel-${placeholder ?? 'default'}`}
</button>
)}
</div>
),
}))
const createComment = (): WorkflowCommentDetail => ({
id: 'comment-1',
position_x: 120,
position_y: 80,
content: '@Alice original comment',
created_by: 'user-1',
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
created_at: 1,
updated_at: 2,
resolved: false,
mentions: [],
replies: [{
id: 'reply-1',
content: 'first reply',
created_by: 'user-1',
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
created_at: 2,
updated_at: 2,
mentions: [],
}],
})
describe('CommentThread', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
const workflowContainer = document.createElement('div')
workflowContainer.id = 'workflow-container'
document.body.appendChild(workflowContainer)
})
it('triggers header actions and closes on Escape', () => {
const onClose = vi.fn()
const onDelete = vi.fn()
const onResolve = vi.fn()
const onPrev = vi.fn()
const onNext = vi.fn()
render(
<CommentThread
comment={createComment()}
onClose={onClose}
onDelete={onDelete}
onResolve={onResolve}
onPrev={onPrev}
onNext={onNext}
canGoPrev
canGoNext
/>,
)
fireEvent.click(screen.getByLabelText('workflow.comments.aria.deleteComment'))
fireEvent.click(screen.getByLabelText('workflow.comments.aria.resolveComment'))
fireEvent.click(screen.getByLabelText('workflow.comments.aria.previousComment'))
fireEvent.click(screen.getByLabelText('workflow.comments.aria.nextComment'))
fireEvent.click(screen.getByLabelText('workflow.comments.aria.closeComment'))
fireEvent.keyDown(document, { key: 'Escape' })
expect(onDelete).toHaveBeenCalledTimes(1)
expect(onResolve).toHaveBeenCalledTimes(1)
expect(onPrev).toHaveBeenCalledTimes(1)
expect(onNext).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(2)
})
it('submits reply and updates preview hovering state on mouse enter/leave', async () => {
const onReply = vi.fn()
const { container } = render(
<CommentThread
comment={createComment()}
onClose={vi.fn()}
onReply={onReply}
/>,
)
fireEvent.mouseEnter(container.firstElementChild as Element)
fireEvent.mouseLeave(container.firstElementChild as Element)
expect(mockSetCommentPreviewHovering).toHaveBeenNthCalledWith(1, true)
expect(mockSetCommentPreviewHovering).toHaveBeenNthCalledWith(2, false)
fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.reply'))
await waitFor(() => {
expect(onReply).toHaveBeenCalledWith('content:workflow.comments.placeholder.reply', ['user-2'])
})
})
it('supports editing and direct deleting an existing reply', async () => {
const onReplyEdit = vi.fn()
const onReplyDeleteDirect = vi.fn()
render(
<CommentThread
comment={createComment()}
onClose={vi.fn()}
onReplyEdit={onReplyEdit}
onReplyDeleteDirect={onReplyDeleteDirect}
/>,
)
fireEvent.click(screen.getByText('workflow.comments.actions.editReply'))
fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.editReply'))
await waitFor(() => {
expect(onReplyEdit).toHaveBeenCalledWith('reply-1', 'first reply', ['user-2'])
})
await waitFor(() => {
expect(screen.getByText('workflow.comments.actions.deleteReply')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('workflow.comments.actions.deleteReply'))
fireEvent.click(screen.getByTestId('confirm-delete-reply'))
expect(onReplyDeleteDirect).toHaveBeenCalledWith('reply-1')
})
})

View File

@ -0,0 +1,259 @@
import type { RestoreIntentData, RestoreRequestData } from '../../collaboration/types/collaboration'
import type { SyncDraftCallback } from '../../hooks-store/store'
import { act, renderHook } from '@testing-library/react'
import { useLeaderRestore, useLeaderRestoreListener } from '../use-leader-restore'
const mockSetViewport = vi.hoisted(() => vi.fn())
const mockSetFeatures = vi.hoisted(() => vi.fn())
const mockSetEnvironmentVariables = vi.hoisted(() => vi.fn())
const mockSetConversationVariables = vi.hoisted(() => vi.fn())
const mockDoSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockToastInfo = vi.hoisted(() => vi.fn())
const mockEmitRestoreIntent = vi.hoisted(() => vi.fn())
const mockEmitRestoreComplete = vi.hoisted(() => vi.fn())
const mockEmitWorkflowUpdate = vi.hoisted(() => vi.fn())
const mockEmitRestoreRequest = vi.hoisted(() => vi.fn())
const mockIsConnected = vi.hoisted(() => vi.fn())
const mockGetIsLeader = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockSetEdges = vi.hoisted(() => vi.fn())
const mockRefreshGraphSynchronously = vi.hoisted(() => vi.fn())
const mockGetNodes = vi.hoisted(() => vi.fn(() => [{ id: 'old-node' }]))
const mockGetEdges = vi.hoisted(() => vi.fn(() => [{ id: 'old-edge' }]))
let restoreCompleteCallback: ((data: { versionId: string, success: boolean }) => void) | null = null
let restoreRequestCallback: ((data: RestoreRequestData) => void) | null = null
let restoreIntentCallback: ((data: RestoreIntentData) => void) | null = null
const unsubscribeRestoreComplete = vi.hoisted(() => vi.fn())
const unsubscribeRestoreRequest = vi.hoisted(() => vi.fn())
const unsubscribeRestoreIntent = vi.hoisted(() => vi.fn())
let isCollaborationEnabled = true
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, userName?: string, versionName?: string }) => {
const ns = options?.ns ? `${options.ns}.` : ''
const extra = options?.userName ? `:${options.userName}:${options.versionName}` : ''
return `${ns}${key}${extra}`
},
}),
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
setViewport: mockSetViewport,
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({
appDetail: { id: 'app-1' },
}),
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
setFeatures: mockSetFeatures,
}),
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
info: (...args: unknown[]) => mockToastInfo(...args),
},
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => boolean) =>
selector({ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled } }),
}))
vi.mock('../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setEnvironmentVariables: mockSetEnvironmentVariables,
setConversationVariables: mockSetConversationVariables,
}),
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: (...args: unknown[]) => mockDoSyncWorkflowDraft(...args),
}),
}))
vi.mock('../../collaboration/core/collaboration-manager', () => ({
collaborationManager: {
emitRestoreIntent: (...args: unknown[]) => mockEmitRestoreIntent(...args),
emitRestoreComplete: (...args: unknown[]) => mockEmitRestoreComplete(...args),
emitWorkflowUpdate: (...args: unknown[]) => mockEmitWorkflowUpdate(...args),
emitRestoreRequest: (...args: unknown[]) => mockEmitRestoreRequest(...args),
isConnected: (...args: unknown[]) => mockIsConnected(...args),
getIsLeader: (...args: unknown[]) => mockGetIsLeader(...args),
setNodes: (...args: unknown[]) => mockSetNodes(...args),
setEdges: (...args: unknown[]) => mockSetEdges(...args),
refreshGraphSynchronously: (...args: unknown[]) => mockRefreshGraphSynchronously(...args),
getNodes: (...args: unknown[]) => mockGetNodes(...args),
getEdges: (...args: unknown[]) => mockGetEdges(...args),
onRestoreComplete: (callback: (data: { versionId: string, success: boolean }) => void) => {
restoreCompleteCallback = callback
return unsubscribeRestoreComplete
},
onRestoreRequest: (callback: (data: RestoreRequestData) => void) => {
restoreRequestCallback = callback
return unsubscribeRestoreRequest
},
onRestoreIntent: (callback: (data: RestoreIntentData) => void) => {
restoreIntentCallback = callback
return unsubscribeRestoreIntent
},
},
}))
describe('useLeaderRestore', () => {
const restoreData: RestoreRequestData = {
versionId: 'v-1',
versionName: 'Version One',
initiatorUserId: 'u-1',
initiatorName: 'Alice',
features: { a: true },
environmentVariables: [{ id: 'env-1', name: 'A', value: '1', value_type: 'string', description: '' }],
conversationVariables: [{ id: 'conv-1', name: 'B', value: '2', value_type: 'string', description: '' }],
graphData: {
nodes: [{ id: 'new-node' }],
edges: [{ id: 'new-edge' }],
viewport: { x: 1, y: 2, zoom: 0.5 },
},
}
beforeEach(() => {
vi.clearAllMocks()
restoreCompleteCallback = null
restoreRequestCallback = null
restoreIntentCallback = null
isCollaborationEnabled = true
mockIsConnected.mockReturnValue(true)
mockGetIsLeader.mockReturnValue(false)
mockDoSyncWorkflowDraft.mockImplementation((_sync: boolean, callbacks?: SyncDraftCallback) => {
callbacks?.onSuccess?.()
callbacks?.onSettled?.()
})
})
it('performs restore locally when collaboration is disabled', async () => {
isCollaborationEnabled = false
const onSuccess = vi.fn()
const onSettled = vi.fn()
const { result } = renderHook(() => useLeaderRestore())
await act(async () => {
result.current.requestRestore(restoreData, { onSuccess, onSettled })
})
expect(mockEmitRestoreIntent).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'v-1',
initiatorName: 'Alice',
}))
expect(mockSetFeatures).toHaveBeenCalledWith({ a: true })
expect(mockSetEnvironmentVariables).toHaveBeenCalled()
expect(mockSetConversationVariables).toHaveBeenCalled()
expect(mockSetNodes).toHaveBeenCalledWith([{ id: 'old-node' }], [{ id: 'new-node' }], 'leader-restore:apply-graph')
expect(mockSetEdges).toHaveBeenCalledWith([{ id: 'old-edge' }], [{ id: 'new-edge' }])
expect(mockRefreshGraphSynchronously).toHaveBeenCalled()
expect(mockSetViewport).toHaveBeenCalledWith({ x: 1, y: 2, zoom: 0.5 })
expect(mockEmitRestoreComplete).toHaveBeenCalledWith({ versionId: 'v-1', success: true })
expect(mockEmitWorkflowUpdate).toHaveBeenCalledWith('app-1')
expect(onSuccess).toHaveBeenCalled()
expect(onSettled).toHaveBeenCalled()
})
it('emits restore request and resolves callbacks from restore-complete events', async () => {
isCollaborationEnabled = true
mockIsConnected.mockReturnValue(true)
mockGetIsLeader.mockReturnValue(false)
const onSuccess = vi.fn()
const onError = vi.fn()
const onSettled = vi.fn()
const { result } = renderHook(() => useLeaderRestore())
act(() => {
result.current.requestRestore(restoreData, { onSuccess, onError, onSettled })
})
expect(mockEmitRestoreRequest).toHaveBeenCalledWith(restoreData)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
act(() => {
restoreCompleteCallback?.({ versionId: 'v-1', success: true })
})
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledTimes(1)
act(() => {
result.current.requestRestore(restoreData, { onSuccess, onError, onSettled })
restoreCompleteCallback?.({ versionId: 'v-1', success: false })
})
expect(onError).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledTimes(2)
})
})
describe('useLeaderRestoreListener', () => {
beforeEach(() => {
vi.clearAllMocks()
restoreRequestCallback = null
restoreIntentCallback = null
mockDoSyncWorkflowDraft.mockImplementation((_sync: boolean, callbacks?: SyncDraftCallback) => {
callbacks?.onSuccess?.()
callbacks?.onSettled?.()
})
})
it('shows restore notifications for request and intent events', () => {
const { unmount } = renderHook(() => useLeaderRestoreListener())
act(() => {
restoreRequestCallback?.({
...{
versionId: 'v-2',
versionName: 'Version Two',
initiatorUserId: 'u-2',
initiatorName: 'Bob',
graphData: { nodes: [], edges: [] },
},
})
})
expect(mockToastInfo).toHaveBeenCalledWith(
'workflow.versionHistory.action.restoreInProgress:Bob:Version Two',
{ timeout: 3000 },
)
expect(mockEmitRestoreIntent).toHaveBeenCalled()
act(() => {
restoreIntentCallback?.({
versionId: 'v-3',
versionName: 'Version Three',
initiatorName: 'Carol',
})
})
expect(mockToastInfo).toHaveBeenCalledWith(
'workflow.versionHistory.action.restoreInProgress:Carol:Version Three',
{ timeout: 3000 },
)
unmount()
expect(unsubscribeRestoreRequest).toHaveBeenCalled()
expect(unsubscribeRestoreIntent).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,404 @@
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import { act, waitFor } from '@testing-library/react'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { ControlMode } from '../../types'
import { useWorkflowComment } from '../use-workflow-comment'
const mockScreenToFlowPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x: x - 90, y: y - 180 })))
const mockSetCenter = vi.hoisted(() => vi.fn())
const mockGetNodes = vi.hoisted(() => vi.fn(() => []))
const mockCreateWorkflowComment = vi.hoisted(() => vi.fn())
const mockCreateWorkflowCommentReply = vi.hoisted(() => vi.fn())
const mockDeleteWorkflowComment = vi.hoisted(() => vi.fn())
const mockDeleteWorkflowCommentReply = vi.hoisted(() => vi.fn())
const mockFetchWorkflowComment = vi.hoisted(() => vi.fn())
const mockFetchWorkflowComments = vi.hoisted(() => vi.fn())
const mockResolveWorkflowComment = vi.hoisted(() => vi.fn())
const mockUpdateWorkflowComment = vi.hoisted(() => vi.fn())
const mockUpdateWorkflowCommentReply = vi.hoisted(() => vi.fn())
const mockEmitCommentsUpdate = vi.hoisted(() => vi.fn())
const mockUnsubscribeCommentsUpdate = vi.hoisted(() => vi.fn())
const commentsUpdateState = vi.hoisted(() => ({
handler: undefined as undefined | (() => void),
}))
const globalFeatureState = vi.hoisted(() => ({
enableCollaboration: true,
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
screenToFlowPosition: mockScreenToFlowPosition,
setCenter: mockSetCenter,
getNodes: mockGetNodes,
}),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) => selector({
systemFeatures: {
enable_collaboration_mode: globalFeatureState.enableCollaboration,
},
}),
}))
vi.mock('@/service/workflow-comment', () => ({
createWorkflowComment: (...args: unknown[]) => mockCreateWorkflowComment(...args),
createWorkflowCommentReply: (...args: unknown[]) => mockCreateWorkflowCommentReply(...args),
deleteWorkflowComment: (...args: unknown[]) => mockDeleteWorkflowComment(...args),
deleteWorkflowCommentReply: (...args: unknown[]) => mockDeleteWorkflowCommentReply(...args),
fetchWorkflowComment: (...args: unknown[]) => mockFetchWorkflowComment(...args),
fetchWorkflowComments: (...args: unknown[]) => mockFetchWorkflowComments(...args),
resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args),
updateWorkflowComment: (...args: unknown[]) => mockUpdateWorkflowComment(...args),
updateWorkflowCommentReply: (...args: unknown[]) => mockUpdateWorkflowCommentReply(...args),
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
emitCommentsUpdate: (...args: unknown[]) => mockEmitCommentsUpdate(...args),
onCommentsUpdate: (handler: () => void) => {
commentsUpdateState.handler = handler
return mockUnsubscribeCommentsUpdate
},
},
}))
const baseComment = (): WorkflowCommentList => ({
id: 'comment-1',
position_x: 10,
position_y: 20,
content: 'hello',
created_by: 'user-1',
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
created_at: 100,
updated_at: 100,
resolved: false,
mention_count: 0,
reply_count: 0,
participants: [],
})
const baseCommentDetail = (): WorkflowCommentDetail => ({
id: 'comment-1',
position_x: 10,
position_y: 20,
content: 'hello',
created_by: 'user-1',
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
created_at: 100,
updated_at: 100,
resolved: false,
mentions: [],
replies: [],
})
describe('useWorkflowComment', () => {
beforeEach(() => {
vi.clearAllMocks()
commentsUpdateState.handler = undefined
globalFeatureState.enableCollaboration = true
mockFetchWorkflowComments.mockResolvedValue([])
mockFetchWorkflowComment.mockResolvedValue(baseCommentDetail())
mockCreateWorkflowComment.mockResolvedValue({
id: 'comment-2',
created_at: '1700000000',
})
mockUpdateWorkflowComment.mockResolvedValue({})
})
it('loads comment list on mount when collaboration is enabled', async () => {
const comment = baseComment()
mockFetchWorkflowComments.mockResolvedValue([comment])
const { store } = renderWorkflowHook(() => useWorkflowComment())
await waitFor(() => {
expect(mockFetchWorkflowComments).toHaveBeenCalledWith('app-1')
})
expect(store.getState().comments).toEqual([comment])
expect(store.getState().commentsLoading).toBe(false)
})
it('does not load comment list when collaboration is disabled', async () => {
globalFeatureState.enableCollaboration = false
renderWorkflowHook(() => useWorkflowComment())
await Promise.resolve()
expect(mockFetchWorkflowComments).not.toHaveBeenCalled()
})
it('creates a comment, updates local cache, and emits collaboration sync', async () => {
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
initialStoreState: {
comments: [],
pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 },
isCommentQuickAdd: true,
mentionableUsersCache: {
'app-1': [{
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
avatar_url: 'bob.png',
}],
},
},
})
await act(async () => {
await result.current.handleCommentSubmit('new message', ['user-2'])
})
expect(mockCreateWorkflowComment).toHaveBeenCalledWith('app-1', {
position_x: 10,
position_y: 20,
content: 'new message',
mentioned_user_ids: ['user-2'],
})
expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1')
const comments = store.getState().comments
expect(comments).toHaveLength(1)
expect(comments[0]).toMatchObject({
id: 'comment-2',
content: 'new message',
position_x: 10,
position_y: 20,
mention_count: 1,
reply_count: 0,
})
expect(comments[0]?.participants.map(p => p.id)).toEqual(['user-1', 'user-2'])
expect(store.getState().commentDetailCache['comment-2']).toMatchObject({
content: 'new message',
position_x: 10,
position_y: 20,
})
expect(store.getState().pendingComment).toBeNull()
expect(store.getState().isCommentQuickAdd).toBe(false)
})
it('rolls back optimistic position update when API update fails', async () => {
const comment = baseComment()
const commentDetail = baseCommentDetail()
mockUpdateWorkflowComment.mockRejectedValue(new Error('update failed'))
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
initialStoreState: {
comments: [comment],
activeCommentId: comment.id,
activeCommentDetail: commentDetail,
commentDetailCache: {
[comment.id]: commentDetail,
},
},
})
await act(async () => {
await result.current.handleCommentPositionUpdate(comment.id, { x: 300, y: 400 })
})
expect(mockUpdateWorkflowComment).toHaveBeenCalledWith('app-1', comment.id, {
content: 'hello',
position_x: 300,
position_y: 400,
})
expect(mockEmitCommentsUpdate).not.toHaveBeenCalled()
expect(store.getState().comments[0]).toMatchObject({
position_x: 10,
position_y: 20,
})
expect(store.getState().commentDetailCache[comment.id]).toMatchObject({
position_x: 10,
position_y: 20,
})
})
it('refreshes comments and active detail when collaboration update event arrives', async () => {
const comment = baseComment()
const detail = {
...baseCommentDetail(),
content: 'updated by another user',
}
mockFetchWorkflowComments.mockResolvedValue([comment])
mockFetchWorkflowComment.mockResolvedValue({ data: detail })
const { unmount } = renderWorkflowHook(() => useWorkflowComment(), {
initialStoreState: {
activeCommentId: comment.id,
},
})
await waitFor(() => {
expect(commentsUpdateState.handler).toBeTypeOf('function')
})
await act(async () => {
commentsUpdateState.handler?.()
})
await waitFor(() => {
expect(mockFetchWorkflowComment).toHaveBeenCalledWith('app-1', comment.id)
})
expect(mockFetchWorkflowComments).toHaveBeenCalledTimes(2)
unmount()
expect(mockUnsubscribeCommentsUpdate).toHaveBeenCalledTimes(1)
})
it('focuses comment thread, loads detail, and updates navigation/create/close states', async () => {
const commentA = baseComment()
const commentB: WorkflowCommentList = {
...baseComment(),
id: 'comment-2',
content: 'second',
position_x: 50,
position_y: 80,
}
mockGetNodes.mockReturnValue([{ id: 'node-1', data: { selected: true } }])
mockFetchWorkflowComment.mockResolvedValue({
...baseCommentDetail(),
id: commentB.id,
content: 'second detail',
position_x: commentB.position_x,
position_y: commentB.position_y,
})
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
initialStoreState: {
comments: [commentA, commentB],
commentDetailCache: {
[commentA.id]: baseCommentDetail(),
},
rightPanelWidth: 800,
nodePanelWidth: 300,
controlMode: ControlMode.Comment,
activeCommentId: commentA.id,
pendingComment: { pageX: 1, pageY: 2, elementX: 3, elementY: 4 },
},
})
await act(async () => {
await result.current.handleCommentNavigate('next')
})
await waitFor(() => {
expect(store.getState().activeCommentId).toBe(commentB.id)
})
expect(mockSetCenter).toHaveBeenCalledWith(
502,
80,
{ zoom: 1, duration: 600 },
)
act(() => {
result.current.handleCreateComment({
pageX: 300,
pageY: 400,
elementX: 30,
elementY: 40,
})
})
expect(store.getState().pendingComment).toEqual({
pageX: 300,
pageY: 400,
elementX: 30,
elementY: 40,
})
act(() => {
result.current.handleActiveCommentClose()
})
expect(store.getState().activeCommentId).toBeNull()
expect(store.getState().activeCommentDetail).toBeNull()
expect(store.getState().activeCommentDetailLoading).toBe(false)
})
it('runs resolve, delete, and reply lifecycle handlers with collaboration sync', async () => {
const commentA = baseComment()
const commentB: WorkflowCommentList = {
...baseComment(),
id: 'comment-2',
content: 'fallback',
position_x: 33,
position_y: 55,
}
mockFetchWorkflowComments.mockResolvedValue([commentB])
mockFetchWorkflowComment.mockResolvedValue({
...baseCommentDetail(),
id: commentB.id,
content: 'fallback detail',
position_x: commentB.position_x,
position_y: commentB.position_y,
})
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
initialStoreState: {
comments: [commentA, commentB],
activeCommentId: commentA.id,
},
})
await act(async () => {
await result.current.handleCommentResolve(commentA.id)
})
expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id)
await act(async () => {
await result.current.handleCommentReply(commentA.id, ' new reply ', ['user-2'])
await result.current.handleCommentReplyUpdate(commentA.id, 'reply-1', ' edited reply ', ['user-2'])
await result.current.handleCommentReplyDelete(commentA.id, 'reply-1')
})
expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, {
content: 'new reply',
mentioned_user_ids: ['user-2'],
})
expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1', {
content: 'edited reply',
mentioned_user_ids: ['user-2'],
})
expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1')
await act(async () => {
await result.current.handleCommentDelete(commentA.id)
})
expect(mockDeleteWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id)
await waitFor(() => {
expect(store.getState().activeCommentId).toBe(commentB.id)
})
expect(mockEmitCommentsUpdate).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,161 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CommentsPanel from '../index'
const mockHandleCommentIconClick = vi.hoisted(() => vi.fn())
const mockLoadComments = vi.hoisted(() => vi.fn())
const mockSetActiveCommentId = vi.hoisted(() => vi.fn())
const mockSetControlMode = vi.hoisted(() => vi.fn())
const mockSetShowResolvedComments = vi.hoisted(() => vi.fn())
const mockResolveWorkflowComment = vi.hoisted(() => vi.fn())
const mockEmitCommentsUpdate = vi.hoisted(() => vi.fn())
const commentFixtures: WorkflowCommentList[] = [
{
id: 'c-1',
created_by: 'user-1',
created_by_account: { id: 'user-1', name: 'Alice', avatar_url: '' },
content: 'my open thread',
created_at: 1,
updated_at: 2,
resolved: false,
reply_count: 2,
participants: [],
},
{
id: 'c-2',
created_by: 'user-2',
created_by_account: { id: 'user-2', name: 'Bob', avatar_url: '' },
content: 'others resolved thread',
created_at: 3,
updated_at: 4,
resolved: true,
reply_count: 0,
participants: [],
},
]
type WorkflowStoreSelectionState = {
activeCommentId: string | null
setActiveCommentId: (value: string | null) => void
setControlMode: (value: unknown) => void
showResolvedComments: boolean
setShowResolvedComments: (value: boolean) => void
}
const storeState = {
activeCommentId: null as string | null,
showResolvedComments: true,
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: () => 'just now',
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { id: 'user-1' },
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: WorkflowStoreSelectionState) => unknown) => selector({
activeCommentId: storeState.activeCommentId,
setActiveCommentId: (...args: unknown[]) => mockSetActiveCommentId(...args),
setControlMode: (...args: unknown[]) => mockSetControlMode(...args),
showResolvedComments: storeState.showResolvedComments,
setShowResolvedComments: (...args: unknown[]) => mockSetShowResolvedComments(...args),
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-comment', () => ({
useWorkflowComment: () => ({
comments: commentFixtures,
loading: false,
loadComments: (...args: unknown[]) => mockLoadComments(...args),
handleCommentIconClick: (...args: unknown[]) => mockHandleCommentIconClick(...args),
}),
}))
vi.mock('@/service/workflow-comment', () => ({
resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args),
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
emitCommentsUpdate: (...args: unknown[]) => mockEmitCommentsUpdate(...args),
},
}))
vi.mock('@/app/components/base/user-avatar-list', () => ({
UserAvatarList: () => <div data-testid="user-avatar-list" />,
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => (
<button type="button" data-testid="show-resolved-switch" onClick={() => onChange(!value)}>
toggle
</button>
),
}))
describe('CommentsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
storeState.activeCommentId = null
storeState.showResolvedComments = true
mockResolveWorkflowComment.mockResolvedValue({})
mockLoadComments.mockResolvedValue(undefined)
})
it('filters comments and selects a thread', () => {
render(<CommentsPanel />)
expect(screen.getByText('my open thread')).toBeInTheDocument()
expect(screen.getByText('others resolved thread')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Filter comments'))
fireEvent.click(screen.getByText('Only your threads'))
expect(screen.queryByText('others resolved thread')).not.toBeInTheDocument()
expect(screen.getByText('my open thread')).toBeInTheDocument()
fireEvent.click(screen.getByText('my open thread'))
expect(mockHandleCommentIconClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'c-1' }))
})
it('resolves a comment and syncs list refresh', async () => {
const { container } = render(<CommentsPanel />)
const resolveIcons = container.querySelectorAll('.h-4.w-4.cursor-pointer.text-text-tertiary')
expect(resolveIcons.length).toBeGreaterThan(0)
fireEvent.click(resolveIcons[0]!)
await waitFor(() => {
expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', 'c-1')
expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1')
expect(mockLoadComments).toHaveBeenCalled()
expect(mockSetActiveCommentId).toHaveBeenCalledWith('c-1')
})
})
it('toggles show-resolved state from filter panel switch', () => {
render(<CommentsPanel />)
fireEvent.click(screen.getByLabelText('Filter comments'))
fireEvent.click(screen.getByTestId('show-resolved-switch'))
expect(mockSetShowResolvedComments).toHaveBeenCalledWith(false)
})
})