From a25e3b66c12e58bf925fce2888dc224be9338148 Mon Sep 17 00:00:00 2001 From: twwu Date: Mon, 20 Apr 2026 17:52:45 +0800 Subject: [PATCH] test(chat): enhance unit tests for chat hooks and answer component visibility --- .../__tests__/hooks.spec.tsx | 76 +++++++++++++++++ .../base/chat/chat/__tests__/hooks.spec.tsx | 85 ++++++++++++++++++- .../chat/chat/answer/__tests__/index.spec.tsx | 61 +++++++++++++ .../embedded-chatbot/__tests__/hooks.spec.tsx | 28 ++++++ 4 files changed, 249 insertions(+), 1 deletion(-) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index f4c8ef0c45..5d62fd5f6e 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -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. diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index 9894c65dab..304ca99a9c 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -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', diff --git a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx index 3a9ddf4d5a..f5993f2797 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx @@ -143,6 +143,67 @@ describe('Answer Component', () => { }) }) + describe('ContentSwitch visibility while responding', () => { + it('should hide ContentSwitch when responding in non-human-inputs layout', () => { + render( + , + ) + 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( + , + ) + 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( + , + ) + 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() diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx index 7ba8df7fa1..1881e1ff26 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx @@ -834,6 +834,34 @@ describe('useEmbeddedChatbot', () => { const question = chatList.find((m: unknown) => (m as Record).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).id === 'question-msg-wf', + ) as Record | undefined + expect(questionNode).toBeDefined() + const answerNode = (questionNode!.children as Record[])?.[0] + expect(answerNode).toBeDefined() + expect(answerNode!.workflow_run_id).toBe('wf-embedded-1') + expect(answerNode!.created_at).toBe(1700000000) + }) }) describe('currentConversationItem from pinned list', () => {