test(chat): enhance unit tests for chat hooks and answer component visibility

This commit is contained in:
twwu 2026-04-20 17:52:45 +08:00
parent 45b0751826
commit a25e3b66c1
4 changed files with 249 additions and 1 deletions

View File

@ -1836,6 +1836,82 @@ describe('useChatWithHistory', () => {
expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1)
expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1)
})
it('should pass through workflow_run_id from item and created_at', async () => {
const listData = createConversationData({
data: [createConversationItem({ id: 'conversation-1' })],
})
mockFetchConversations.mockResolvedValue(listData)
mockFetchChatList.mockResolvedValue({
data: [
{
id: 'msg-running',
query: 'Running query',
answer: 'Running answer',
message_files: [],
feedback: null,
retriever_resources: [],
agent_thoughts: null,
parent_message_id: null,
inputs: {},
status: 'normal',
workflow_run_id: 'wf-direct-id',
created_at: 1700000000,
},
],
})
const { result } = await renderWithClient(() => useChatWithHistory())
await waitFor(() => {
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
})
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
expect(answerNode?.workflow_run_id).toBe('wf-direct-id')
expect(answerNode?.created_at).toBe(1700000000)
})
it('should prefer item.workflow_run_id over extra_contents workflow_run_id', async () => {
const listData = createConversationData({
data: [createConversationItem({ id: 'conversation-1' })],
})
mockFetchConversations.mockResolvedValue(listData)
mockFetchChatList.mockResolvedValue({
data: [
{
id: 'msg-both',
query: 'Both query',
answer: 'Both answer',
message_files: [],
feedback: null,
retriever_resources: [],
agent_thoughts: null,
parent_message_id: null,
inputs: {},
status: 'paused',
workflow_run_id: 'wf-item-level',
extra_contents: [
{
type: 'human_input',
submitted: false,
form_definition: { fields: [] },
workflow_run_id: 'wf-extra-level',
},
],
},
],
})
const { result } = await renderWithClient(() => useChatWithHistory())
await waitFor(() => {
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
})
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
expect(answerNode?.workflow_run_id).toBe('wf-item-level')
})
})
// Scenario: newConversation merge replaces existing conversation item when id already exists.

View File

@ -1292,7 +1292,7 @@ describe('useChat', () => {
})
expect(sseGet).toHaveBeenCalledWith(
'/workflow/wr-reconnect/events?include_state_snapshot=true',
'/workflow/wr-reconnect/events?include_state_snapshot=true&replay=true',
expect.any(Object),
expect.any(Object),
)
@ -1540,6 +1540,89 @@ describe('useChat', () => {
})
})
describe('onWorkflowStarted re-enables responding in handleSend', () => {
it('should set isResponding back to true when onWorkflowStarted fires after stop', () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const stopChat = vi.fn()
const { result } = renderHook(() => useChat(undefined, undefined, undefined, stopChat))
act(() => {
result.current.handleSend('test-url', { query: 'workflow restart' }, {})
})
expect(result.current.isResponding).toBe(true)
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
callbacks.onData('part', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' })
})
act(() => {
result.current.handleStop()
})
expect(result.current.isResponding).toBe(false)
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-2', task_id: 't-2' })
})
expect(result.current.isResponding).toBe(true)
})
})
describe('abortInflightRequests on unmount', () => {
it('should abort all in-flight requests when the hook unmounts', () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const workflowAbort = createAbortControllerMock()
const conversationAbort = createAbortControllerMock()
const suggestedAbort = createAbortControllerMock()
const config = { suggested_questions_after_answer: { enabled: true } }
const onGetConversationMessages = vi.fn().mockImplementation(async (_id: string, setAbort: (ac: AbortController) => void) => {
setAbort(conversationAbort)
return {
data: [{
id: 'm-1',
answer: 'a',
message: [{ role: 'assistant', text: 'a' }],
created_at: Date.now(),
answer_tokens: 1,
message_tokens: 1,
provider_response_latency: 0.1,
inputs: {},
query: 'q',
}],
}
})
const onGetSuggestedQuestions = vi.fn().mockImplementation(async (_id: string, setAbort: (ac: AbortController) => void) => {
setAbort(suggestedAbort)
return { data: [] }
})
const { result, unmount } = renderHook(() => useChat(config as ChatConfig))
act(() => {
result.current.handleSend('test-url', { query: 'unmount' }, {
onGetConversationMessages,
onGetSuggestedQuestions,
})
})
act(() => {
callbacks.getAbortController(workflowAbort)
})
unmount()
expect(workflowAbort.abort).toHaveBeenCalled()
})
})
describe('annotations and siblings', () => {
const prevChatTree = [{
id: 'q-1',

View File

@ -143,6 +143,67 @@ describe('Answer Component', () => {
})
})
describe('ContentSwitch visibility while responding', () => {
it('should hide ContentSwitch when responding in non-human-inputs layout', () => {
render(
<Answer
{...defaultProps}
responding={true}
item={{
...defaultProps.item,
siblingCount: 3,
siblingIndex: 1,
prevSibling: 'msg-0',
nextSibling: 'msg-2',
} as unknown as ChatItem}
switchSibling={vi.fn()}
/>,
)
expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument()
})
it('should hide ContentSwitch when responding in human-inputs layout with content', () => {
render(
<Answer
{...defaultProps}
responding={true}
item={{
...defaultProps.item,
content: 'partial response',
siblingCount: 3,
siblingIndex: 1,
prevSibling: 'msg-0',
nextSibling: 'msg-2',
humanInputFormDataList: [{ id: 'form1' }],
} as unknown as ChatItem}
switchSibling={vi.fn()}
/>,
)
expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument()
})
it('should show ContentSwitch when not responding with siblings', () => {
render(
<Answer
{...defaultProps}
responding={false}
item={{
...defaultProps.item,
siblingCount: 3,
siblingIndex: 1,
prevSibling: 'msg-0',
nextSibling: 'msg-2',
} as unknown as ChatItem}
switchSibling={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Previous' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should handle switch sibling', () => {
const mockSwitchSibling = vi.fn()

View File

@ -834,6 +834,34 @@ describe('useEmbeddedChatbot', () => {
const question = chatList.find((m: unknown) => (m as Record<string, unknown>).id === 'question-msg-no-files')
expect(question).toBeDefined()
})
it('should pass through workflow_run_id and created_at from message items', async () => {
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { DEFAULT: 'conversation-1' } }))
mockFetchConversations.mockResolvedValue(
createConversationData({ data: [createConversationItem({ id: 'conversation-1' })] }),
)
mockFetchChatList.mockResolvedValue({
data: [{
id: 'msg-wf',
query: 'Running workflow',
answer: 'Partial',
workflow_run_id: 'wf-embedded-1',
created_at: 1700000000,
}],
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => expect(result.current.appPrevChatList.length).toBeGreaterThan(0), { timeout: 3000 })
const questionNode = result.current.appPrevChatList.find(
(m: unknown) => (m as Record<string, unknown>).id === 'question-msg-wf',
) as Record<string, unknown> | undefined
expect(questionNode).toBeDefined()
const answerNode = (questionNode!.children as Record<string, unknown>[])?.[0]
expect(answerNode).toBeDefined()
expect(answerNode!.workflow_run_id).toBe('wf-embedded-1')
expect(answerNode!.created_at).toBe(1700000000)
})
})
describe('currentConversationItem from pinned list', () => {