test: enhance useChat hook tests with additional scenarios (#33928)

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Wu Tianwei 2026-03-24 14:19:32 +08:00 committed by GitHub
parent b0920ecd17
commit 508350ec6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 2831 additions and 15 deletions

View File

@ -0,0 +1,194 @@
/* eslint-disable ts/no-explicit-any */
import { act, renderHook } from '@testing-library/react'
import { useChat } from '../../hooks'
const mockHandleRun = vi.fn()
const mockNotify = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockSetIterTimes = vi.fn()
const mockSetLoopTimes = vi.fn()
const mockSubmitHumanInputForm = vi.fn()
const mockSseGet = vi.fn()
const mockGetNodes = vi.fn((): any[] => [])
let mockWorkflowRunningData: any = null
vi.mock('@/service/base', () => ({
sseGet: (...args: any[]) => mockSseGet(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
}))
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('../../../../hooks', () => ({
useWorkflowRun: () => ({ handleRun: mockHandleRun }),
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }),
}))
vi.mock('../../../../hooks-store', () => ({
useHooksStore: () => null,
}))
vi.mock('../../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setIterTimes: mockSetIterTimes,
setLoopTimes: mockSetLoopTimes,
inputs: {},
workflowRunningData: mockWorkflowRunningData,
}),
}),
useStore: () => vi.fn(),
}))
const resetMocksAndWorkflowState = () => {
vi.clearAllMocks()
mockWorkflowRunningData = null
}
describe('useChat handleSend', () => {
beforeEach(() => {
resetMocksAndWorkflowState()
mockHandleRun.mockReset()
})
it('should call handleRun with processed params', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'hello', inputs: {} }, {})
})
expect(mockHandleRun).toHaveBeenCalledTimes(1)
const [bodyParams] = mockHandleRun.mock.calls[0]
expect(bodyParams.query).toBe('hello')
})
it('should show notification and return false when already responding', () => {
mockHandleRun.mockImplementation(() => {})
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'first' }, {})
})
act(() => {
const returned = result.current.handleSend({ query: 'second' }, {})
expect(returned).toBe(false)
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should set isResponding to true after sending', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'hello' }, {})
})
expect(result.current.isResponding).toBe(true)
})
it('should add placeholder question and answer to chatList', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'test question' }, {})
})
const questionItem = result.current.chatList.find(item => item.content === 'test question')
expect(questionItem).toBeDefined()
expect(questionItem!.isAnswer).toBe(false)
const answerPlaceholder = result.current.chatList.find(
item => item.isAnswer && !item.isOpeningStatement && item.content === '',
)
expect(answerPlaceholder).toBeDefined()
})
it('should strip url from local_file transfer method files', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend(
{
query: 'hello',
files: [
{
id: 'f1',
name: 'test.png',
size: 1024,
type: 'image/png',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'image',
url: 'blob://local',
uploadedId: 'up1',
},
{
id: 'f2',
name: 'remote.png',
size: 2048,
type: 'image/png',
progress: 100,
transferMethod: 'remote_url',
supportFileType: 'image',
url: 'https://example.com/img.png',
uploadedId: '',
},
] as any,
},
{},
)
})
expect(mockHandleRun).toHaveBeenCalledTimes(1)
const [bodyParams] = mockHandleRun.mock.calls[0]
const localFile = bodyParams.files.find((f: any) => f.transfer_method === 'local_file')
const remoteFile = bodyParams.files.find((f: any) => f.transfer_method === 'remote_url')
expect(localFile.url).toBe('')
expect(remoteFile.url).toBe('https://example.com/img.png')
})
it('should abort previous workflowEventsAbortController before sending', () => {
const mockAbort = vi.fn()
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
callbacks.getAbortController({ abort: mockAbort } as any)
callbacks.onCompleted(false)
})
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'first' }, {})
})
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
callbacks.getAbortController({ abort: vi.fn() } as any)
})
act(() => {
result.current.handleSend({ query: 'second' }, {})
})
expect(mockAbort).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,199 @@
/* eslint-disable ts/no-explicit-any */
import { act, renderHook } from '@testing-library/react'
import { useChat } from '../../hooks'
const mockHandleRun = vi.fn()
const mockNotify = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockSetIterTimes = vi.fn()
const mockSetLoopTimes = vi.fn()
const mockSubmitHumanInputForm = vi.fn()
const mockSseGet = vi.fn()
const mockStopChat = vi.fn()
const mockGetNodes = vi.fn((): any[] => [])
let mockWorkflowRunningData: any = null
vi.mock('@/service/base', () => ({
sseGet: (...args: any[]) => mockSseGet(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
}))
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('../../../../hooks', () => ({
useWorkflowRun: () => ({ handleRun: mockHandleRun }),
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }),
}))
vi.mock('../../../../hooks-store', () => ({
useHooksStore: () => null,
}))
vi.mock('../../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setIterTimes: mockSetIterTimes,
setLoopTimes: mockSetLoopTimes,
inputs: {},
workflowRunningData: mockWorkflowRunningData,
}),
}),
useStore: () => vi.fn(),
}))
const resetMocksAndWorkflowState = () => {
vi.clearAllMocks()
mockWorkflowRunningData = null
}
describe('useChat handleStop', () => {
beforeEach(() => {
resetMocksAndWorkflowState()
})
it('should set isResponding to false', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleStop()
})
expect(result.current.isResponding).toBe(false)
})
it('should not call stopChat when taskId is empty even if stopChat is provided', () => {
const { result } = renderHook(() => useChat({}, undefined, undefined, mockStopChat))
act(() => {
result.current.handleStop()
})
expect(mockStopChat).not.toHaveBeenCalled()
})
it('should reset iter/loop times to defaults', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleStop()
})
expect(mockSetIterTimes).toHaveBeenCalledWith(1)
expect(mockSetLoopTimes).toHaveBeenCalledWith(1)
})
it('should abort workflowEventsAbortController when set', () => {
const mockWfAbort = vi.fn()
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
callbacks.getAbortController({ abort: mockWfAbort } as any)
})
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'test' }, {})
})
act(() => {
result.current.handleStop()
})
expect(mockWfAbort).toHaveBeenCalledTimes(1)
})
it('should abort suggestedQuestionsAbortController when set', async () => {
const mockSqAbort = vi.fn()
let capturedCb: any
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
capturedCb = callbacks
})
const mockGetSuggested = vi.fn().mockImplementation((_id: string, getAbortCtrl: any) => {
getAbortCtrl({ abort: mockSqAbort } as any)
return Promise.resolve({ data: ['s'] })
})
const { result } = renderHook(() =>
useChat({ suggested_questions_after_answer: { enabled: true } }),
)
act(() => {
result.current.handleSend({ query: 'test' }, {
onGetSuggestedQuestions: mockGetSuggested,
})
})
await act(async () => {
await capturedCb.onCompleted(false)
})
act(() => {
result.current.handleStop()
})
expect(mockSqAbort).toHaveBeenCalledTimes(1)
})
it('should call stopChat with taskId when both are available', () => {
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
callbacks.onData('msg', true, {
conversationId: 'c1',
messageId: 'msg-1',
taskId: 'task-stop',
})
})
const { result } = renderHook(() => useChat({}, undefined, undefined, mockStopChat))
act(() => {
result.current.handleSend({ query: 'test' }, {})
})
act(() => {
result.current.handleStop()
})
expect(mockStopChat).toHaveBeenCalledWith('task-stop')
})
})
describe('useChat handleRestart', () => {
beforeEach(() => {
resetMocksAndWorkflowState()
})
it('should clear suggestedQuestions and set isResponding to false', () => {
const config = { opening_statement: 'Hello' }
const { result } = renderHook(() => useChat(config))
act(() => {
result.current.handleRestart()
})
expect(result.current.suggestedQuestions).toEqual([])
expect(result.current.isResponding).toBe(false)
})
it('should reset iter/loop times to defaults', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleRestart()
})
expect(mockSetIterTimes).toHaveBeenCalledWith(1)
expect(mockSetLoopTimes).toHaveBeenCalledWith(1)
})
})

