mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): replace HITL owner fakes and editor shims (#45189)
This commit is contained in:
1 parent
dca4024e57
commit
2050078a5f
4 files changed
+608
-938
No files matched your search
+123
-169
@@ -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,
|
||||
)
|
||||
})
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
+171
-426
@@ -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()
|
||||
},
|
||||
)
|
||||
})
|
||||
+222
-207
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user