test(web): replace HITL owner fakes and editor shims (#45189)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-31 03:24:16 +00:00
1 parent dca4024e57
commit 2050078a5f
4 files changed
+608 -938

No files matched your search

@@ -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<typeof import('@/config')>()),
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<typeof import('@/service/base')>()),
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 }) => <div>{content}</div>,
}))
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<typeof useChat>
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.
<ChatWithHistoryContext.Provider value={createContext(formVersion)}>
<ChatWrapper />
</ChatWithHistoryContext.Provider>,
)
return userEvent.setup()
}
const defaultChatHookReturn: Partial<ChatHookReturn> = {
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(<ChatWrapper />)
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,
)
})
),
)
},
)
})
@@ -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<typeof import('@/config')>()),
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<typeof import('@/service/base')>()),
sseGet: vi.fn(),
ssePost: vi.fn(),
}))
vi.mock('../inputs-form', () => ({
__esModule: true,
default: () => <div>inputs form</div>,
vi.mock('@/service/share', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/share')>()),
submitHumanInputForm: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div>{content}</div>,
vi.mock('@/service/workflow', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/workflow')>()),
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<string, HumanInputFieldValue>; action: string },
) => Promise<void>
onStopResponding: () => void
}) => (
<div>
<div>{chatNode}</div>
{answerIcon}
{chatList.map((item) => (
<div key={item.id}>{item.content}</div>
))}
<div>chat count: {chatList.length}</div>
{questionIcon}
<button onClick={() => onSend('hello world')}>send through chat</button>
<button
onClick={() =>
onRegenerate({
id: 'answer-1',
isAnswer: true,
content: 'answer',
parentMessageId: 'question-1',
})
}
>
regenerate answer
</button>
<button
onClick={() =>
onRegenerate(
{ id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' },
{ message: 'new query' },
)
}
>
regenerate edited
</button>
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
<button disabled={inputDisabled}>send message</button>
<button onClick={onStopResponding}>stop responding</button>
<button
onClick={() =>
onHumanInputFormSubmit('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
}
>
submit human input
</button>
</div>
),
}))
vi.mock('@/service/share', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/share')>()
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<typeof useChat>
const createContextValue = (
overrides: Partial<EmbeddedChatbotContextValue> = {},
): 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> = {}): 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<EmbeddedChatbotContextValue>,
) => {
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.
<EmbeddedChatbotContext.Provider value={context}>
<ChatWrapper />
</EmbeddedChatbotContext.Provider>,
)
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<typeof userEvent.setup>) => {
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<boolean>((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(<ChatWrapper />)
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(<ChatWrapper />)
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(<ChatWrapper />)
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(<ChatWrapper />)
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()
},
)
})
@@ -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<Shape['doSyncWorkflowDraft']>(),
workflowTest:
vi.fn<
(
input: PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData,
) => Promise<MessageTemplateTestResponse>
>(),
chatflowTest:
vi.fn<
(
input: PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData,
) => Promise<MessageTemplateTestResponse>
>(),
}))
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<typeof import('@/service/console')>()
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
}) => (
<textarea
aria-label="message-body-editor"
type TemplateProps = ComponentProps<typeof MessageTemplate>
const TemplateCaller = ({ value: initialValue, onChange, ...props }: TemplateProps) => {
const [value, setValue] = useState(initialValue)
return (
<>
<MessageTemplate
{...props}
value={value}
disabled={readOnly}
onChange={(event) => onChange(event.target.value)}
onChange={(nextValue) => {
setValue(nextValue)
onChange(nextValue)
}}
/>
),
}),
<output aria-label="Committed subject">{value.subject}</output>
<output aria-label="Committed body">{value.body}</output>
</>
)
}
type TemplateEnvironmentProps = {
props: TemplateProps
hooksStoreProps: Partial<Shape>
resource: EditorResource
}
const TemplateEnvironment = ({ props, hooksStoreProps, resource }: TemplateEnvironmentProps) => (
<ReactFlowProvider>
<HooksStoreContextProvider {...hooksStoreProps}>
<EditorResourceScope resource={resource}>
<TemplateCaller {...props} />
</EditorResourceScope>
</HooksStoreContextProvider>
</ReactFlowProvider>
)
const renderTemplate = (
@@ -58,10 +101,10 @@ const renderTemplate = (
resource: EditorResource = {
kind: 'app',
appId: 'app-template-test',
mode: mocks.isChatMode ? 'advanced-chat' : 'workflow',
mode: 'workflow',
},
) => {
const props: ComponentProps<typeof MessageTemplate> = {
const props: TemplateProps = {
nodeId: 'human-input-v2',
value: { subject: 'Original subject', body: originalBody },
onChange: vi.fn(),
@@ -71,30 +114,28 @@ const renderTemplate = (
...overrides,
}
const queryClient = createChannelsQueryClient(configuredProviders)
const hooksStore = createHooksStore({
seedModelProviderQueries(queryClient)
const hooksStoreProps = {
doSyncWorkflowDraft: mocks.syncDraft,
...storeOverrides,
})
const resourceStore = createStore()
resourceStore.set(editorResourceAtom, resource)
return {
resourceStore,
...render(
<QueryClientProvider client={queryClient}>
<HooksStoreContext value={hooksStore}>
<Provider store={resourceStore}>
<MessageTemplate {...props} />
</Provider>
</HooksStoreContext>
</QueryClientProvider>,
),
props,
hooksStore,
}
let environmentProps: TemplateEnvironmentProps = { props, hooksStoreProps, resource }
const view = renderWorkflowComponent(<TemplateEnvironment {...environmentProps} />, {
queryClient,
})
return {
...view,
props,
queryClient,
rerenderEnvironment: (overrides: Partial<TemplateEnvironmentProps>) => {
environmentProps = { ...environmentProps, ...overrides }
view.rerender(<TemplateEnvironment {...environmentProps} />)
},
}
}
const openTemplate = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByText('workflow.nodes.humanInputV2.template.title'))
await user.click(screen.getByRole('button', { name: 'workflow.common.configure' }))
}
const openTest = async (user: ReturnType<typeof userEvent.setup>) => {
@@ -110,42 +151,26 @@ const sendTest = async (user: ReturnType<typeof userEvent.setup>) => {
)
}
type RecordedRequest = { url: string; method: string; body: unknown }
const getBodyEditor = () =>
screen.getByRole('textbox', { name: 'workflow.nodes.humanInputV2.template.body' })
const jsonResponse = (body: unknown = {}, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
const setBody = async (value: string) => replacePromptEditorText(getBodyEditor(), value)
const setupFetch = (respond: () => Response | Promise<Response> = () => jsonResponse()) => {
const requests: RecordedRequest[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (resource, options) => {
const request = resource instanceof Request ? resource : new Request(resource, options)
requests.push({
url: request.url,
method: request.method,
body: await request.clone().json(),
})
return respond()
})
return requests
const expectNoTemplateTest = () => {
expect(mocks.workflowTest).not.toHaveBeenCalled()
expect(mocks.chatflowTest).not.toHaveBeenCalled()
}
describe('Human Input v2 Message Template', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.isChatMode = false
mocks.workflowTest.mockResolvedValue({})
mocks.chatflowTest.mockResolvedValue({})
mocks.syncDraft.mockImplementation(async (_notRefresh, callbacks) => {
callbacks?.onSuccess?.()
return { hash: 'saved-draft', updatedAt: 1 }
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('preserves existing subject variables while committing subject and body edits together', async () => {
const user = userEvent.setup()
const { props } = renderTemplate({
@@ -154,20 +179,25 @@ describe('Human Input v2 Message Template', () => {
await openTemplate(user)
const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')
const body = screen.getByLabelText('message-body-editor')
expect(subject).toHaveValue('Original subject{{#start.email#}}')
expect(body).toHaveValue(originalBody)
expect(screen.getByLabelText('Committed body')).toHaveTextContent(originalBody)
await user.type(subject, ' updated')
await user.clear(body)
await user.paste('Updated body {{#url#}}')
await setBody('Updated body {{#url#}}')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(props.onChange).toHaveBeenCalledTimes(1)
expect(props.onChange).toHaveBeenCalledWith({
subject: 'Original subject{{#start.email#}} updated',
body: 'Updated body {{#url#}}',
})
expect(screen.getByLabelText('Committed subject')).toHaveTextContent(
'Original subject{{#start.email#}} updated',
)
expect(screen.getByLabelText('Committed body')).toHaveTextContent('Updated body {{#url#}}')
await openTemplate(user)
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue(
'Original subject{{#start.email#}} updated',
)
})
it('discards local drafts on Cancel and Escape and restores trigger focus', async () => {
@@ -180,9 +210,7 @@ describe('Human Input v2 Message Template', () => {
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() =>
expect(
screen.getByText('workflow.nodes.humanInputV2.template.title').closest('button'),
).toHaveFocus(),
expect(screen.getByRole('button', { name: 'workflow.common.configure' })).toHaveFocus(),
)
await openTemplate(user)
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue(
@@ -206,7 +234,7 @@ describe('Human Input v2 Message Template', () => {
const { props } = renderTemplate()
await openTemplate(user)
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
await user.clear(screen.getByLabelText('message-body-editor'))
await setBody('')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(screen.getAllByRole('alert')).toHaveLength(2)
@@ -217,8 +245,7 @@ describe('Human Input v2 Message Template', () => {
screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'),
'Subject',
)
await user.click(screen.getByLabelText('message-body-editor'))
await user.paste('Body {{#url#}}')
await setBody('Body {{#url#}}')
const save = screen.getByRole('button', { name: 'common.operation.save' })
await user.dblClick(save)
expect(props.onChange).toHaveBeenCalledTimes(1)
@@ -233,7 +260,7 @@ describe('Human Input v2 Message Template', () => {
await openTemplate(user)
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled()
expect(screen.getByLabelText('message-body-editor')).toBeDisabled()
expect(getBodyEditor()).toHaveAttribute('contenteditable', 'false')
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
expect(props.onChange).not.toHaveBeenCalled()
})
@@ -252,7 +279,7 @@ describe('Human Input v2 Message Template', () => {
expect(screen.getByRole('alert')).toHaveTextContent(
`workflow.nodes.humanInputV2.error.${error}`,
)
await waitFor(() => expect(screen.getByLabelText('message-body-editor')).toHaveFocus())
await waitFor(() => expect(getBodyEditor()).toHaveFocus())
expect(props.onChange).not.toHaveBeenCalled()
},
)
@@ -266,33 +293,29 @@ describe('Human Input v2 Message Template', () => {
'validates $error while editing before $action and clears it when corrected',
async ({ action, body, error }) => {
const user = userEvent.setup()
const requests = setupFetch()
const { props } = renderTemplate()
if (action === 'test') await openTest(user)
else await openTemplate(user)
const editor = screen.getByLabelText('message-body-editor')
await user.clear(editor)
await user.paste(body)
await setBody(body)
expect(screen.getByRole('alert')).toHaveTextContent(
`workflow.nodes.humanInputV2.error.${error}`,
)
if (action === 'test') await sendTest(user)
else await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(editor).toHaveFocus())
await waitFor(() => expect(getBodyEditor()).toHaveFocus())
expect(props.onChange).not.toHaveBeenCalled()
expect(mocks.syncDraft).not.toHaveBeenCalled()
expect(requests).toHaveLength(0)
expectNoTemplateTest()
await user.clear(editor)
await user.paste('Review {{#url#}}')
await setBody('Review {{#url#}}')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
if (action === 'test') {
await sendTest(user)
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(mocks.workflowTest).toHaveBeenCalledOnce()
} else {
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
}
@@ -306,7 +329,6 @@ describe('Human Input v2 Message Template', () => {
it('opens test settings from the card without sending and discards changes with Close', async () => {
const user = userEvent.setup()
const requests = setupFetch()
const { props } = renderTemplate()
const testTrigger = screen.getByRole('button', {
name: 'workflow.nodes.humanInputV2.template.test',
@@ -316,7 +338,7 @@ describe('Human Input v2 Message Template', () => {
expect(
screen.getByRole('combobox', { name: 'workflow.nodes.humanInputV2.template.testChannel' }),
).toBeInTheDocument()
expect(requests).toHaveLength(0)
expectNoTemplateTest()
expect(mocks.syncDraft).not.toHaveBeenCalled()
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
await user.type(
@@ -354,7 +376,6 @@ describe('Human Input v2 Message Template', () => {
it('defaults to the configured IM channel when Email is not configured', async () => {
const user = userEvent.setup()
const requests = setupFetch()
renderTemplate({}, {}, ['slack'])
await openTest(user)
@@ -366,12 +387,14 @@ describe('Human Input v2 Message Template', () => {
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
expect(requests[0]?.body).toEqual({ channel: 'slack', inputs: {} })
expect(mocks.workflowTest).toHaveBeenCalledWith({
params: { app_id: 'app-template-test', node_id: 'human-input-v2' },
body: { channel: 'slack', inputs: {} },
})
})
it('does not save or send a test when the workspace has no configured channels', async () => {
const user = userEvent.setup()
const requests = setupFetch()
const { props } = renderTemplate({}, {}, [])
await openTest(user)
@@ -382,12 +405,11 @@ describe('Human Input v2 Message Template', () => {
expect(mocks.syncDraft).not.toHaveBeenCalled()
expect(props.onChange).not.toHaveBeenCalled()
expect(requests).toHaveLength(0)
expectNoTemplateTest()
})
it('uses the remaining configured channel when the selected channel is removed while open', async () => {
const user = userEvent.setup()
const requests = setupFetch()
const { queryClient } = renderTemplate()
await openTest(user)
const channelQueryKey =
@@ -409,12 +431,14 @@ describe('Human Input v2 Message Template', () => {
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
expect(requests[0]?.body).toEqual({ channel: 'slack', inputs: {} })
expect(mocks.workflowTest).toHaveBeenCalledWith({
params: { app_id: 'app-template-test', node_id: 'human-input-v2' },
body: { channel: 'slack', inputs: {} },
})
})
it('does not send to a channel removed while the template is being saved', async () => {
const user = userEvent.setup()
const requests = setupFetch()
let finishSave = () => {}
mocks.syncDraft.mockImplementationOnce(
(_notRefresh, callbacks) =>
@@ -449,77 +473,78 @@ describe('Human Input v2 Message Template', () => {
screen.getByRole('button', { name: 'workflow.nodes.humanInputV2.template.saveAndTest' }),
).toBeEnabled(),
)
expect(requests).toHaveLength(0)
expectNoTemplateTest()
await sendTest(user)
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
expect(requests[0]?.body).toEqual({ channel: 'slack', inputs: {} })
expect(mocks.workflowTest).toHaveBeenCalledWith({
params: { app_id: 'app-template-test', node_id: 'human-input-v2' },
body: { channel: 'slack', inputs: {} },
})
})
it.each([
{ mode: 'workflow', chat: false, prefix: 'workflows' },
{ mode: 'chatflow', chat: true, prefix: 'advanced-chat/workflows' },
])(
'sends a $mode template test through its generated route after saving',
async ({ chat, prefix }) => {
const user = userEvent.setup()
mocks.isChatMode = chat
const requests = setupFetch()
let completeSave = () => {}
mocks.syncDraft.mockImplementationOnce(
(_notRefresh, callbacks) =>
new Promise((resolve) => {
completeSave = () => {
callbacks?.onSuccess?.()
resolve({ hash: 'saved-new-template', updatedAt: 2 })
}
}),
)
const { props } = renderTemplate({}, {}, ['resend', 'lark'])
await openTest(user)
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
await user.type(
screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'),
'Updated test subject',
)
await user.click(
screen.getByRole('combobox', { name: 'workflow.nodes.humanInputV2.template.testChannel' }),
)
await user.click(
await screen.findByRole('option', {
name: 'workflow.nodes.humanInputV2.debug.channel.lark',
{ mode: 'workflow', chat: false },
{ mode: 'chatflow', chat: true },
])('sends a $mode template test through its generated route after saving', async ({ chat }) => {
const user = userEvent.setup()
let completeSave = () => {}
mocks.syncDraft.mockImplementationOnce(
(_notRefresh, callbacks) =>
new Promise((resolve) => {
completeSave = () => {
callbacks?.onSuccess?.()
resolve({ hash: 'saved-new-template', updatedAt: 2 })
}
}),
)
await sendTest(user)
)
const { props } = renderTemplate({}, {}, ['resend', 'lark'], {
kind: 'app',
appId: 'app-template-test',
mode: chat ? 'advanced-chat' : 'workflow',
})
await openTest(user)
await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'))
await user.type(
screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'),
'Updated test subject',
)
await user.click(
screen.getByRole('combobox', { name: 'workflow.nodes.humanInputV2.template.testChannel' }),
)
await user.click(
await screen.findByRole('option', {
name: 'workflow.nodes.humanInputV2.debug.channel.lark',
}),
)
await sendTest(user)
expect(props.onChange).toHaveBeenCalledWith({
subject: 'Updated test subject',
body: originalBody,
})
expect(mocks.syncDraft).toHaveBeenCalledTimes(1)
expect(requests).toHaveLength(0)
expect(props.onChange).toHaveBeenCalledWith({
subject: 'Updated test subject',
body: originalBody,
})
expect(mocks.syncDraft).toHaveBeenCalledTimes(1)
expectNoTemplateTest()
await act(async () => completeSave())
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
expect(requests).toEqual([
{
url: `http://localhost:5001/console/api/apps/app-template-test/${prefix}/draft/human-input/nodes/human-input-v2/message-template/test`,
method: 'POST',
body: { channel: 'lark', inputs: {} },
},
])
},
)
await act(async () => completeSave())
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
const selectedService = chat ? mocks.chatflowTest : mocks.workflowTest
const otherService = chat ? mocks.workflowTest : mocks.chatflowTest
expect(selectedService).toHaveBeenCalledWith({
params: { app_id: 'app-template-test', node_id: 'human-input-v2' },
body: { channel: 'lark', inputs: {} },
})
expect(otherService).not.toHaveBeenCalled()
})
it.each(['app changes', 'run permission is revoked'] as const)(
'does not send a stale test when %s while the draft save is pending',
async (change) => {
const user = userEvent.setup()
const requests = setupFetch()
let completeSave = () => {}
mocks.syncDraft.mockImplementationOnce(
(_notRefresh, callbacks) =>
@@ -530,36 +555,36 @@ describe('Human Input v2 Message Template', () => {
}
}),
)
const { hooksStore, resourceStore } = renderTemplate()
const { rerenderEnvironment } = renderTemplate()
await openTest(user)
await sendTest(user)
expect(mocks.syncDraft).toHaveBeenCalledTimes(1)
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled()
await act(async () => {
if (change === 'app changes') {
resourceStore.set(editorResourceAtom, {
if (change === 'app changes') {
rerenderEnvironment({
resource: {
kind: 'app',
appId: 'another-app',
mode: 'workflow',
})
} else {
hooksStore.setState({
},
})
} else {
rerenderEnvironment({
hooksStoreProps: {
doSyncWorkflowDraft: mocks.syncDraft,
accessControl: {
canEdit: true,
canRun: false,
canImportExportDSL: true,
canReleaseAndVersion: true,
},
})
}
})
},
})
}
await act(async () => completeSave())
await waitFor(() =>
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeEnabled(),
)
expect(requests).toHaveLength(0)
expectNoTemplateTest()
expect(
screen.queryByText('workflow.nodes.humanInputV2.template.testCompleted'),
).not.toBeInTheDocument()
@@ -575,7 +600,6 @@ describe('Human Input v2 Message Template', () => {
'does not send against a %s draft save',
async (outcome) => {
const user = userEvent.setup()
const requests = setupFetch()
mocks.syncDraft.mockImplementationOnce(async (_notRefresh, callbacks) => {
if (outcome === 'failed') callbacks?.onError?.()
return null
@@ -587,7 +611,7 @@ describe('Human Input v2 Message Template', () => {
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testSaveFailed'),
).toBeInTheDocument()
expect(requests).toHaveLength(0)
expectNoTemplateTest()
expect(
screen.queryByText('workflow.nodes.humanInputV2.template.testCompleted'),
).not.toBeInTheDocument()
@@ -599,16 +623,12 @@ describe('Human Input v2 Message Template', () => {
it('reports an unavailable backend without claiming the test was sent', async () => {
const user = userEvent.setup()
const requests = setupFetch(() =>
jsonResponse(
{
code: 'not_implemented',
message: 'Human Input v2 draft stub endpoint is not implemented yet.',
},
501,
),
mocks.workflowTest.mockRejectedValueOnce(
new ORPCError('not_implemented', {
status: 501,
message: 'Human Input v2 draft stub endpoint is not implemented yet.',
}),
)
vi.spyOn(console, 'error').mockImplementation(() => {})
renderTemplate()
await openTest(user)
await sendTest(user)
@@ -616,7 +636,7 @@ describe('Human Input v2 Message Template', () => {
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testUnavailable'),
).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(mocks.workflowTest).toHaveBeenCalledOnce()
expect(
screen.queryByText('workflow.nodes.humanInputV2.template.testCompleted'),
).not.toBeInTheDocument()
@@ -627,7 +647,6 @@ describe('Human Input v2 Message Template', () => {
it('deduplicates subject and body variables, preserving zero and string-array input types', async () => {
const user = userEvent.setup()
const requests = setupFetch()
renderTemplate({
value: {
subject: 'Review {{#start.score#}} and {{#start.owner#}}',
@@ -654,7 +673,7 @@ describe('Human Input v2 Message Template', () => {
await screen.findByText('workflow.nodes.humanInputV2.template.testInputsRequired'),
).toBeInTheDocument()
expect(mocks.syncDraft).not.toHaveBeenCalled()
expect(requests).toHaveLength(0)
expectNoTemplateTest()
await user.type(screen.getByLabelText('start.score'), '0')
await user.type(screen.getByLabelText('start.owner'), 'Reviewer')
@@ -664,12 +683,15 @@ describe('Human Input v2 Message Template', () => {
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
expect(requests[0]?.body).toEqual({
channel: 'email',
inputs: {
'#start.score#': 0,
'#start.owner#': 'Reviewer',
'#start.tags#': ['urgent', 'legal'],
expect(mocks.workflowTest).toHaveBeenCalledWith({
params: { app_id: 'app-template-test', node_id: 'human-input-v2' },
body: {
channel: 'email',
inputs: {
'#start.score#': 0,
'#start.owner#': 'Reviewer',
'#start.tags#': ['urgent', 'legal'],
},
},
})
})
@@ -678,7 +700,6 @@ describe('Human Input v2 Message Template', () => {
'rejects invalid string-array test input %s before saving',
async (input) => {
const user = userEvent.setup()
const requests = setupFetch()
renderTemplate({
value: { subject: 'Subject', body: '{{#start.tags#}} {{#url#}}' },
availableVars: [
@@ -697,35 +718,30 @@ describe('Human Input v2 Message Template', () => {
await screen.findByText('workflow.errorMsg.invalidJson:{"field":"start.tags"}'),
).toBeInTheDocument()
expect(mocks.syncDraft).not.toHaveBeenCalled()
expect(requests).toHaveLength(0)
expectNoTemplateTest()
},
)
it('keeps one request in flight when the send button is double-clicked', async () => {
const user = userEvent.setup()
let finishRequest = (_response: Response) => {}
const requests = setupFetch(
() =>
new Promise((resolve) => {
finishRequest = resolve
}),
)
const request = createDeferred<MessageTemplateTestResponse>()
mocks.workflowTest.mockImplementationOnce(() => request.promise)
const { props } = renderTemplate()
await openTest(user)
const send = screen.getByRole('button', {
name: 'workflow.nodes.humanInputV2.template.saveAndTest',
})
await user.dblClick(send)
await waitFor(() => expect(requests).toHaveLength(1))
await waitFor(() => expect(mocks.workflowTest).toHaveBeenCalledOnce())
expect(mocks.syncDraft).toHaveBeenCalledTimes(1)
expect(props.onChange).toHaveBeenCalledTimes(1)
expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled()
await act(async () => finishRequest(jsonResponse()))
await act(async () => request.resolve({}))
expect(
await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'),
).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(mocks.workflowTest).toHaveBeenCalledOnce()
})
it.each([
@@ -755,7 +771,6 @@ describe('Human Input v2 Message Template', () => {
},
])('does not offer test sending for $reason', async ({ readonly, canRun, resource }) => {
const user = userEvent.setup()
const requests = setupFetch()
renderTemplate(
{ readonly },
{
@@ -774,6 +789,6 @@ describe('Human Input v2 Message Template', () => {
screen.queryByRole('button', { name: 'workflow.nodes.humanInputV2.template.test' }),
).not.toBeInTheDocument()
expect(mocks.syncDraft).not.toHaveBeenCalled()
expect(requests).toHaveLength(0)
expectNoTemplateTest()
})
})
@@ -1,171 +1,127 @@
import type {
ListContactOptionsResponse,
ListContactsResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { HumanInputV2NodeType } from '../types'
import type useHumanInputV2Config from '../use-config'
import type { NodePanelProps } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/console'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { seedToolProviders } from '@/test/console/tool-providers'
import { renderNodePanel } from '../../__tests__/render-node-panel'
import { HumanInputV2Panel } from '../panel'
import { createChannelsQueryClient } from './channel-fixtures'
const mockHitlIm = vi.hoisted(() => ({ enabled: true }))
const hitlIm = vi.hoisted(() => ({ enabled: true }))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'workspaces.current.humanInput.v2.contacts.get': async () =>
({ data: [], total: 0, page: 1, limit: 1 }) satisfies ListContactsResponse,
'workspaces.current.humanInput.v2.contactOptions.get': async () =>
({ data: [], total: 0, page: 1, limit: 20 }) satisfies ListContactOptionsResponse,
}),
}
})
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
get ENABLE_HITL_IM() {
return mockHitlIm.enabled
return hitlIm.enabled
},
}))
const mockUseConfig = vi.hoisted(() => vi.fn())
const mockSharedPanel = vi.hoisted(() => vi.fn())
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['test-current-user-profile'],
queryFn: async () => ({ profile: { email: 'reviewer@example.com' } }),
}),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseConfig(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
default: () => ({ availableVars: [], availableNodesWithParent: [] }),
}))
vi.mock('@/app/components/workflow/nodes/human-input/shared/panel-sections', () => ({
__esModule: true,
default: (props: unknown) => {
mockSharedPanel(props)
return <div>shared-human-input-sections</div>
},
}))
vi.mock('../components/recipients', () => ({
__esModule: true,
default: (props: { onChange: (value: HumanInputV2NodeType['recipients_spec']) => void }) => (
<button type="button" onClick={() => props.onChange([{ type: 'initiator' }])}>
recipients-section
</button>
),
}))
vi.mock('../components/message-template', () => ({
__esModule: true,
default: (props: { onChange: (value: HumanInputV2NodeType['message_template']) => void }) => (
<button type="button" onClick={() => props.onChange({ subject: 'Subject', body: 'Body' })}>
message-template-section
</button>
),
}))
vi.mock('../components/debug-mode', () => ({
__esModule: true,
default: (props: { onChange: (value: HumanInputV2NodeType['debug_mode']) => void }) => (
<button type="button" onClick={() => props.onChange({ enabled: true, channels: ['email'] })}>
debug-mode-section
</button>
),
}))
const data: HumanInputV2NodeType = {
const createData = (overrides: Partial<HumanInputV2NodeType> = {}): HumanInputV2NodeType => ({
type: BlockEnum.HumanInput,
version: '2',
title: 'Human Input v2',
desc: '',
recipients_spec: [],
message_template: { subject: '', body: '' },
message_template: { subject: 'Original subject', body: 'Review {{#url#}}' },
debug_mode: { enabled: false, channels: [] },
form_content: '',
form_content: 'Review this request',
inputs: [],
user_actions: [],
timeout: 36,
timeout_unit: 'hour',
}
...overrides,
})
const config = {
readOnly: false,
inputs: data,
editorKey: 0,
structuredOutputCollapsed: true,
setStructuredOutputCollapsed: vi.fn(),
handleRecipientsChange: vi.fn(),
handleMessageTemplateChange: vi.fn(),
handleDebugModeChange: vi.fn(),
handleUserActionAdd: vi.fn(),
handleUserActionChange: vi.fn(),
handleUserActionDelete: vi.fn(),
handleTimeoutChange: vi.fn(),
handleFormContentChange: vi.fn(),
handleFormInputsChange: vi.fn(),
handleFormInputItemRename: vi.fn(),
handleFormInputItemRemove: vi.fn(),
} satisfies ReturnType<typeof useHumanInputV2Config>
const renderPanel = (data = createData()) => {
const queryClient = createChannelsQueryClient(['resend', 'slack'])
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
seedAccountProfileQuery(queryClient, { email: 'reviewer@example.com' })
return renderNodePanel(HumanInputV2Panel, data, {
queryClient,
hooksStoreProps: { doSyncWorkflowDraft: async () => null },
})
}
describe('Human Input v2 panel composition', () => {
beforeEach(() => {
mockHitlIm.enabled = true
vi.clearAllMocks()
mockUseConfig.mockReturnValue(config)
hitlIm.enabled = true
})
it('composes v2 sections with shared fields and never renders Delivery Method', async () => {
it('persists recipient, message-template, and debug settings through the live node panel', async () => {
const user = userEvent.setup()
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<HumanInputV2Panel
id="human-input-v2"
data={data}
panelProps={{} as NodePanelProps<HumanInputV2NodeType>['panelProps']}
/>
</QueryClientProvider>,
)
const { data } = renderPanel()
expect(screen.getByText('shared-human-input-sections')).toBeInTheDocument()
expect(screen.queryByText(/delivery method/i)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'recipients-section' }))
await user.click(screen.getByRole('button', { name: 'message-template-section' }))
await user.click(screen.getByRole('button', { name: 'debug-mode-section' }))
expect(config.handleRecipientsChange).toHaveBeenCalledWith([{ type: 'initiator' }])
expect(config.handleMessageTemplateChange).toHaveBeenCalledWith({
subject: 'Subject',
body: 'Body',
const recipient = screen.getByLabelText('workflow.nodes.humanInputV2.recipients.placeholder')
await user.click(recipient)
await user.paste('reviewer@example.com')
await user.keyboard('{Enter}')
expect(data().recipients_spec).toEqual([
{ type: 'onetime_email', email: 'reviewer@example.com' },
])
await user.click(screen.getByRole('button', { name: 'workflow.common.configure' }))
const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')
await user.clear(subject)
await user.type(subject, 'Updated subject')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(data().message_template).toEqual({
subject: 'Updated subject',
body: 'Review {{#url#}}',
})
expect(config.handleDebugModeChange).toHaveBeenCalledWith({
enabled: true,
channels: ['email'],
})
expect(mockSharedPanel).toHaveBeenCalledWith(
expect.objectContaining({ id: 'human-input-v2', config }),
await user.click(
screen.getByRole('button', { name: 'workflow.nodes.humanInputV2.debug.configure' }),
)
await user.click(
screen.getByRole('checkbox', {
name: 'workflow.nodes.humanInputV2.debug.channel.email',
}),
)
await user.click(
screen.getByRole('switch', { name: 'workflow.nodes.humanInputV2.debug.toggle' }),
)
expect(data().debug_mode).toEqual({ enabled: true, channels: ['email'] })
})
it('keeps stored v2 settings intact and unavailable while the feature is disabled', () => {
hitlIm.enabled = false
const stored = createData({
recipients_spec: [{ type: 'contact', contact_id: 'stored-contact' }],
message_template: { subject: 'Stored subject', body: 'Stored {{#url#}}' },
})
const original = structuredClone(stored)
const { data } = renderPanel(stored)
expect(screen.getByRole('status')).toHaveTextContent('workflow.nodes.humanInputV2.disabled')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(data()).toMatchObject(original)
})
})
it('keeps a stored v2 panel unavailable without mounting editable recipient or template flows while disabled', () => {
mockHitlIm.enabled = false
const stored = {
...data,
recipients_spec: [
{ type: 'contact', contact_id: 'stored-contact' },
] as HumanInputV2NodeType['recipients_spec'],
message_template: { subject: 'Stored subject', body: 'Stored {{#url#}}' },
}
const original = structuredClone(stored)
render(
<HumanInputV2Panel
id="human-input-v2"
data={stored}
panelProps={{} as NodePanelProps<HumanInputV2NodeType>['panelProps']}
/>,
)
expect(screen.getByRole('status')).toHaveTextContent('workflow.nodes.humanInputV2.disabled')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.queryByText('recipients-section')).not.toBeInTheDocument()
expect(screen.queryByText('message-template-section')).not.toBeInTheDocument()
expect(screen.queryByText('debug-mode-section')).not.toBeInTheDocument()
expect(config.handleRecipientsChange).not.toHaveBeenCalled()
expect(config.handleMessageTemplateChange).not.toHaveBeenCalled()
expect(config.handleDebugModeChange).not.toHaveBeenCalled()
expect(stored).toEqual(original)
})