View File

@ -0,0 +1,380 @@
/* eslint-disable ts/no-explicit-any */
import type { ChatItemInTree } from '@/app/components/base/chat/types'
import { act, renderHook } from '@testing-library/react'
import { useChat } from '../../hooks'
const mockHandleRun = vi.fn()
const mockNotify = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockSetIterTimes = vi.fn()
const mockSetLoopTimes = vi.fn()
const mockSubmitHumanInputForm = vi.fn()
const mockSseGet = vi.fn()
const mockGetNodes = vi.fn((): any[] => [])
let mockWorkflowRunningData: any = null
vi.mock('@/service/base', () => ({
sseGet: (...args: any[]) => mockSseGet(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
}))
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('../../../../hooks', () => ({
useWorkflowRun: () => ({ handleRun: mockHandleRun }),
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }),
}))
vi.mock('../../../../hooks-store', () => ({
useHooksStore: () => null,
}))
vi.mock('../../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setIterTimes: mockSetIterTimes,
setLoopTimes: mockSetLoopTimes,
inputs: {},
workflowRunningData: mockWorkflowRunningData,
}),
}),
useStore: () => vi.fn(),
}))
const resetMocksAndWorkflowState = () => {
vi.clearAllMocks()
mockWorkflowRunningData = null
}
describe('useChat handleSwitchSibling', () => {
beforeEach(() => {
resetMocksAndWorkflowState()
mockHandleRun.mockReset()
mockSseGet.mockReset()
})
it('should call handleResume when target has workflow_run_id and pending humanInputFormData', async () => {
let sendCallbacks: any
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
sendCallbacks = callbacks
})
mockSseGet.mockImplementation(() => {})
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'test' }, {})
})
act(() => {
sendCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-switch',
task_id: 'task-1',
conversation_id: null,
message_id: 'msg-switch',
})
})
act(() => {
sendCallbacks.onHumanInputRequired({
data: { node_id: 'human-n', form_token: 'ft-1' },
})
})
await act(async () => {
await sendCallbacks.onCompleted(false)
})
act(() => {
result.current.handleSwitchSibling('msg-switch', {})
})
expect(mockSseGet).toHaveBeenCalled()
})
it('should not call handleResume when target has no humanInputFormDataList', async () => {
let sendCallbacks: any
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
sendCallbacks = callbacks
})
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'test' }, {})
})
act(() => {
sendCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-switch',
task_id: 'task-1',
conversation_id: null,
message_id: 'msg-switch',
})
})
await act(async () => {
await sendCallbacks.onCompleted(false)
})
act(() => {
result.current.handleSwitchSibling('msg-switch', {})
})
expect(mockSseGet).not.toHaveBeenCalled()
})
it('should return undefined from findMessageInTree when not found', () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSwitchSibling('nonexistent-id', {})
})
expect(mockSseGet).not.toHaveBeenCalled()
})
it('should search children recursively in findMessageInTree', async () => {
let sendCallbacks: any
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
sendCallbacks = callbacks
})
mockSseGet.mockImplementation(() => {})
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'parent' }, {})
})
act(() => {
sendCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-1',
task_id: 'task-1',
conversation_id: null,
message_id: 'msg-parent',
})
})
await act(async () => {
await sendCallbacks.onCompleted(false)
})
act(() => {
result.current.handleSend({
query: 'child',
parent_message_id: 'msg-parent',
}, {})
})
act(() => {
sendCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-2',
task_id: 'task-2',
conversation_id: null,
message_id: 'msg-child',
})
})
act(() => {
sendCallbacks.onHumanInputRequired({
data: { node_id: 'h-child', form_token: 'ft-c' },
})
})
await act(async () => {
await sendCallbacks.onCompleted(false)
})
act(() => {
result.current.handleSwitchSibling('msg-child', {})
})
expect(mockSseGet).toHaveBeenCalled()
})
})
describe('useChat handleSubmitHumanInputForm', () => {
beforeEach(() => {
resetMocksAndWorkflowState()
mockSubmitHumanInputForm.mockResolvedValue({})
})
it('should call submitHumanInputForm with token and data', async () => {
const { result } = renderHook(() => useChat({}))
await act(async () => {
await result.current.handleSubmitHumanInputForm('token-123', { field: 'value' })
})
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-123', { field: 'value' })
})
})
describe('useChat getHumanInputNodeData', () => {
beforeEach(() => {
resetMocksAndWorkflowState()
mockGetNodes.mockReturnValue([])
})
it('should return the custom node matching the given nodeID', () => {
const mockNode = { id: 'node-1', type: 'custom', data: { title: 'Human Input' } }
mockGetNodes.mockReturnValue([
mockNode,
{ id: 'node-2', type: 'custom', data: { title: 'Other' } },
])
const { result } = renderHook(() => useChat({}))
const node = result.current.getHumanInputNodeData('node-1')
expect(node).toEqual(mockNode)
})
it('should return undefined when no matching node', () => {
mockGetNodes.mockReturnValue([{ id: 'node-2', type: 'custom', data: {} }])
const { result } = renderHook(() => useChat({}))
const node = result.current.getHumanInputNodeData('nonexistent')
expect(node).toBeUndefined()
})
it('should filter out non-custom nodes', () => {
mockGetNodes.mockReturnValue([
{ id: 'node-1', type: 'default', data: {} },
{ id: 'node-1', type: 'custom', data: { found: true } },
])
const { result } = renderHook(() => useChat({}))
const node = result.current.getHumanInputNodeData('node-1')
expect(node).toEqual({ id: 'node-1', type: 'custom', data: { found: true } })
})
})
describe('useChat conversationId and setTargetMessageId', () => {
beforeEach(() => {
resetMocksAndWorkflowState()
})
it('should initially be an empty string', () => {
const { result } = renderHook(() => useChat({}))
expect(result.current.conversationId).toBe('')
})
it('setTargetMessageId should change chatList thread path', () => {
const prevChatTree: ChatItemInTree[] = [
{
id: 'q1',
content: 'question 1',
isAnswer: false,
children: [
{
id: 'a1',
content: 'answer 1',
isAnswer: true,
children: [
{
id: 'q2-branch-a',
content: 'branch A question',
isAnswer: false,
children: [
{ id: 'a2-branch-a', content: 'branch A answer', isAnswer: true, children: [] },
],
},
{
id: 'q2-branch-b',
content: 'branch B question',
isAnswer: false,
children: [
{ id: 'a2-branch-b', content: 'branch B answer', isAnswer: true, children: [] },
],
},
],
},
],
},
]
const { result } = renderHook(() => useChat({}, undefined, prevChatTree))
const defaultList = result.current.chatList
expect(defaultList.some(item => item.id === 'a1')).toBe(true)
act(() => {
result.current.setTargetMessageId('a2-branch-a')
})
const listA = result.current.chatList
expect(listA.some(item => item.id === 'a2-branch-a')).toBe(true)
expect(listA.some(item => item.id === 'a2-branch-b')).toBe(false)
act(() => {
result.current.setTargetMessageId('a2-branch-b')
})
const listB = result.current.chatList
expect(listB.some(item => item.id === 'a2-branch-b')).toBe(true)
expect(listB.some(item => item.id === 'a2-branch-a')).toBe(false)
})
})
describe('useChat updateCurrentQAOnTree with parent_message_id', () => {
let capturedCallbacks: any
beforeEach(() => {
resetMocksAndWorkflowState()
mockHandleRun.mockReset()
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
capturedCallbacks = callbacks
})
})
it('should handle follow-up message with parent_message_id', async () => {
const { result } = renderHook(() => useChat({}))
act(() => {
result.current.handleSend({ query: 'first' }, {})
})
const firstCallbacks = capturedCallbacks
act(() => {
firstCallbacks.onData('answer1', true, {
conversationId: 'c1',
messageId: 'msg-1',
taskId: 't1',
})
})
await act(async () => {
await firstCallbacks.onCompleted(false)
})
act(() => {
result.current.handleSend({
query: 'follow up',
parent_message_id: 'msg-1',
}, {})
})
expect(mockHandleRun).toHaveBeenCalledTimes(2)
expect(result.current.chatList.length).toBeGreaterThan(0)
})
})

