test(web): exercise workflow initialization and completion owners (#44660)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-25 03:23:05 +00:00
1 parent f6bb28b6ad
commit 6e4a368da4
3 files changed
+340 -747

No files matched your search

@@ -1,704 +1,302 @@
import type { ReactElement, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type { ModelAndParameter } from '../../types'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { screen } from '@testing-library/react'
import type { VisionFile } from '@/types/app'
import { zProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Log from '@/app/components/base/chat/chat/log'
import { TransferMethod } from '@/app/components/base/chat/types'
import { FeaturesProvider } from '@/app/components/base/features/context'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import DebugConfigurationContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { PromptMode } from '@/models/debug'
import { consoleQuery } from '@/service/console'
import { createCatalogModelFixture, createModelCatalogFixture } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createDatasetFixture } from '@/test/fixtures/dataset'
import {
createDebugConfigurationFixture,
createModelConfigFixture,
} from '@/test/fixtures/debug-configuration'
import { TransferMethod } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../../types'
import { DebugWithMultipleModelContextProvider } from '../context-provider'
import TextGenerationItem from '../text-generation-item'
const mockUseDebugConfigurationContext = vi.fn()
const modelListResponse = vi.fn()
const mockUseFeatures = vi.fn()
const mockUseTextGeneration = vi.fn()
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
const requests = vi.hoisted(() => ({
// oxlint-disable-next-line typescript/no-deprecated -- The existing completion owner uses SSE; retain its typed streaming transport boundary.
stream: vi.fn<typeof import('@/service/base').ssePost>(),
log: vi.fn(),
notifyError: vi.fn(),
}))
let capturedTextGenerationProps: {
onOpenLog: (item: IChatItem) => void
content: string
isLoading: boolean
isResponding: boolean
messageId: string | null
className?: string
} | null = null
let emitDebugEvent: ((v: { type: string; payload?: Record<string, unknown> }) => void) | null = null
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
ssePost: requests.stream,
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
vi.mock('@/service/debug', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/debug')>()),
fetchTextGenerationMessage: requests.log,
}))
vi.mock('@/app/components/base/text-generation/hooks', () => ({
useTextGeneration: () => mockUseTextGeneration(),
}))
vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: mockToastError,
},
toast: { error: requests.notifyError },
}))
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-123' }),
}))
vi.mock('@/app/components/app/text-generate/item', () => ({
default: (props: typeof capturedTextGenerationProps) => {
capturedTextGenerationProps = props
return (
<div data-testid="text-generation">
{props && (
<Log
logItem={{ id: props.messageId!, isAnswer: true, content: props.content }}
onOpenLog={props.onOpenLog}
/>
)}
<span data-testid="content">{props?.content}</span>
<span data-testid="is-loading">{props?.isLoading ? 'yes' : 'no'}</span>
<span data-testid="is-responding">{props?.isResponding ? 'yes' : 'no'}</span>
<span data-testid="message-id">{props?.messageId || 'null'}</span>
</div>
)
},
}))
function EventSource({ children }: { children: ReactNode }) {
const { eventEmitter } = useEventEmitterContextContext()
emitDebugEvent = (event) => eventEmitter!.emit(event)
return children
}
function render(ui: ReactElement) {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryOptions({
input: { params: { model_type: ModelTypeEnum.textGeneration } },
}).queryKey,
modelListResponse(),
)
return renderWithConsoleQuery(
<EventEmitterContextProvider>
<EventSource>{ui}</EventSource>
</EventEmitterContextProvider>,
{ queryClient },
)
}
const createModelAndParameter = (
overrides: Partial<ModelAndParameter> = {},
): ModelAndParameter => ({
const model: ModelAndParameter = {
id: 'model-1',
model: 'gpt-3.5-turbo',
model: 'gpt-4',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
}
const localFile: VisionFile = {
id: 'f1',
type: 'image',
transfer_method: TransferMethod.local_file,
url: 'blob:123',
upload_file_id: 'upload-1',
}
const remoteFile: VisionFile = {
id: 'f2',
type: 'image',
transfer_method: TransferMethod.remote_url,
url: 'https://example.com/file.png',
upload_file_id: '',
}
const DebugProvider = DebugConfigurationContext.Provider
const createDefaultMocks = () => {
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: {
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [{ key: 'name', name: 'Name', type: 'string', is_context_var: false }],
},
},
function renderItem({
configuration = createDebugConfigurationFixture({
appId: 'app-123',
mode: 'completion',
inputs: { name: 'World' },
promptMode: 'simple',
speechToTextConfig: { enabled: true },
introduction: 'Welcome!',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: true },
externalDataToolsConfig: [],
chatPromptConfig: {},
completionPromptConfig: {},
dataSets: [{ id: 'ds-1' }],
datasetConfigs: { retrieval_model: 'single' },
})
modelListResponse.mockReturnValue({
data: [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
model_properties: { mode: 'chat' },
},
],
},
],
})
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = {
features: {
moreLikeThis: { enabled: false },
moderation: { enabled: false },
text2speech: { enabled: false },
file: { enabled: true },
},
}
return selector(state)
})
mockUseTextGeneration.mockReturnValue({
completion: 'Generated text',
handleSend: vi.fn(),
isResponding: false,
messageId: 'msg-123',
})
}),
fileEnabled = true,
onOpenLog = (_item: IChatItem) => {},
}: {
configuration?: ReturnType<typeof createDebugConfigurationFixture>
fileEnabled?: boolean
onOpenLog?: (item: IChatItem) => void
} = {}) {
let emit!: NonNullable<ReturnType<typeof useEventEmitterContextContext>['eventEmitter']>['emit']
function EventSource({ children }: { children: ReactNode }) {
const { eventEmitter } = useEventEmitterContextContext()
emit = (event) => eventEmitter!.emit(event)
return children
}
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({
input: { params: { model_type: ModelTypeEnum.textGeneration } },
}),
{
data: [
zProviderWithModelsResponse.parse(
createModelCatalogFixture({
models: [createCatalogModelFixture({ model_properties: { mode: 'chat' } })],
}),
),
],
},
)
renderWithConsoleQuery(
<DebugProvider value={configuration}>
<FeaturesProvider
features={{
file: { enabled: fileEnabled },
moreLikeThis: { enabled: false },
moderation: { enabled: false },
text2speech: { enabled: false },
}}
>
<EventEmitterContextProvider>
<EventSource>
<DebugWithMultipleModelContextProvider
multipleModelConfigs={[model]}
onMultipleModelConfigsChange={() => {}}
onDebugWithMultipleModelChange={() => {}}
onOpenLog={onOpenLog}
>
<TextGenerationItem modelAndParameter={model} />
</DebugWithMultipleModelContextProvider>
</EventSource>
</EventEmitterContextProvider>
</FeaturesProvider>
</DebugProvider>,
{ queryClient },
)
return {
send: (files: VisionFile[] = [], type = APP_CHAT_WITH_MULTIPLE_MODEL) => {
act(() => emit({ type, payload: { message: 'Generate', files } }))
},
}
}
const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => {
const defaultProps = {
modelAndParameter: createModelAndParameter(),
...props,
}
return render(<TextGenerationItem {...defaultProps} />)
function streamCallbacks() {
expect(requests.stream).toHaveBeenCalledTimes(1)
return requests.stream.mock.calls[0]![2]!
}
function requestBody() {
expect(requests.stream).toHaveBeenCalledTimes(1)
expect(requests.stream.mock.calls[0]![0]).toBe('apps/app-123/completion-messages')
return requests.stream.mock.calls[0]![1].body
}
describe('TextGenerationItem', () => {
beforeEach(() => {
vi.resetAllMocks()
capturedTextGenerationProps = null
emitDebugEvent = null
createDefaultMocks()
})
it('opens this model result through the owning debug session', async () => {
it('shows loading, streams the result and opens its log through the debug session', async () => {
const user = userEvent.setup()
const onOpenLog = vi.fn()
render(
<DebugWithMultipleModelContextProvider
multipleModelConfigs={[]}
onMultipleModelConfigsChange={vi.fn()}
onDebugWithMultipleModelChange={vi.fn()}
onOpenLog={onOpenLog}
>
<TextGenerationItem modelAndParameter={createModelAndParameter()} />
</DebugWithMultipleModelContextProvider>,
requests.log.mockResolvedValue({ id: 'msg-123', answer: 'Generated text', message: [] })
const { send } = renderItem({ onOpenLog })
send()
expect(screen.getByLabelText('common.loading')).toBeInTheDocument()
const stream = streamCallbacks()
act(() =>
stream.onData?.('Generated text', true, {
messageId: 'msg-123',
conversationId: '',
taskId: '',
}),
)
expect(await screen.findByText('Generated text')).toBeVisible()
expect(screen.queryByLabelText('common.loading')).not.toBeInTheDocument()
act(() => stream.onCompleted?.())
await user.click(screen.getByRole('button', { name: 'common.operation.log' }))
expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: 'msg-123', isAnswer: true }),
await waitFor(() =>
expect(onOpenLog).toHaveBeenCalledWith(
expect.objectContaining({
id: 'msg-123',
content: 'Generated text',
isAnswer: true,
}),
),
)
expect(requests.log).toHaveBeenCalledWith({ appId: 'app-123', messageId: 'msg-123' })
})
describe('rendering', () => {
it('should show loading when no completion and responding', () => {
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend: vi.fn(),
isResponding: true,
messageId: null,
})
renderComponent()
expect(screen.getByTestId('is-loading')).toHaveTextContent('yes')
})
it('should not show loading when completion exists', () => {
mockUseTextGeneration.mockReturnValue({
completion: 'Some text',
handleSend: vi.fn(),
isResponding: true,
messageId: 'msg-1',
})
renderComponent()
expect(screen.getByTestId('is-loading')).toHaveTextContent('no')
})
})
describe('config composition', () => {
it('should use prompt_template in non-advanced mode', () => {
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: {
configs: {
prompt_template: 'My Template',
prompt_variables: [],
},
},
appId: 'app-123',
inputs: {},
promptMode: 'simple',
speechToTextConfig: {},
introduction: '',
suggestedQuestionsAfterAnswerConfig: {},
citationConfig: {},
externalDataToolsConfig: [],
chatPromptConfig: {},
completionPromptConfig: {},
dataSets: [],
datasetConfigs: {},
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Generate', files: [] },
})
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
'apps/app-123/completion-messages',
expect.objectContaining({
model_config: expect.objectContaining({ pre_prompt: 'My Template' }),
}),
expect.any(Object),
)
})
it('should use empty pre_prompt in advanced mode', () => {
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: true,
modelConfig: {
configs: {
prompt_template: 'Should not be used',
prompt_variables: [],
},
},
appId: 'app-123',
inputs: {},
promptMode: 'advanced',
speechToTextConfig: {},
introduction: '',
suggestedQuestionsAfterAnswerConfig: {},
citationConfig: {},
externalDataToolsConfig: [],
chatPromptConfig: { custom: true },
completionPromptConfig: { custom: true },
dataSets: [],
datasetConfigs: {},
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Generate', files: [] },
})
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
'apps/app-123/completion-messages',
expect.objectContaining({
model_config: expect.objectContaining({
pre_prompt: '',
prompt_type: 'advanced',
chat_prompt_config: { custom: true },
completion_prompt_config: { custom: true },
}),
}),
expect.any(Object),
)
})
it('should find context variable from prompt_variables', () => {
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: {
configs: {
prompt_template: '',
prompt_variables: [
{ key: 'context', name: 'Context', type: 'string', is_context_var: true },
{ key: 'query', name: 'Query', type: 'string', is_context_var: false },
],
},
},
appId: 'app-123',
inputs: {},
promptMode: 'simple',
speechToTextConfig: {},
introduction: '',
suggestedQuestionsAfterAnswerConfig: {},
citationConfig: {},
externalDataToolsConfig: [],
chatPromptConfig: {},
completionPromptConfig: {},
dataSets: [],
datasetConfigs: {},
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Generate', files: [] },
})
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
'apps/app-123/completion-messages',
expect.objectContaining({
model_config: expect.objectContaining({ dataset_query_variable: 'context' }),
}),
expect.any(Object),
)
})
})
describe('dataset configuration', () => {
it('should transform dataSets to postDatasets format', () => {
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: {
configs: { prompt_template: '', prompt_variables: [] },
},
appId: 'app-123',
inputs: {},
promptMode: 'simple',
speechToTextConfig: {},
introduction: '',
suggestedQuestionsAfterAnswerConfig: {},
citationConfig: {},
externalDataToolsConfig: [],
chatPromptConfig: {},
completionPromptConfig: {},
dataSets: [{ id: 'ds-1' }, { id: 'ds-2' }],
datasetConfigs: { retrieval_model: 'multiple' },
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Generate', files: [] },
})
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
'apps/app-123/completion-messages',
expect.objectContaining({
model_config: expect.objectContaining({
dataset_configs: {
retrieval_model: 'multiple',
datasets: {
datasets: [
{ dataset: { enabled: true, id: 'ds-1' } },
{ dataset: { enabled: true, id: 'ds-2' } },
],
},
},
}),
}),
expect.any(Object),
)
})
})
describe('event subscription', () => {
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Generate text', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
'apps/app-123/completion-messages',
expect.objectContaining({
inputs: { name: 'World' },
}),
expect.objectContaining({
onNotifyError: expect.any(Function),
}),
)
const callbacks = handleSend.mock.calls[0]![2] as {
onNotifyError: (message: string) => void
}
callbacks.onNotifyError('Base model not found')
expect(mockToastError).toHaveBeenCalledWith('Base model not found')
})
it('should ignore other event types', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
renderComponent()
emitDebugEvent?.({
type: 'OTHER_EVENT',
payload: {},
})
expect(handleSend).not.toHaveBeenCalled()
})
})
describe('doSend function', () => {
it('should include model configuration', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
const modelAndParameter = createModelAndParameter({
provider: 'anthropic',
model: 'claude-3',
parameters: { max_tokens: 2000 },
})
renderComponent({ modelAndParameter })
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Test', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
model: expect.objectContaining({
provider: 'anthropic',
name: 'claude-3',
completion_params: { max_tokens: 2000 },
}),
}),
}),
expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
it('should include files with local_file transfer method handled', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
renderComponent()
const files = [
{ id: 'f1', transfer_method: TransferMethod.local_file, url: 'blob:123' },
{ id: 'f2', transfer_method: TransferMethod.remote_url, url: 'https://example.com/file' },
]
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Test', files },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
files: [
expect.objectContaining({
id: 'f1',
transfer_method: TransferMethod.local_file,
url: '',
}),
expect.objectContaining({
id: 'f2',
transfer_method: TransferMethod.remote_url,
url: 'https://example.com/file',
}),
it('sends the configured prompt, input schema, model and datasets to the completion service', () => {
const configuration = createDebugConfigurationFixture({
appId: 'app-123',
mode: 'completion',
inputs: { name: 'World', age: '42' },
modelConfig: createModelConfigFixture({
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string', required: true },
{ key: 'age', name: 'Age', type: 'number', required: true },
{ key: 'context', name: 'Context', type: 'string', is_context_var: true },
],
}),
expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
it('should not include files when file upload is disabled', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
mockUseFeatures.mockImplementation(
(selector: (state: Record<string, unknown>) => unknown) => {
const state = {
features: {
moreLikeThis: { enabled: false },
moderation: { enabled: false },
text2speech: { enabled: false },
file: { enabled: false },
},
}
return selector(state)
},
)
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Test', files: [{ id: 'f1' }] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.not.objectContaining({
files: expect.anything(),
}),
expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
}),
dataSets: [createDatasetFixture({ id: 'ds-1' }), createDatasetFixture({ id: 'ds-2' })],
})
const { send } = renderItem({ configuration })
send([localFile, remoteFile])
it('should not include files when files array is empty', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Test', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.not.objectContaining({
files: expect.anything(),
}),
expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
})
describe('model resolution', () => {
it('should find current provider and model', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
modelListResponse.mockReturnValue({
data: [
{
provider: 'openai',
models: [
{ model: 'gpt-3.5-turbo', model_properties: { mode: 'chat' } },
{ model: 'gpt-4', model_properties: { mode: 'chat' } },
expect(requestBody()).toMatchObject({
response_mode: 'streaming',
inputs: { name: 'World', age: '42' },
model_config: {
pre_prompt: 'Hello {{name}}',
prompt_type: 'simple',
dataset_query_variable: 'context',
model: {
provider: 'openai',
name: 'gpt-4',
mode: 'chat',
completion_params: { temperature: 0.7 },
},
file_upload: { enabled: true },
dataset_configs: {
datasets: {
datasets: [
{ dataset: { enabled: true, id: 'ds-1' } },
{ dataset: { enabled: true, id: 'ds-2' } },
],
},
],
})
const modelAndParameter = createModelAndParameter({
provider: 'openai',
model: 'gpt-4',
})
renderComponent({ modelAndParameter })
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Test', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
model: expect.objectContaining({
mode: 'chat',
},
user_input_form: expect.arrayContaining([
{
'text-input': expect.objectContaining({
variable: 'name',
label: 'Name',
required: true,
}),
}),
}),
expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
},
{ number: expect.objectContaining({ variable: 'age', label: 'Age', required: true }) },
]),
},
files: [
{ id: 'f1', transfer_method: TransferMethod.local_file, url: '' },
{
id: 'f2',
transfer_method: TransferMethod.remote_url,
url: 'https://example.com/file.png',
},
],
})
})
describe('promptVariablesToUserInputsForm', () => {
it('sends the generated input schema with the completion request', () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend,
isResponding: false,
messageId: null,
})
const promptVariables = [
{ key: 'name', name: 'Name', type: 'string' },
{ key: 'age', name: 'Age', type: 'number' },
]
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: {
configs: { prompt_template: '', prompt_variables: promptVariables },
},
appId: 'app-123',
inputs: {},
promptMode: 'simple',
speechToTextConfig: {},
introduction: '',
suggestedQuestionsAfterAnswerConfig: {},
citationConfig: {},
externalDataToolsConfig: [],
chatPromptConfig: {},
completionPromptConfig: {},
dataSets: [],
datasetConfigs: {},
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Generate text', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
'apps/app-123/completion-messages',
expect.objectContaining({
model_config: expect.objectContaining({
user_input_form: [
{
'text-input': expect.objectContaining({
variable: 'name',
label: 'Name',
required: true,
}),
},
{
number: expect.objectContaining({ variable: 'age', label: 'Age', required: true }),
},
],
}),
}),
expect.any(Object),
)
it('sends advanced prompts instead of the simple template', () => {
const configuration = createDebugConfigurationFixture({
appId: 'app-123',
mode: 'completion',
isAdvancedMode: true,
promptMode: PromptMode.advanced,
modelConfig: createModelConfigFixture({
configs: { prompt_template: 'Unused template', prompt_variables: [] },
}),
chatPromptConfig: { prompt: [{ role: 'system', text: 'Advanced chat prompt' }] },
completionPromptConfig: {
...createDebugConfigurationFixture().completionPromptConfig,
prompt: { role: 'system', text: 'Advanced completion prompt' },
},
})
const { send } = renderItem({ configuration })
send()
expect(requestBody()).toMatchObject({
model_config: {
pre_prompt: '',
prompt_type: 'advanced',
chat_prompt_config: configuration.chatPromptConfig,
completion_prompt_config: configuration.completionPromptConfig,
},
})
})
it.each([
{
name: 'uploads are disabled',
fileEnabled: false,
files: [localFile],
},
{ name: 'no files are selected', fileEnabled: true, files: [] },
])('omits attachments when $name', ({ fileEnabled, files }) => {
const { send } = renderItem({ fileEnabled })
send(files)
expect(requestBody()).not.toHaveProperty('files')
})
it('ignores unrelated events', () => {
const { send } = renderItem()
send([], 'OTHER_EVENT')
expect(requests.stream).not.toHaveBeenCalled()
expect(screen.queryByLabelText('common.loading')).not.toBeInTheDocument()
})
it('reports service notification errors', () => {
const { send } = renderItem()
send()
act(() => streamCallbacks().onNotifyError?.('Base model not found'))
expect(requests.notifyError).toHaveBeenCalledWith('Base model not found')
})
})
@@ -1,7 +1,9 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { ReactElement, ReactNode } from 'react'
import type { ReactElement } from 'react'
import { screen, waitFor } from '@testing-library/react'
import { StrictMode } from 'react'
import { createEdge, createNode } from '@/app/components/workflow/__tests__/fixtures'
import { WorkflowInitializationProbe } from '@/app/components/workflow/__tests__/workflow-initialization-probe'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import { BlockEnum } from '@/app/components/workflow/types'
@@ -12,6 +14,7 @@ import { seedAppDetail } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
import { createWorkflowResponseFixture } from '@/test/fixtures/workflow-response'
import { AppACLPermission } from '@/utils/permission'
import WorkflowApp from '../index'
@@ -20,18 +23,7 @@ let workflowStore: ReturnType<typeof useWorkflowStore>
let appDetail: AppDetailWithSite
let workflowInitState: {
data: {
graph: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: { x: number; y: number; zoom: number }
}
features: Record<string, unknown>
} | null
isLoading: boolean
fileUploadConfigResponse: Record<string, unknown> | null
}
const fetchDraft = vi.fn()
let consoleState: {
isLoadingCurrentWorkspace: boolean
@@ -105,72 +97,59 @@ vi.mock('@/next/navigation', async (importOriginal) => ({
}),
}))
// Draft loading has its own suite; this owner integrates ready/loading data, replay and trigger status.
vi.mock('../hooks/use-workflow-init', () => ({ useWorkflowInit: () => workflowInitState }))
vi.mock('@/service/workflow', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/workflow')>()),
fetchWorkflowDraft: (...args: unknown[]) => fetchDraft(...args),
}))
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, {
'files.upload.get': () => createFileUploadConfigFixture(),
'apps.byAppId.workflows.defaultWorkflowBlockConfigs.get': () => [],
'apps.byAppId.workflows.publish.get': () => createWorkflowResponseFixture(),
}),
}
})
vi.mock('@/service/log', () => ({
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({
nodes,
edges,
children,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
children: ReactNode
}) => (
<div
data-testid="workflow-default-context"
data-nodes={JSON.stringify(nodes)}
data-edges={JSON.stringify(edges)}
>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
default: function WorkflowMainMock({
nodes,
edges,
// Full editor interactions have their own suites; this probe observes the real initialized store.
default: function WorkflowMainProbe({
viewport,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: Record<string, unknown>
viewport: { x: number; y: number; zoom: number }
}) {
workflowStore = useWorkflowStore()
return (
<div
role="region"
aria-label="Workflow canvas"
data-nodes={JSON.stringify(nodes)}
data-edges={JSON.stringify(edges)}
data-viewport={JSON.stringify(viewport)}
/>
<section aria-label="Workflow canvas">
<WorkflowInitializationProbe />
<output aria-label="Saved viewport">{JSON.stringify(viewport)}</output>
</section>
)
},
}))
describe('WorkflowApp', () => {
beforeEach(() => {
vi.clearAllMocks()
useTriggerStatusStore.getState().clearTriggerStatuses()
appDetail = createAppDetailFixture({
id: 'app-1',
mode: 'workflow' as const,
permission_keys: [AppACLPermission.TestAndRun],
})
workflowInitState = {
data: {
fetchDraft.mockResolvedValue(
createWorkflowResponseFixture({
hash: 'loaded-draft',
graph: {
nodes: [
createNode({ id: 'raw-node', data: { type: BlockEnum.Start } }),
createNode({ id: 'end-node', data: { type: BlockEnum.End } }),
createNode({ id: 'raw-node', data: { type: BlockEnum.Start, variables: [] } }),
createNode({ id: 'end-node', data: { type: BlockEnum.End, outputs: [] } }),
],
edges: [
createEdge({
@@ -180,17 +159,11 @@ describe('WorkflowApp', () => {
data: { targetType: BlockEnum.End },
}),
],
viewport: { x: 1, y: 2, zoom: 3 },
viewport: { x: 1, y: 2, zoom: 1.5 },
},
features: {
file_upload: {
enabled: true,
},
},
},
isLoading: false,
fileUploadConfigResponse: createFileUploadConfigFixture(),
}
features: { file_upload: { enabled: true } },
}),
)
consoleState = {
isLoadingCurrentWorkspace: false,
currentWorkspace: { id: 'workspace-1' },
@@ -202,20 +175,36 @@ describe('WorkflowApp', () => {
mockFetchRunDetail.mockReset().mockResolvedValue({ inputs: null })
})
it('should render the loading shell while workflow data is still loading', () => {
workflowInitState = {
data: null,
isLoading: true,
fileUploadConfigResponse: null,
}
it('keeps the canvas gated until the workflow draft arrives', async () => {
let resolveDraft!: (value: ReturnType<typeof createWorkflowResponseFixture>) => void
fetchDraft.mockReturnValueOnce(
new Promise((resolve) => {
resolveDraft = resolve
}),
)
render(<WorkflowApp />)
expect(screen.getByLabelText('common.loading')).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Workflow canvas' })).not.toBeInTheDocument()
resolveDraft(
createWorkflowResponseFixture({
graph: {
nodes: [
createNode({ id: 'loaded-start', data: { type: BlockEnum.Start, variables: [] } }),
],
edges: [],
},
}),
)
expect(await screen.findByRole('region', { name: 'Workflow canvas' })).toBeInTheDocument()
expect(await screen.findByRole('status', { name: 'Initialized nodes' })).toHaveTextContent(
'loaded-start:Node loaded-start',
)
expect(screen.queryByLabelText('common.loading')).not.toBeInTheDocument()
})
it('should render the workflow app shell and sync trigger statuses when data is ready', () => {
it('initializes the graph and trigger statuses in the real workflow providers', async () => {
appTriggersState = {
data: {
data: [
@@ -225,23 +214,24 @@ describe('WorkflowApp', () => {
},
}
render(<WorkflowApp />)
render(
<StrictMode>
<WorkflowApp />
</StrictMode>,
)
const graphContext = screen.getByTestId('workflow-default-context')
expect(JSON.parse(graphContext.getAttribute('data-nodes')!)).toMatchObject([
{ id: 'raw-node', data: { type: BlockEnum.Start, _connectedSourceHandleIds: ['source'] } },
{ id: 'end-node', data: { type: BlockEnum.End, _connectedTargetHandleIds: ['target'] } },
])
expect(JSON.parse(graphContext.getAttribute('data-edges')!)).toMatchObject([
{
source: 'raw-node',
target: 'end-node',
data: { sourceType: BlockEnum.Start, targetType: BlockEnum.End },
},
])
expect(screen.getByRole('region', { name: 'Workflow canvas' })).toHaveAttribute(
'data-viewport',
JSON.stringify({ x: 1, y: 2, zoom: 3 }),
await screen.findByRole('region', { name: 'Workflow canvas' })
expect(await screen.findByRole('status', { name: 'Draft hash' })).toHaveTextContent(
'loaded-draft',
)
expect(await screen.findByRole('status', { name: 'Initialized nodes' })).toHaveTextContent(
'raw-node:Node raw-node,end-node:Node end-node',
)
expect(await screen.findByRole('status', { name: 'Initialized edges' })).toHaveTextContent(
'raw-node->end-node',
)
expect(screen.getByLabelText('Saved viewport')).toHaveTextContent(
JSON.stringify({ x: 1, y: 2, zoom: 1.5 }),
)
expect(useTriggerStatusStore.getState().triggerStatuses).toEqual({
'trigger-enabled': 'enabled',
@@ -256,7 +246,8 @@ describe('WorkflowApp', () => {
'{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}',
})
const { unmount } = render(<WorkflowApp />)
render(<WorkflowApp />)
await screen.findByRole('region', { name: 'Workflow canvas' })
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/apps/app-1/workflow-runs/run-1')
@@ -270,12 +261,11 @@ describe('WorkflowApp', () => {
expect(workflowStore.getState().showInputsPanel).toBe(true)
expect(workflowStore.getState().showDebugAndPreviewPanel).toBe(true)
})
unmount()
})
it('should skip replay lookups when replayRunId is missing', () => {
it('should skip replay lookups when replayRunId is missing', async () => {
render(<WorkflowApp />)
await screen.findByRole('region', { name: 'Workflow canvas' })
expect(mockFetchRunDetail).not.toHaveBeenCalled()
expect(workflowStore.getState().inputs).toEqual({})
@@ -307,6 +297,7 @@ describe('WorkflowApp', () => {
})
render(<WorkflowApp />)
await screen.findByRole('region', { name: 'Workflow canvas' })
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/apps/app-1/workflow-runs/run-1')
@@ -330,6 +321,7 @@ describe('WorkflowApp', () => {
})
render(<WorkflowApp />)
await screen.findByRole('region', { name: 'Workflow canvas' })
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/apps/app-1/workflow-runs/run-1')
@@ -4,6 +4,8 @@ import { useStore } from '../store'
// graph initialized by the real provider instead of capturing provider props in a mock.
export function WorkflowInitializationProbe() {
const graph = useStore((state) => state.workflowHistory)
const draftHash = useStore((state) => state.syncWorkflowDraftHash)
return (
<>
<output aria-label="Initialized nodes">
@@ -12,6 +14,7 @@ export function WorkflowInitializationProbe() {
<output aria-label="Initialized edges">
{graph.edges.map((edge) => `${edge.source}->${edge.target}`).join(',')}
</output>
<output aria-label="Draft hash">{draftHash}</output>
</>
)
}