From 2050078a5fb55cdc90e6e2f043abbdd5baeff337 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Sat, 31 Oct 2026 03:24:16 +0000 Subject: [PATCH] test(web): replace HITL owner fakes and editor shims (#45189) --- .../__tests__/chat-wrapper.hitl-v2.spec.tsx | 292 ++++----- .../__tests__/chat-wrapper.hitl-v2.spec.tsx | 597 +++++------------- .../__tests__/message-template.spec.tsx | 429 +++++++------ .../human-input-v2/__tests__/panel.spec.tsx | 228 +++---- 4 files changed, 608 insertions(+), 938 deletions(-) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx index 5d1a57bf199..29a3a326297 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx @@ -1,118 +1,106 @@ -import type { ChatConfig } from '../../types' +import type { ChatConfig, ChatItemInTree } from '../../types' import type { ChatWithHistoryContextValue } from '../context' -import type { AppData, AppMeta, ConversationItem } from '@/models/share' +import type { AppData } from '@/models/share' import type { HumanInputFormData } from '@/types/workflow' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { onTestFinished } from 'vite-plus/test' +import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' +// oxlint-disable-next-line no-restricted-imports -- Exercise the existing chat SSE transport at its network boundary. +import { sseGet } from '@/service/base' import { submitWebAppHumanInputForm } from '@/service/webapp-human-input' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' -import { useChat } from '../../chat/hooks' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createChatConfigFixture } from '@/test/fixtures/chat' +import { + createHumanInputFormFixture, + createWorkflowProcessFixture, +} from '@/test/fixtures/workflow-process' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' import ChatWrapper from '../chat-wrapper' -import { useChatWithHistoryContext } from '../context' +import { ChatWithHistoryContext } from '../context' + +// oxlint-disable-next-line typescript/no-deprecated -- Existing workflow event transport boundary. +const workflowEvents = vi.mocked(sseGet) vi.mock('@/config', async (importOriginal) => ({ ...(await importOriginal()), ENABLE_HITL_IM: true, })) - -const mockTrackEvent = vi.hoisted(() => vi.fn()) - -vi.mock('@/app/components/base/amplitude/web-app-event', () => ({ - trackWebAppEvent: mockTrackEvent, -})) - -vi.mock('../../chat/hooks', () => ({ - useChat: vi.fn(), -})) - -vi.mock('../context', () => ({ - useChatWithHistoryContext: vi.fn(), -})) - vi.mock('@/next/navigation', () => ({ - useRouter: vi.fn(() => ({ - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - })), - usePathname: vi.fn(() => '/'), - useSearchParams: vi.fn(() => new URLSearchParams()), - useParams: vi.fn(() => ({ token: 'test-token' })), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/', + useSearchParams: () => new URLSearchParams(), + useParams: () => ({ token: 'test-token' }), })) - -vi.mock('../../utils', () => ({ - isValidGeneratedAnswer: vi.fn(), - getLastAnswer: vi.fn(), +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + sseGet: vi.fn(), + ssePost: vi.fn(), })) - -vi.mock('@/service/share', () => ({ - fetchChatList: vi.fn(), - fetchSuggestedQuestions: vi.fn(), - getUrl: vi.fn(() => 'mock-url'), - stopChatMessageResponding: vi.fn(), - submitHumanInputForm: vi.fn(), - AppSourceType: { - installedApp: 'installedApp', - webApp: 'webApp', - }, -})) - -vi.mock('@/service/workflow', () => ({ - submitHumanInputForm: vi.fn(), -})) - vi.mock('@/service/webapp-human-input', () => ({ - submitWebAppHumanInputForm: vi.fn(), + submitWebAppHumanInputForm: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/app/components/base/markdown', () => ({ - Markdown: ({ content }: { content: string }) =>
{content}
, -})) +const createForm = (formVersion: HumanInputFormData['form_version']): HumanInputFormData => + createHumanInputFormFixture({ + form_version: formVersion, + form_token: 'form-token', + node_title: 'Approval', + form_content: '{{#$output.review#}}', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'review', + default: { type: 'constant', value: '', selector: [] }, + }, + ], + actions: [{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }], + }) -vi.mock('@/utils/model-config', () => ({ - formatBooleanInputs: vi.fn((forms, inputs) => inputs), -})) - -vi.mock('@/hooks/use-timestamp', () => ({ - default: () => ({ - formatTime: (timestamp: number) => `formatted-${timestamp}`, - formatDate: (value: string) => `formatted-${value}`, - }), -})) - -type ChatHookReturn = ReturnType - -const mockAppData = { - mode: 'advanced-chat', - site: { - title: 'Test Chat', - chat_color_theme: 'blue', - icon_type: 'image', - icon: 'test-icon', - icon_background: '#000000', - icon_url: 'https://example.com/icon.png', - use_icon_as_answer_icon: false, +const pausedConversation = (formVersion: HumanInputFormData['form_version']): ChatItemInTree[] => [ + { + id: 'question', + content: 'Please review', + isAnswer: false, + children: [ + { + id: 'paused-answer', + content: '', + isAnswer: true, + parentMessageId: 'question', + workflow_run_id: 'run-1', + workflowProcess: createWorkflowProcessFixture({ status: WorkflowRunningStatus.Paused }), + humanInputFormDataList: [createForm(formVersion)], + }, + ], }, -} as unknown as AppData +] -const defaultContextValue: ChatWithHistoryContextValue = { - appData: mockAppData, - appParams: { - system_parameters: { vision_config: { enabled: true } }, - opening_statement: 'Default opening statement', - } as unknown as ChatConfig, - appMeta: { tool_icons: {} } as unknown as AppMeta, - currentConversationId: '1', - currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem, - appPrevChatTree: [], +const createContext = ( + formVersion: HumanInputFormData['form_version'], +): ChatWithHistoryContextValue => ({ + appData: { + app_id: 'app-1', + mode: 'advanced-chat', + custom_config: null, + site: { title: 'Test Chat' }, + } as AppData, + appParams: createChatConfigFixture() as ChatConfig, + appMeta: { tool_icons: {} }, + currentConversationId: 'conversation-1', + currentConversationItem: { + id: 'conversation-1', + name: 'Conversation', + inputs: {}, + introduction: '', + }, + appPrevChatTree: pausedConversation(formVersion), newConversationInputs: {}, - newConversationInputsRef: { - current: {}, - } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { current: {} }, inputsForms: [], isInstalledApp: false, - currentChatInstanceRef: { - current: { handleStop: vi.fn() }, - } as ChatWithHistoryContextValue['currentChatInstanceRef'], + currentChatInstanceRef: { current: { handleStop: vi.fn() } }, setIsResponding: vi.fn(), setClearChatList: vi.fn(), appChatListDataLoading: false, @@ -126,99 +114,65 @@ const defaultContextValue: ChatWithHistoryContextValue = { handleRenameConversation: vi.fn(), handleNewConversation: vi.fn(), handleNewConversationInputsChange: vi.fn(), - handleStartChat: vi.fn(), + handleStartChat: (callback) => callback?.(), handleChangeConversation: vi.fn(), handleNewConversationCompleted: vi.fn(), - handleFeedback: vi.fn(), + handleFeedback: vi.fn().mockResolvedValue(undefined), pinnedConversationList: [], chatShouldReloadKey: '', isMobile: false, - currentConversationInputs: null, + currentConversationInputs: {}, setCurrentConversationInputs: vi.fn(), allInputsHidden: false, - initUserVariables: undefined, - appId: 'test-app-id', + appId: 'app-1', +}) + +const renderSubject = (formVersion: HumanInputFormData['form_version']) => { + renderWithConsoleQuery( + // oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its Provider component. + + + , + ) + return userEvent.setup() } -const defaultChatHookReturn: Partial = { - chatList: [], - handleSend: vi.fn(), - handleStop: vi.fn(), - handleSwitchSibling: vi.fn(), - prepareHumanInputSubmission: vi.fn().mockResolvedValue(true), - isResponding: false, - suggestedQuestions: [], -} - -describe('ChatWrapper', () => { +describe('ChatWithHistory human-input submission', () => { beforeEach(() => { - vi.clearAllMocks() - vi.mocked(useChatWithHistoryContext).mockReturnValue(defaultContextValue) - vi.mocked(useChat).mockReturnValue(defaultChatHookReturn as ChatHookReturn) + const stopGuard = guardUnexpectedFetch() + onTestFinished(stopGuard) }) it.each(['1', '2', undefined] as const)( - 'submits Web App forms with event version %s', + 'submits Web App form version %s only after the real workflow stream is ready', async (formVersion) => { - vi.mocked(useChatWithHistoryContext).mockReturnValue({ - ...defaultContextValue, - isInstalledApp: false, - }) + const user = renderSubject(formVersion) + await user.type(await screen.findByRole('textbox', { name: 'review' }), 'Approved') + await user.click(screen.getByRole('button', { name: 'Approve' })) + expect(submitWebAppHumanInputForm).not.toHaveBeenCalled() - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'q1', content: 'Question' }, - { - id: 'a1', - isAnswer: true, - content: '', - humanInputFormDataList: [ - { - id: 'node1', - form_id: 'form1', - form_token: 'token-web-1', - form_version: formVersion, - node_id: 'node1', - node_title: 'Node Web 1', - display_in_ui: true, - form_content: '{{#$output.test#}}', - inputs: [ - { - variable: 'test', - label: 'Test', - type: 'paragraph', - required: true, - output_variable_name: 'test', - default: { type: 'text', value: '' }, - }, - ], - actions: [{ id: 'run', title: 'Run', button_style: 'primary' }], - }, - ] as unknown as HumanInputFormData[], + await waitFor(() => expect(workflowEvents).toHaveBeenCalledOnce()) + act(() => + workflowEvents.mock.calls[0]![2].onWorkflowPaused?.({ + task_id: 'task-1', + workflow_run_id: 'run-1', + event: 'workflow_paused', + data: { + workflow_run_id: 'run-1', + outputs: {}, + paused_nodes: ['human-input'], + reasons: [], }, - ], - } as unknown as ChatHookReturn) + }), + ) - render() - expect(await screen.findByText('Node Web 1'))!.toBeInTheDocument() - - const input = - screen.getAllByRole('textbox').find((el) => el.closest('.chat-answer-container')) || - screen.getAllByRole('textbox')[0] - fireEvent.change(input!, { target: { value: 'web-test' } }) - fireEvent.click(screen.getByText('Run')) - - await waitFor(() => { - expect(submitWebAppHumanInputForm).toHaveBeenCalledWith( - 'token-web-1', - { - inputs: { test: 'web-test' }, - action: 'run', - }, + await waitFor(() => + expect(submitWebAppHumanInputForm).toHaveBeenCalledExactlyOnceWith( + 'form-token', + { inputs: { review: 'Approved' }, action: 'approve' }, formVersion, - ) - }) + ), + ) }, ) }) diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.hitl-v2.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.hitl-v2.spec.tsx index 516e21ebd61..43463eb5687 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.hitl-v2.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.hitl-v2.spec.tsx @@ -1,452 +1,197 @@ -import type { HumanInputFieldValue } from '../../chat/answer/human-input-content/field-renderer' -import type { ChatConfig, ChatItem, ChatItemInTree } from '../../types' +import type { ChatItemInTree } from '../../types' import type { EmbeddedChatbotContextValue } from '../context' import type { HumanInputFormData } from '@/types/workflow' -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { InputVarType } from '@/app/components/workflow/types' -import { AppSourceType, fetchSuggestedQuestions, submitHumanInputForm } from '@/service/share' +import { onTestFinished } from 'vite-plus/test' +import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' +// oxlint-disable-next-line no-restricted-imports -- Exercise the existing chat SSE transport at its network boundary. +import { sseGet } from '@/service/base' +import { AppSourceType, submitHumanInputForm } from '@/service/share' import { submitWebAppHumanInputForm } from '@/service/webapp-human-input' -import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' -import { useChat } from '../../chat/hooks' +import { submitHumanInputForm as submitInstalledHumanInput } from '@/service/workflow' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createChatConfigFixture } from '@/test/fixtures/chat' +import { createEmbeddedChatbotContextFixture } from '@/test/fixtures/embedded-chatbot' +import { + createHumanInputFormFixture, + createWorkflowProcessFixture, +} from '@/test/fixtures/workflow-process' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' import ChatWrapper from '../chat-wrapper' -import { useEmbeddedChatbotContext } from '../context' +import { EmbeddedChatbotContext } from '../context' -const mockTrackEvent = vi.hoisted(() => vi.fn()) +// oxlint-disable-next-line typescript/no-deprecated -- Existing workflow event transport boundary. +const workflowEvents = vi.mocked(sseGet) -const createHumanInputForm = ( - formVersion: HumanInputFormData['form_version'], -): HumanInputFormData => ({ - form_version: formVersion, - form_id: 'form-id', - form_token: 'form-token', - node_id: 'human-node', - node_title: 'Review', - form_content: 'Review', - inputs: [], - actions: [], - resolved_default_values: {}, - display_in_ui: true, - expiration_time: null, -}) - -vi.mock('@/app/components/base/amplitude/web-app-event', () => ({ - trackWebAppEvent: mockTrackEvent, +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + ENABLE_HITL_IM: true, })) - -vi.mock('../context', () => ({ - useEmbeddedChatbotContext: vi.fn(), +vi.mock('@/next/navigation', () => ({ + useParams: () => ({ token: 'share-code' }), + usePathname: () => '/chat/share-code', })) - -vi.mock('../../chat/hooks', () => ({ - useChat: vi.fn(), +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + sseGet: vi.fn(), + ssePost: vi.fn(), })) - -vi.mock('../inputs-form', () => ({ - __esModule: true, - default: () =>
inputs form
, +vi.mock('@/service/share', async (importOriginal) => ({ + ...(await importOriginal()), + submitHumanInputForm: vi.fn().mockResolvedValue(undefined), })) - -vi.mock('@/app/components/base/markdown', () => ({ - Markdown: ({ content }: { content: string }) =>
{content}
, +vi.mock('@/service/workflow', async (importOriginal) => ({ + ...(await importOriginal()), + submitHumanInputForm: vi.fn().mockResolvedValue(undefined), })) - -vi.mock('../../chat', () => ({ - __esModule: true, - default: ({ - chatNode, - chatList, - inputDisabled, - questionIcon, - answerIcon, - onSend, - onRegenerate, - switchSibling, - onHumanInputFormSubmit, - onStopResponding, - }: { - chatNode: React.ReactNode - chatList: ChatItem[] - inputDisabled: boolean - questionIcon?: React.ReactNode - answerIcon?: React.ReactNode - onSend: (message: string) => void - onRegenerate: ( - chatItem: ChatItem, - editedQuestion?: { message: string; files?: never[] }, - ) => void - switchSibling: (siblingMessageId: string) => void - onHumanInputFormSubmit: ( - formToken: string, - formData: { inputs: Record; action: string }, - ) => Promise - onStopResponding: () => void - }) => ( -
-
{chatNode}
- {answerIcon} - {chatList.map((item) => ( -
{item.content}
- ))} -
chat count: {chatList.length}
- {questionIcon} - - - - - - - -
- ), -})) - -vi.mock('@/service/share', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - fetchSuggestedQuestions: vi.fn(), - getUrl: vi.fn(() => '/chat-messages'), - stopChatMessageResponding: vi.fn(), - submitHumanInputForm: vi.fn(), - } -}) - -vi.mock('@/service/workflow', () => ({ - submitHumanInputForm: vi.fn(), -})) - vi.mock('@/service/webapp-human-input', () => ({ - submitWebAppHumanInputForm: vi.fn(), + submitWebAppHumanInputForm: vi.fn().mockResolvedValue(undefined), })) -const mockIsDify = vi.fn(() => false) -vi.mock('../utils', () => ({ - isDify: () => mockIsDify(), -})) +const createForm = (formVersion: HumanInputFormData['form_version']): HumanInputFormData => + createHumanInputFormFixture({ + form_version: formVersion, + form_token: 'form-token', + node_title: 'Approval', + form_content: '{{#$output.review#}}', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'review', + default: { type: 'constant', value: '', selector: [] }, + }, + ], + actions: [{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }], + }) -type UseChatReturn = ReturnType - -const createContextValue = ( - overrides: Partial = {}, -): EmbeddedChatbotContextValue => ({ - appMeta: { tool_icons: {} }, - appData: { - app_id: 'app-1', - mode: 'chat', - can_replace_logo: true, - custom_config: { - remove_webapp_brand: false, - replace_webapp_logo: '', - }, - enable_site: true, - end_user_id: 'user-1', - site: { - title: 'Embedded App', - icon_type: 'emoji', - icon: 'bot', - icon_background: '#000000', - icon_url: '', - use_icon_as_answer_icon: false, - }, +const pausedConversation = (formVersion: HumanInputFormData['form_version']): ChatItemInTree[] => [ + { + id: 'question', + content: 'Please review', + isAnswer: false, + children: [ + { + id: 'paused-answer', + content: '', + isAnswer: true, + parentMessageId: 'question', + workflow_run_id: 'run-1', + workflowProcess: createWorkflowProcessFixture({ status: WorkflowRunningStatus.Paused }), + humanInputFormDataList: [createForm(formVersion)], + }, + ], }, - appParams: { - system_parameters: { - audio_file_size_limit: 1, - file_size_limit: 1, - image_file_size_limit: 1, - video_file_size_limit: 1, - workflow_file_upload_limit: 1, - }, - more_like_this: { - enabled: false, - }, - } as ChatConfig, - appChatListDataLoading: false, - currentConversationId: '', - currentConversationItem: undefined, - appPrevChatList: [], - pinnedConversationList: [], - conversationList: [], - newConversationInputs: {}, - newConversationInputsRef: { current: {} }, - handleNewConversationInputsChange: vi.fn(), - inputsForms: [], - handleNewConversation: vi.fn(), - handleStartChat: vi.fn(), - handleChangeConversation: vi.fn(), - handleNewConversationCompleted: vi.fn(), - chatShouldReloadKey: 'reload-key', - isMobile: false, - isInstalledApp: false, - appSourceType: AppSourceType.webApp, - allowResetChat: true, - appId: 'app-1', - disableFeedback: false, - handleFeedback: vi.fn(), - currentChatInstanceRef: { current: { handleStop: vi.fn() } }, - theme: undefined, - clearChatList: false, - setClearChatList: vi.fn(), - isResponding: false, - setIsResponding: vi.fn(), - currentConversationInputs: {}, - setCurrentConversationInputs: vi.fn(), - allInputsHidden: false, - initUserVariables: {}, - ...overrides, -}) +] -const createUseChatReturn = (overrides: Partial = {}): UseChatReturn => ({ - chatList: [], - setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'], - handleSend: vi.fn(), - handleResume: vi.fn(), - setIsResponding: vi.fn() as UseChatReturn['setIsResponding'], - handleStop: vi.fn(), - handleSwitchSibling: vi.fn(), - prepareHumanInputSubmission: vi.fn().mockResolvedValue(true), - isResponding: false, - suggestedQuestions: [], - handleRestart: vi.fn(), - handleAnnotationEdited: vi.fn(), - handleAnnotationAdded: vi.fn(), - handleAnnotationRemoved: vi.fn(), - ...overrides, -}) +const renderSubject = ( + formVersion: HumanInputFormData['form_version'], + overrides: Partial, +) => { + const context = createEmbeddedChatbotContextFixture({ + appId: 'app-1', + appData: { + app_id: 'app-1', + mode: 'chat', + custom_config: null, + site: { title: 'Embedded App', input_placeholder: 'Ask the assistant' }, + }, + appMeta: { tool_icons: {} }, + appParams: createChatConfigFixture(), + currentConversationId: 'conversation-1', + appPrevChatList: pausedConversation(formVersion), + ...overrides, + }) + renderWithConsoleQuery( + // oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its Provider component. + + + , + ) + return userEvent.setup() +} -describe('EmbeddedChatbot chat-wrapper', () => { +const confirmWorkflowReady = async () => { + await waitFor(() => expect(workflowEvents).toHaveBeenCalledOnce()) + act(() => + workflowEvents.mock.calls[0]![2].onWorkflowPaused?.({ + task_id: 'task-1', + workflow_run_id: 'run-1', + event: 'workflow_paused', + data: { workflow_run_id: 'run-1', outputs: {}, paused_nodes: ['human-input'], reasons: [] }, + }), + ) +} + +const submitVisibleForm = async (user: ReturnType) => { + await user.type(await screen.findByRole('textbox', { name: 'review' }), 'Approved') + await user.click(screen.getByRole('button', { name: 'Approve' })) +} + +describe('EmbeddedChatbot human-input submission', () => { beforeEach(() => { - vi.clearAllMocks() - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue()) - vi.mocked(useChat).mockReturnValue(createUseChatReturn()) + const stopGuard = guardUnexpectedFetch() + onTestFinished(stopGuard) }) - describe('Human input submit behavior', () => { - it.each([undefined, '1', '2'] as const)( - 'submits installed app forms with event version %s', - async (formVersion) => { - let resolveWorkflowEventsReady: (isReady: boolean) => void = () => {} - const prepareHumanInputSubmission = vi.fn( - () => - new Promise((resolve) => { - resolveWorkflowEventsReady = resolve - }), - ) - vi.mocked(useChat).mockReturnValue( - createUseChatReturn({ - prepareHumanInputSubmission, - chatList: [ - { - id: 'answer-1', - content: '', - isAnswer: true, - humanInputFormDataList: [createHumanInputForm(formVersion)], - }, - ], - }), - ) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue( - createContextValue({ - isInstalledApp: true, - }), - ) + it.each(['1', '2', undefined] as const)( + 'submits installed-app form version %s after the workflow stream is ready', + async (formVersion) => { + const user = renderSubject(formVersion, { isInstalledApp: true }) + await submitVisibleForm(user) - render() - fireEvent.click(screen.getByRole('button', { name: 'submit human input' })) - - expect(prepareHumanInputSubmission).toHaveBeenCalledOnce() - expect(submitHumanInputFormService).not.toHaveBeenCalled() - - await act(async () => { - resolveWorkflowEventsReady(true) - }) - await waitFor(() => { - expect(submitHumanInputFormService).toHaveBeenCalledWith( - 'form-token', - { - inputs: { answer: 'ok' }, - action: 'approve', - }, - formVersion, - ) - }) - expect(submitHumanInputForm).not.toHaveBeenCalled() - }, - ) - - it.each([undefined, '1', '2'] as const)( - 'submits Web App forms with event version %s and supports chat actions', - async (formVersion) => { - const handleSend = vi.fn() - const handleSwitchSibling = vi.fn() - const handleStop = vi.fn() - vi.mocked(useChat).mockReturnValue( - createUseChatReturn({ - handleSend, - handleSwitchSibling, - handleStop, - chatList: [ - { id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' }, - { id: 'question-1', isAnswer: false, content: 'Question' }, - { - id: 'answer-1', - isAnswer: true, - content: 'Answer', - parentMessageId: 'question-1', - humanInputFormDataList: [createHumanInputForm(formVersion)], - }, - ] as ChatItemInTree[], - }), - ) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue( - createContextValue({ - isInstalledApp: false, - appSourceType: AppSourceType.tryApp, - isMobile: true, - inputsForms: [ - { variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }, - ], - currentConversationId: 'conversation-1', - }), - ) - mockIsDify.mockReturnValue(true) - - render() - - expect(screen.getByText('chat count: 3')).toBeInTheDocument() - expect(screen.queryByText('inputs form')).not.toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'send through chat' })) - fireEvent.click(screen.getByRole('button', { name: 'regenerate answer' })) - fireEvent.click(screen.getByRole('button', { name: 'switch sibling' })) - fireEvent.click(screen.getByRole('button', { name: 'stop responding' })) - fireEvent.click(screen.getByRole('button', { name: 'submit human input' })) - - await waitFor(() => { - expect(submitHumanInputForm).toHaveBeenCalledWith( - 'form-token', - { - inputs: { answer: 'ok' }, - action: 'approve', - }, - formVersion, - ) - }) - expect(handleSend).toHaveBeenCalledTimes(2) - const sendOptions = handleSend.mock.calls[0]?.[2] as { - onGetSuggestedQuestions: (responseItemId: string) => void - } - sendOptions.onGetSuggestedQuestions('resp-1') - expect(handleSwitchSibling).toHaveBeenCalledWith( - 'sibling-2', - expect.objectContaining({ - isPublicAPI: false, - }), - ) - const switchOptions = handleSwitchSibling.mock.calls.find( - (call) => call[0] === 'sibling-2', - )?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void } - switchOptions.onGetSuggestedQuestions('resp-2') - expect(fetchSuggestedQuestions).toHaveBeenCalledWith( - 'resp-1', - AppSourceType.tryApp, - 'app-1', - ) - expect(fetchSuggestedQuestions).toHaveBeenCalledWith( - 'resp-2', - AppSourceType.tryApp, - 'app-1', - ) - expect(handleStop).toHaveBeenCalled() - expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument() - expect(mockTrackEvent).not.toHaveBeenCalled() - - cleanup() - vi.mocked(useEmbeddedChatbotContext).mockReturnValue( - createContextValue({ - isMobile: true, - currentConversationId: '', - inputsForms: [ - { variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }, - ], - }), - ) - vi.mocked(useChat).mockReturnValue( - createUseChatReturn({ - chatList: [ - { - id: 'opening-mobile', - isAnswer: true, - isOpeningStatement: true, - content: 'Mobile welcome', - }, - ], - }), - ) - - render() - expect(screen.getByText('inputs form')).toBeInTheDocument() - }, - ) - - it.each(['1', '2', undefined] as const)( - 'submits web app version %s through the passport-aware service', - async (formVersion) => { - const user = userEvent.setup() - vi.mocked(useChat).mockReturnValue( - createUseChatReturn({ - chatList: [ - { - id: 'answer-1', - isAnswer: true, - content: '', - humanInputFormDataList: [createHumanInputForm(formVersion)], - }, - ], - }), - ) - render() - - await user.click(screen.getByRole('button', { name: 'submit human input' })) - - expect(submitWebAppHumanInputForm).toHaveBeenCalledWith( + expect(submitInstalledHumanInput).not.toHaveBeenCalled() + await confirmWorkflowReady() + await waitFor(() => + expect(submitInstalledHumanInput).toHaveBeenCalledExactlyOnceWith( 'form-token', - { - inputs: { answer: 'ok' }, - action: 'approve', - }, + { inputs: { review: 'Approved' }, action: 'approve' }, formVersion, - ) - expect(submitHumanInputForm).not.toHaveBeenCalled() - expect(submitHumanInputFormService).not.toHaveBeenCalled() - }, - ) - }) + ), + ) + expect(submitHumanInputForm).not.toHaveBeenCalled() + expect(submitWebAppHumanInputForm).not.toHaveBeenCalled() + }, + ) + + it.each(['1', '2', undefined] as const)( + 'submits public-share form version %s after the workflow stream is ready', + async (formVersion) => { + const user = renderSubject(formVersion, { appSourceType: AppSourceType.tryApp }) + await submitVisibleForm(user) + + expect(submitHumanInputForm).not.toHaveBeenCalled() + await confirmWorkflowReady() + await waitFor(() => + expect(submitHumanInputForm).toHaveBeenCalledExactlyOnceWith( + 'form-token', + { inputs: { review: 'Approved' }, action: 'approve' }, + formVersion, + ), + ) + expect(submitInstalledHumanInput).not.toHaveBeenCalled() + expect(submitWebAppHumanInputForm).not.toHaveBeenCalled() + }, + ) + + it.each(['1', '2', undefined] as const)( + 'submits web-app form version %s after the workflow stream is ready', + async (formVersion) => { + const user = renderSubject(formVersion, { appSourceType: AppSourceType.webApp }) + await submitVisibleForm(user) + + expect(submitWebAppHumanInputForm).not.toHaveBeenCalled() + await confirmWorkflowReady() + await waitFor(() => + expect(submitWebAppHumanInputForm).toHaveBeenCalledExactlyOnceWith( + 'form-token', + { inputs: { review: 'Approved' }, action: 'approve' }, + formVersion, + ), + ) + expect(submitInstalledHumanInput).not.toHaveBeenCalled() + expect(submitHumanInputForm).not.toHaveBeenCalled() + }, + ) }) diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx b/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx index 9715438ab2e..617dd60b7a5 100644 --- a/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx @@ -1,3 +1,8 @@ +import type { + MessageTemplateTestResponse, + PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData, + PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData, +} from '@dify/contracts/api/console/apps/types.gen' import type { ChannelSummary, ListChannelsResponse, @@ -5,50 +10,88 @@ import type { import type { ComponentProps } from 'react' import type { EditorResource } from '@/app/components/workflow/editor-resource/resource' import type { Shape } from '@/app/components/workflow/hooks-store/store' -import { QueryClientProvider } from '@tanstack/react-query' -import { act, render, screen, waitFor } from '@testing-library/react' +import { ORPCError } from '@orpc/client' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { createStore, Provider } from 'jotai' -import { editorResourceAtom } from '@/app/components/workflow/editor-resource/state' -import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider' -import { createHooksStore } from '@/app/components/workflow/hooks-store/store' +import { useState } from 'react' +import { ReactFlowProvider } from 'reactflow' +import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { EditorResourceScope } from '@/app/components/workflow/editor-resource/provider' +import { HooksStoreContextProvider } from '@/app/components/workflow/hooks-store/provider' import { VarType } from '@/app/components/workflow/types' import { consoleQuery } from '@/service/console' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createDeferred } from '@/test/deferred' import MessageTemplate from '../components/message-template' import { createChannelsQueryClient } from './channel-fixtures' const mocks = vi.hoisted(() => ({ - isChatMode: false, syncDraft: vi.fn(), + workflowTest: + vi.fn< + ( + input: PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData, + ) => Promise + >(), + chatflowTest: + vi.fn< + ( + input: PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData, + ) => Promise + >(), })) const originalBody = 'Original body {{#url#}}' -vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ - useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mocks.syncDraft }), -})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.workflows.draft.humanInput.nodes.byNodeId.messageTemplate.test.post': + mocks.workflowTest, + 'apps.byAppId.advancedChat.workflows.draft.humanInput.nodes.byNodeId.messageTemplate.test.post': + mocks.chatflowTest, + }), + } +}) -vi.mock( - '@/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input', - () => ({ - __esModule: true, - default: ({ - value, - onChange, - readOnly, - }: { - value: string - onChange: (value: string) => void - readOnly: boolean - }) => ( -