View File

@ -1,50 +1,73 @@
/* eslint-disable ts/no-explicit-any */
import type { ChatItemInTree } from '@/app/components/base/chat/types' import type { ChatItemInTree } from '@/app/components/base/chat/types'
import { renderHook } from '@testing-library/react' import { renderHook } from '@testing-library/react'
import { useChat } from '../hooks' import { useChat } from '../../hooks'
const mockHandleRun = vi.fn()
const mockNotify = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockSetIterTimes = vi.fn()
const mockSetLoopTimes = vi.fn()
const mockSubmitHumanInputForm = vi.fn()
const mockSseGet = vi.fn()
const mockGetNodes = vi.fn((): any[] => [])
let mockWorkflowRunningData: any = null
vi.mock('@/service/base', () => ({ vi.mock('@/service/base', () => ({
sseGet: vi.fn(), sseGet: (...args: any[]) => mockSseGet(...args),
})) }))
vi.mock('@/service/use-workflow', () => ({ vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => vi.fn(), useInvalidAllLastRun: () => mockInvalidAllLastRun,
})) }))
vi.mock('@/service/workflow', () => ({ vi.mock('@/service/workflow', () => ({
submitHumanInputForm: vi.fn(), submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args),
})) }))
vi.mock('@/app/components/base/toast/context', () => ({ vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: vi.fn() }), useToastContext: () => ({ notify: mockNotify }),
})) }))
vi.mock('reactflow', () => ({ vi.mock('reactflow', () => ({
useStoreApi: () => ({ getState: () => ({}) }), useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
})) }))
vi.mock('../../../hooks', () => ({ vi.mock('../../../../hooks', () => ({
useWorkflowRun: () => ({ handleRun: vi.fn() }), useWorkflowRun: () => ({ handleRun: mockHandleRun }),
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn() }), useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }),
})) }))
vi.mock('../../../hooks-store', () => ({ vi.mock('../../../../hooks-store', () => ({
useHooksStore: () => null, useHooksStore: () => null,
})) }))
vi.mock('../../../store', () => ({ vi.mock('../../../../store', () => ({
useWorkflowStore: () => ({ useWorkflowStore: () => ({
getState: () => ({ getState: () => ({
setIterTimes: vi.fn(), setIterTimes: mockSetIterTimes,
setLoopTimes: vi.fn(), setLoopTimes: mockSetLoopTimes,
inputs: {}, inputs: {},
workflowRunningData: mockWorkflowRunningData,
}), }),
}), }),
useStore: () => vi.fn(), useStore: () => vi.fn(),
})) }))
const resetMocksAndWorkflowState = () => {
vi.clearAllMocks()
mockWorkflowRunningData = null
}
describe('workflow debug useChat opening statement stability', () => { describe('workflow debug useChat opening statement stability', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() resetMocksAndWorkflowState()
}) })
it('should return empty chatList when config has no opening_statement', () => { it('should return empty chatList when config has no opening_statement', () => {
@ -59,7 +82,6 @@ describe('workflow debug useChat opening statement stability', () => {
it('should use stable id "opening-statement" instead of Date.now()', () => { it('should use stable id "opening-statement" instead of Date.now()', () => {
const config = { opening_statement: 'Welcome!' } const config = { opening_statement: 'Welcome!' }
const { result } = renderHook(() => useChat(config)) const { result } = renderHook(() => useChat(config))
expect(result.current.chatList[0].id).toBe('opening-statement') expect(result.current.chatList[0].id).toBe('opening-statement')
}) })
@ -132,4 +154,21 @@ describe('workflow debug useChat opening statement stability', () => {
const openerAfter = result.current.chatList[0] const openerAfter = result.current.chatList[0]
expect(openerAfter).toBe(openerBefore) expect(openerAfter).toBe(openerBefore)
}) })
it('should include suggestedQuestions in opening statement when config has them', () => {
const config = {
opening_statement: 'Welcome!',
suggested_questions: ['How are you?', 'What can you do?'],
}
const { result } = renderHook(() => useChat(config))
const opener = result.current.chatList[0]
expect(opener.suggestedQuestions).toEqual(['How are you?', 'What can you do?'])
})
it('should not include suggestedQuestions when config has none', () => {
const config = { opening_statement: 'Welcome!' }
const { result } = renderHook(() => useChat(config))
const opener = result.current.chatList[0]
expect(opener.suggestedQuestions).toBeUndefined()
})
}) })

View File

@ -0,0 +1,914 @@
/* eslint-disable ts/no-explicit-any */
import { act, renderHook } from '@testing-library/react'
import { useChat } from '../../hooks'
const mockHandleRun = vi.fn()
const mockNotify = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockSetIterTimes = vi.fn()
const mockSetLoopTimes = vi.fn()
const mockSubmitHumanInputForm = vi.fn()
const mockSseGet = vi.fn()
const mockGetNodes = vi.fn((): any[] => [])
let mockWorkflowRunningData: any = null
vi.mock('@/service/base', () => ({
sseGet: (...args: any[]) => mockSseGet(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
}))
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('../../../../hooks', () => ({
useWorkflowRun: () => ({ handleRun: mockHandleRun }),
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }),
}))
vi.mock('../../../../hooks-store', () => ({
useHooksStore: () => null,
}))
vi.mock('../../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setIterTimes: mockSetIterTimes,
setLoopTimes: mockSetLoopTimes,
inputs: {},
workflowRunningData: mockWorkflowRunningData,
}),
}),
useStore: () => vi.fn(),
}))
const resetMocksAndWorkflowState = () => {
vi.clearAllMocks()
mockWorkflowRunningData = null
}
describe('useChat handleSend SSE callbacks', () => {
let capturedCallbacks: any
beforeEach(() => {
resetMocksAndWorkflowState()
mockHandleRun.mockReset()
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
capturedCallbacks = callbacks
})
})
function setupAndSend(config: any = {}) {
const hook = renderHook(() => useChat(config))
act(() => {
hook.result.current.handleSend({ query: 'test' }, {
onGetSuggestedQuestions: vi.fn().mockResolvedValue({ data: ['q1'] }),
})
})
return hook
}
function startWorkflow(overrides: Record<string, any> = {}) {
act(() => {
capturedCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-1',
task_id: 'task-1',
conversation_id: null,
message_id: null,
...overrides,
})
})
}
function startNode(nodeId: string, traceId: string, extra: Record<string, any> = {}) {
act(() => {
capturedCallbacks.onNodeStarted({
data: { node_id: nodeId, id: traceId, ...extra },
})
})
}
describe('onData', () => {
it('should append message content', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('Hello', true, {
conversationId: 'conv-1',
messageId: 'msg-1',
taskId: 'task-1',
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.content).toContain('Hello')
})
it('should set response id from messageId on first call', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('Hi', true, {
conversationId: 'conv-1',
messageId: 'msg-123',
taskId: 'task-1',
})
})
const answer = result.current.chatList.find(item => item.id === 'msg-123')
expect(answer).toBeDefined()
})
it('should set conversationId on first message with newConversationId', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('Hi', true, {
conversationId: 'new-conv-id',
messageId: 'msg-1',
taskId: 'task-1',
})
})
expect(result.current.conversationId).toBe('new-conv-id')
})
it('should not set conversationId when isFirstMessage is false', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('Hi', false, {
conversationId: 'conv-should-not-set',
messageId: 'msg-1',
taskId: 'task-1',
})
})
expect(result.current.conversationId).toBe('')
})
it('should not update hasSetResponseId when messageId is empty', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('msg1', true, {
conversationId: '',
messageId: '',
taskId: 'task-1',
})
})
act(() => {
capturedCallbacks.onData('msg2', false, {
conversationId: '',
messageId: 'late-id',
taskId: 'task-1',
})
})
const answer = result.current.chatList.find(item => item.id === 'late-id')
expect(answer).toBeDefined()
})
it('should only set hasSetResponseId once', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('msg1', true, {
conversationId: 'c1',
messageId: 'msg-first',
taskId: 'task-1',
})
})
act(() => {
capturedCallbacks.onData('msg2', false, {
conversationId: 'c1',
messageId: 'msg-second',
taskId: 'task-1',
})
})
const question = result.current.chatList.find(item => !item.isAnswer)
expect(question!.id).toBe('question-msg-first')
})
})
describe('onCompleted', () => {
it('should set isResponding to false', async () => {
const { result } = setupAndSend()
await act(async () => {
await capturedCallbacks.onCompleted(false)
})
expect(result.current.isResponding).toBe(false)
})
it('should call fetchInspectVars and invalidAllLastRun when not paused', async () => {
setupAndSend()
await act(async () => {
await capturedCallbacks.onCompleted(false)
})
expect(mockFetchInspectVars).toHaveBeenCalledWith({})
expect(mockInvalidAllLastRun).toHaveBeenCalled()
})
it('should not call fetchInspectVars when workflow is paused', async () => {
mockWorkflowRunningData = { result: { status: 'paused' } }
setupAndSend()
await act(async () => {
await capturedCallbacks.onCompleted(false)
})
expect(mockFetchInspectVars).not.toHaveBeenCalled()
})
it('should set error content on response item when hasError with errorMessage', async () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('partial', true, {
conversationId: 'c1',
messageId: 'msg-err',
taskId: 't1',
})
})
await act(async () => {
await capturedCallbacks.onCompleted(true, 'Something went wrong')
})
const answer = result.current.chatList.find(item => item.id === 'msg-err')
expect(answer!.content).toBe('Something went wrong')
expect(answer!.isError).toBe(true)
})
it('should not set error content when hasError is true but errorMessage is empty', async () => {
const { result } = setupAndSend()
await act(async () => {
await capturedCallbacks.onCompleted(true)
})
expect(result.current.isResponding).toBe(false)
})
it('should fetch suggested questions when enabled and invoke abort controller callback', async () => {
const mockGetSuggested = vi.fn().mockImplementation((_id: string, getAbortCtrl: any) => {
getAbortCtrl(new AbortController())
return Promise.resolve({ data: ['suggestion1'] })
})
const hook = renderHook(() =>
useChat({ suggested_questions_after_answer: { enabled: true } }),
)
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
capturedCallbacks = callbacks
})
act(() => {
hook.result.current.handleSend({ query: 'test' }, {
onGetSuggestedQuestions: mockGetSuggested,
})
})
await act(async () => {
await capturedCallbacks.onCompleted(false)
})
expect(mockGetSuggested).toHaveBeenCalled()
})
it('should set suggestedQuestions to empty array when fetch fails', async () => {
const mockGetSuggested = vi.fn().mockRejectedValue(new Error('fail'))
const hook = renderHook(() =>
useChat({ suggested_questions_after_answer: { enabled: true } }),
)
mockHandleRun.mockImplementation((_params: any, callbacks: any) => {
capturedCallbacks = callbacks
})
act(() => {
hook.result.current.handleSend({ query: 'test' }, {
onGetSuggestedQuestions: mockGetSuggested,
})
})
await act(async () => {
await capturedCallbacks.onCompleted(false)
})
expect(hook.result.current.suggestedQuestions).toEqual([])
})
})
describe('onError', () => {
it('should set isResponding to false', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onError()
})
expect(result.current.isResponding).toBe(false)
})
})
describe('onMessageEnd', () => {
it('should update citation and files', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('response', true, {
conversationId: 'c1',
messageId: 'msg-1',
taskId: 't1',
})
})
act(() => {
capturedCallbacks.onMessageEnd({
metadata: { retriever_resources: [{ id: 'r1' }] },
files: [],
})
})
const answer = result.current.chatList.find(item => item.id === 'msg-1')
expect(answer!.citation).toEqual([{ id: 'r1' }])
})
it('should default citation to empty array when no retriever_resources', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('response', true, {
conversationId: 'c1',
messageId: 'msg-1',
taskId: 't1',
})
})
act(() => {
capturedCallbacks.onMessageEnd({ metadata: {}, files: [] })
})
const answer = result.current.chatList.find(item => item.id === 'msg-1')
expect(answer!.citation).toEqual([])
})
})
describe('onMessageReplace', () => {
it('should replace answer content on responseItem', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onData('old', true, {
conversationId: 'c1',
messageId: 'msg-1',
taskId: 't1',
})
})
act(() => {
capturedCallbacks.onMessageReplace({ answer: 'replaced' })
})
act(() => {
capturedCallbacks.onMessageEnd({ metadata: {}, files: [] })
})
const answer = result.current.chatList.find(item => item.id === 'msg-1')
expect(answer!.content).toBe('replaced')
})
})
describe('onWorkflowStarted', () => {
it('should create workflow process with Running status', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-1',
task_id: 'task-1',
conversation_id: 'conv-1',
message_id: 'msg-1',
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.status).toBe('running')
expect(answer!.workflowProcess!.tracing).toEqual([])
})
it('should set conversationId when provided', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-1',
task_id: 'task-1',
conversation_id: 'from-workflow',
message_id: null,
})
})
expect(result.current.conversationId).toBe('from-workflow')
})
it('should not override existing conversationId when conversation_id is null', () => {
const { result } = setupAndSend()
startWorkflow()
expect(result.current.conversationId).toBe('')
})
it('should resume existing workflow process when tracing exists', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('n1', 'trace-1')
startWorkflow({ workflow_run_id: 'wfr-2', task_id: 'task-2' })
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.status).toBe('running')
expect(answer!.workflowProcess!.tracing.length).toBe(1)
})
it('should replace placeholder answer id with real message_id from server', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onWorkflowStarted({
workflow_run_id: 'wfr-1',
task_id: 'task-1',
conversation_id: null,
message_id: 'wf-msg-id',
})
})
const answer = result.current.chatList.find(item => item.id === 'wf-msg-id')
expect(answer).toBeDefined()
})
})
describe('onWorkflowFinished', () => {
it('should update workflow process status', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onWorkflowFinished({ data: { status: 'succeeded' } })
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.status).toBe('succeeded')
})
})
describe('onIterationStart / onIterationFinish', () => {
it('should push tracing entry on start', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onIterationStart({
data: { id: 'iter-1', node_id: 'n-iter' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('iter-1')
expect(trace.node_id).toBe('n-iter')
expect(trace.status).toBe('running')
})
it('should update matching tracing on finish', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onIterationStart({
data: { id: 'iter-1', node_id: 'n-iter' },
})
})
act(() => {
capturedCallbacks.onIterationFinish({
data: { id: 'iter-1', node_id: 'n-iter', output: 'done' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
const trace = answer!.workflowProcess!.tracing.find((t: any) => t.id === 'iter-1')
expect(trace).toBeDefined()
expect(trace!.node_id).toBe('n-iter')
expect((trace as any).output).toBe('done')
})
it('should not update tracing on finish when id does not match', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onIterationStart({
data: { id: 'iter-1', node_id: 'n-iter' },
})
})
act(() => {
capturedCallbacks.onIterationFinish({
data: { id: 'iter-nonexistent', node_id: 'n-other' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
expect((answer!.workflowProcess!.tracing[0] as any).output).toBeUndefined()
})
})
describe('onLoopStart / onLoopFinish', () => {
it('should push tracing entry on start', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onLoopStart({
data: { id: 'loop-1', node_id: 'n-loop' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('loop-1')
expect(trace.node_id).toBe('n-loop')
expect(trace.status).toBe('running')
})
it('should update matching tracing on finish', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onLoopStart({
data: { id: 'loop-1', node_id: 'n-loop' },
})
})
act(() => {
capturedCallbacks.onLoopFinish({
data: { id: 'loop-1', node_id: 'n-loop', output: 'done' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('loop-1')
expect(trace.node_id).toBe('n-loop')
expect((trace as any).output).toBe('done')
})
it('should not update tracing on finish when id does not match', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onLoopStart({
data: { id: 'loop-1', node_id: 'n-loop' },
})
})
act(() => {
capturedCallbacks.onLoopFinish({
data: { id: 'loop-nonexistent', node_id: 'n-other' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
expect((answer!.workflowProcess!.tracing[0] as any).output).toBeUndefined()
})
})
describe('onNodeStarted / onNodeRetry / onNodeFinished', () => {
it('should add new tracing entry', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('node-1', 'trace-1')
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('trace-1')
expect(trace.node_id).toBe('node-1')
expect(trace.status).toBe('running')
})
it('should update existing tracing entry with same node_id', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('node-1', 'trace-1')
startNode('node-1', 'trace-1-v2')
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('trace-1-v2')
expect(trace.node_id).toBe('node-1')
expect(trace.status).toBe('running')
})
it('should push retry data to tracing', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onNodeRetry({
data: { node_id: 'node-1', id: 'retry-1', retry_index: 1 },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('retry-1')
expect(trace.node_id).toBe('node-1')
expect((trace as any).retry_index).toBe(1)
})
it('should update tracing entry on finish by id', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('node-1', 'trace-1')
act(() => {
capturedCallbacks.onNodeFinished({
data: { node_id: 'node-1', id: 'trace-1', status: 'succeeded', outputs: { text: 'done' } },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('trace-1')
expect(trace.status).toBe('succeeded')
expect((trace as any).outputs).toEqual({ text: 'done' })
})
it('should not update tracing on finish when id does not match', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('node-1', 'trace-1')
act(() => {
capturedCallbacks.onNodeFinished({
data: { node_id: 'node-x', id: 'trace-x', status: 'succeeded' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.tracing).toHaveLength(1)
const trace = answer!.workflowProcess!.tracing[0]
expect(trace.id).toBe('trace-1')
expect(trace.status).toBe('running')
})
})
describe('onAgentLog', () => {
function setupWithNode() {
const hook = setupAndSend()
startWorkflow()
return hook
}
it('should create execution_metadata.agent_log when no execution_metadata exists', () => {
const { result } = setupWithNode()
startNode('agent-node', 'trace-agent')
act(() => {
capturedCallbacks.onAgentLog({
data: { node_id: 'agent-node', message_id: 'log-1', content: 'init' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node')
expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(1)
})
it('should create agent_log array when execution_metadata exists but no agent_log', () => {
const { result } = setupWithNode()
startNode('agent-node', 'trace-agent', { execution_metadata: { parallel_id: 'p1' } })
act(() => {
capturedCallbacks.onAgentLog({
data: { node_id: 'agent-node', message_id: 'log-1', content: 'step1' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node')
expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(1)
})
it('should update existing agent_log entry by message_id', () => {
const { result } = setupWithNode()
startNode('agent-node', 'trace-agent', {
execution_metadata: { agent_log: [{ message_id: 'log-1', content: 'v1' }] },
})
act(() => {
capturedCallbacks.onAgentLog({
data: { node_id: 'agent-node', message_id: 'log-1', content: 'v2' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node')
expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(1)
expect((agentTrace!.execution_metadata!.agent_log as any[])[0].content).toBe('v2')
})
it('should push new agent_log entry when message_id does not match', () => {
const { result } = setupWithNode()
startNode('agent-node', 'trace-agent', {
execution_metadata: { agent_log: [{ message_id: 'log-1', content: 'v1' }] },
})
act(() => {
capturedCallbacks.onAgentLog({
data: { node_id: 'agent-node', message_id: 'log-2', content: 'new' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node')
expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(2)
})
it('should not crash when node_id is not found in tracing', () => {
setupWithNode()
act(() => {
capturedCallbacks.onAgentLog({
data: { node_id: 'nonexistent-node', message_id: 'log-1', content: 'noop' },
})
})
})
})
describe('onHumanInputRequired', () => {
it('should add form data to humanInputFormDataList', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('human-node', 'trace-human')
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.humanInputFormDataList).toHaveLength(1)
expect(answer!.humanInputFormDataList![0].node_id).toBe('human-node')
expect((answer!.humanInputFormDataList![0] as any).form_token).toBe('token-1')
})
it('should update existing form for same node_id', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('human-node', 'trace-human')
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
})
})
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-2' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.humanInputFormDataList).toHaveLength(1)
expect((answer!.humanInputFormDataList![0] as any).form_token).toBe('token-2')
})
it('should push new form data for different node_id', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node-1', form_token: 'token-1' },
})
})
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node-2', form_token: 'token-2' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.humanInputFormDataList).toHaveLength(2)
expect(answer!.humanInputFormDataList![0].node_id).toBe('human-node-1')
expect(answer!.humanInputFormDataList![1].node_id).toBe('human-node-2')
})
it('should set tracing node status to Paused when tracing index found', () => {
const { result } = setupAndSend()
startWorkflow()
startNode('human-node', 'trace-human')
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
const trace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'human-node')
expect(trace!.status).toBe('paused')
})
})
describe('onHumanInputFormFilled', () => {
it('should remove form and add to filled list', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
})
})
act(() => {
capturedCallbacks.onHumanInputFormFilled({
data: { node_id: 'human-node', form_data: { answer: 'yes' } },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.humanInputFormDataList).toHaveLength(0)
expect(answer!.humanInputFilledFormDataList).toHaveLength(1)
expect(answer!.humanInputFilledFormDataList![0].node_id).toBe('human-node')
expect((answer!.humanInputFilledFormDataList![0] as any).form_data).toEqual({ answer: 'yes' })
})
})
describe('onHumanInputFormTimeout', () => {
it('should update expiration_time on form data', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
})
})
act(() => {
capturedCallbacks.onHumanInputFormTimeout({
data: { node_id: 'human-node', expiration_time: '2025-01-01T00:00:00Z' },
})
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
const form = answer!.humanInputFormDataList!.find((f: any) => f.node_id === 'human-node')
expect(form!.expiration_time).toBe('2025-01-01T00:00:00Z')
})
})
describe('onWorkflowPaused', () => {
it('should set status to Paused', () => {
const { result } = setupAndSend()
startWorkflow()
act(() => {
capturedCallbacks.onWorkflowPaused({ data: {} })
})
const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement)
expect(answer!.workflowProcess!.status).toBe('paused')
})
})
})