test(web): replace editor and upload doubles with behavioral tests (#45077)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-03 02:05:28 +00:00
1 parent 3c26fe8905
commit c5ee60f1a1
4 files changed
+180 -155

No files matched your search

@@ -1,10 +1,17 @@
import { act, renderHook } from '@testing-library/react'
import type { PropsWithChildren } from 'react'
import { act, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import DebugConfigurationContext from '@/context/debug-configuration'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { PromptMode } from '@/models/debug'
import { createDatasetFixture } from '@/test/fixtures/dataset'
import {
createDatasetConfigsFixture,
createDebugConfigurationFixture,
createModelConfigFixture,
} from '@/test/fixtures/debug-configuration'
import { AgentStrategy } from '@/types/app'
import { AgentStrategy, RETRIEVE_TYPE } from '@/types/app'
import {
useConfigFromDebugContext,
useDebugWithSingleOrMultipleModel,
@@ -12,15 +19,8 @@ import {
useFormattingChangedSubscription,
} from '../hooks'
const mockUseDebugConfigurationContext = vi.fn()
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
describe('configuration debug hooks', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
@@ -66,7 +66,7 @@ describe('configuration debug hooks', () => {
})
it('should derive chat config data from the debug context', () => {
mockUseDebugConfigurationContext.mockReturnValue({
const configuration = createDebugConfigurationFixture({
isAdvancedMode: false,
modelConfig: createModelConfigFixture({
agentConfig: {
@@ -82,24 +82,26 @@ describe('configuration debug hooks', () => {
more_like_this: { enabled: true },
}),
appId: 'app-1',
promptMode: 'simple',
promptMode: PromptMode.simple,
speechToTextConfig: { enabled: false },
introduction: 'hello',
suggestedQuestions: ['how are you?'],
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: true },
moderationConfig: { enabled: false },
chatPromptConfig: { prompt: [] },
completionPromptConfig: { prompt: { text: 'completion' } },
dataSets: [{ id: 'dataset-1' }],
datasetConfigs: createDatasetConfigsFixture({ retrieval_model: 'multiple' }),
visionConfig: { transfer_methods: ['local_file'], number_limits: 2 },
annotationConfig: { enabled: false },
dataSets: [createDatasetFixture({ id: 'dataset-1' })],
datasetConfigs: createDatasetConfigsFixture({ retrieval_model: RETRIEVE_TYPE.multiWay }),
textToSpeechConfig: { enabled: false, voice: '', language: '' },
isFunctionCall: true,
})
const { result } = renderHook(() => useConfigFromDebugContext())
const { result } = renderHook(() => useConfigFromDebugContext(), {
wrapper: ({ children }: PropsWithChildren) => (
<DebugConfigurationContext.Provider value={configuration}>
{children}
</DebugConfigurationContext.Provider>
),
})
expect(result.current).toEqual(
expect.objectContaining({
@@ -117,26 +119,41 @@ describe('configuration debug hooks', () => {
})
it.each([
{ hasAnswer: true, formattingChanged: false, calls: 1 },
{ hasAnswer: false, formattingChanged: false, calls: 0 },
{ hasAnswer: true, formattingChanged: true, calls: 0 },
{ hasAnswer: true, initiallyChanged: false, expected: 'Refresh required' },
{ hasAnswer: false, initiallyChanged: false, expected: 'Up to date' },
{ hasAnswer: true, initiallyChanged: true, expected: 'Refresh required' },
])(
'dispatches formatting changes only for existing answers requiring refresh: %j',
({ hasAnswer, formattingChanged, calls }) => {
const setFormattingChanged = vi.fn()
mockUseDebugConfigurationContext.mockReturnValue({ formattingChanged, setFormattingChanged })
const { result } = renderHook(
() => {
useFormattingChangedSubscription([
{ id: 'message-1', content: 'Hello', isAnswer: hasAnswer },
])
return useFormattingChangedDispatcher()
},
{ wrapper: EventEmitterContextProvider },
'updates formatting state for the current conversation: %j',
async ({ hasAnswer, initiallyChanged, expected }) => {
function Conversation() {
const dispatch = useFormattingChangedDispatcher()
useFormattingChangedSubscription([
{ id: 'message-1', content: 'Hello', isAnswer: hasAnswer },
])
return <button onClick={dispatch}>Change configuration</button>
}
function Session() {
const [formattingChanged, setFormattingChanged] = useState(initiallyChanged)
return (
<DebugConfigurationContext.Provider
value={createDebugConfigurationFixture({ formattingChanged, setFormattingChanged })}
>
<Conversation />
<output>{formattingChanged ? 'Refresh required' : 'Up to date'}</output>
</DebugConfigurationContext.Provider>
)
}
const user = userEvent.setup()
render(
<EventEmitterContextProvider>
<Session />
</EventEmitterContextProvider>,
)
act(() => result.current())
expect(setFormattingChanged).toHaveBeenCalledTimes(calls)
if (calls) expect(setFormattingChanged).toHaveBeenCalledWith(true)
expect(screen.getByRole('status')).toHaveTextContent(
initiallyChanged ? 'Refresh required' : 'Up to date',
)
await user.click(screen.getByRole('button', { name: 'Change configuration' }))
expect(screen.getByRole('status')).toHaveTextContent(expected)
},
)
})
@@ -1,18 +1,22 @@
import type { ComponentProps } from 'react'
import type { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vite-plus/test'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createDeferred } from '@/test/deferred'
import { TransferMethod } from '@/types/app'
import HumanInputForm from '../human-input-form'
const releaseGate = vi.hoisted(() => ({ enabled: true }))
// The uploader owns file state; only its multipart network transport is controlled.
const { uploadRequest } = vi.hoisted(() => ({
uploadRequest: vi.fn<(options: { data: FormData }) => Promise<unknown>>(),
}))
// The build-time release flag has no runtime control, so vary that configuration boundary directly.
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
get ENABLE_HITL_IM() {
@@ -20,38 +24,28 @@ vi.mock('@/config', async (importOriginal) => ({
},
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
fileConfig,
}: ComponentProps<typeof FileUploaderInAttachmentWrapper>) => {
const single = fileConfig.number_limits === 1
const update = (progress: number) =>
onChange([
{
id: single ? 'file-2' : 'file-1',
name: single ? 'main.png' : 'avatar.png',
size: 128,
type: 'image/png',
progress,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.image,
uploadedId: progress === 100 ? (single ? 'upload-file-2' : 'upload-file-1') : undefined,
},
])
return (
<>
<button onClick={() => update(50)}>
{single ? 'Upload single file' : 'Upload file list'}
</button>
<button onClick={() => update(100)}>
{single ? 'Finish single file' : 'Finish file list'}
</button>
</>
)
},
vi.mock('@/next/navigation', () => ({ useParams: () => ({}), usePathname: () => '/' }))
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
upload: uploadRequest,
}))
const uploadResponse = (file: File) => ({
id: file.name === 'main.png' ? 'upload-file-2' : 'upload-file-1',
name: file.name,
size: file.size,
mime_type: file.type,
extension: 'png',
created_at: 1,
created_by: 'user-1',
preview_url: null,
source_url: '',
})
beforeEach(() => {
uploadRequest.mockImplementation(async ({ data }) => uploadResponse(data.get('file') as File))
})
describe('HumanInputForm', () => {
beforeEach(() => {
releaseGate.enabled = true
@@ -70,7 +64,7 @@ describe('HumanInputForm', () => {
type: 'paragraph',
output_variable_name: 'field1',
default: { type: 'constant', value: 'initial', selector: [] },
} as FormInputItem,
},
],
actions: [
{ id: 'action_1', title: 'Submit', button_style: UserActionButtonType.Primary },
@@ -160,15 +154,24 @@ describe('HumanInputForm', () => {
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 5,
},
] as FormInputItem[],
],
}
render(<HumanInputForm formData={formDataWithFileList} onSubmit={mockOnSubmit} />)
const { container } = render(
<HumanInputForm formData={formDataWithFileList} onSubmit={mockOnSubmit} />,
)
const input = screen.getByRole('textbox', { name: 'field1' })
await user.clear(input)
await user.type(input, 'new value')
await user.click(screen.getByRole('button', { name: 'Finish file list' }))
// The current file chooser has no label; target the native upload input.
await user.upload(
container.querySelector<HTMLInputElement>('input[type="file"]')!,
new File(['avatar'], 'avatar.png', { type: 'image/png' }),
)
await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled())
expect(screen.getByText('avatar.png')).toBeVisible()
expect((uploadRequest.mock.calls[0]![0].data.get('file') as File).name).toBe('avatar.png')
await user.click(screen.getByRole('button', { name: 'Submit' }))
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
@@ -218,28 +221,67 @@ describe('HumanInputForm', () => {
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
],
}
render(
const singleUpload = createDeferred<ReturnType<typeof uploadResponse>>()
const listUpload = createDeferred<ReturnType<typeof uploadResponse>>()
const replacementUpload = createDeferred<ReturnType<typeof uploadResponse>>()
let listAttempts = 0
uploadRequest.mockImplementation(({ data }) => {
const file = data.get('file') as File
if (file.name === 'main.png') return singleUpload.promise
return listAttempts++ === 0 ? listUpload.promise : replacementUpload.promise
})
const { container } = render(
<HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />,
)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).toBeDisabled()
await user.click(screen.getByRole('combobox', { name: 'field2' }))
await user.click(screen.getByRole('option', { name: 'approved' }))
await user.click(screen.getByRole('button', { name: 'Upload single file' }))
await user.click(screen.getByRole('button', { name: 'Finish file list' }))
expect(submitButton).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Finish single file' }))
await user.click(screen.getByRole('button', { name: 'Upload file list' }))
const singleFile = new File(['main'], 'main.png', { type: 'image/png' })
const listFile = new File(['avatar'], 'avatar.png', { type: 'image/png' })
// Native input.multiple distinguishes the single-file and file-list fields.
await user.upload(
container.querySelector<HTMLInputElement>('input[type="file"]:not([multiple])')!,
singleFile,
)
await screen.findByText('main.png')
expect(submitButton).toBeDisabled()
await user.click(submitButton)
expect(mockOnSubmit).not.toHaveBeenCalled()
await user.upload(
container.querySelector<HTMLInputElement>('input[type="file"][multiple]')!,
listFile,
)
await screen.findByText('avatar.png')
expect(submitButton).toBeDisabled()
await user.click(submitButton)
expect(mockOnSubmit).not.toHaveBeenCalled()
await act(async () => listUpload.resolve(uploadResponse(listFile)))
// A completed file list must not hide a still-pending single-file upload.
expect(submitButton).toBeDisabled()
await user.click(submitButton)
expect(mockOnSubmit).not.toHaveBeenCalled()
await act(async () => singleUpload.resolve(uploadResponse(singleFile)))
await waitFor(() => expect(submitButton).toBeEnabled())
await user.click(screen.getByRole('button', { name: 'Finish file list' }))
expect(submitButton).toBeEnabled()
// Replace the completed list file through the real removal/upload controls.
await user.click(screen.getByRole('button', { name: 'common.operation.remove avatar.png' }))
expect(submitButton).toBeDisabled()
await user.upload(
container.querySelector<HTMLInputElement>('input[type="file"][multiple]')!,
listFile,
)
await screen.findByText('avatar.png')
expect(submitButton).toBeDisabled()
await user.click(submitButton)
expect(mockOnSubmit).not.toHaveBeenCalled()
await act(async () => replacementUpload.resolve(uploadResponse(listFile)))
await waitFor(() => expect(submitButton).toBeEnabled())
await user.click(submitButton)
@@ -297,7 +339,7 @@ describe('HumanInputForm', () => {
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
],
}
render(<HumanInputForm formData={formDataWithStaleInput} onSubmit={mockOnSubmit} />)
@@ -1,59 +1,30 @@
import { act, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import JsonImporter from '../json-importer'
import { MittProvider, useMittContext, VisualEditorContext } from '../visual-editor/context'
import { MittProvider, VisualEditorContext } from '../visual-editor/context'
import { createVisualEditorStore } from '../visual-editor/store'
// Monaco editing is outside this test; parsing, depth validation and editor state remain real.
vi.mock('../code-editor', () => ({
default: ({ value, onUpdate }: { value: string; onUpdate: (value: string) => void }) => (
<textarea
aria-label="JSON input"
value={value}
onChange={(event) => onUpdate(event.target.value)}
/>
),
}))
// Use the shared external Monaco shim, keeping Dify's code editor real.
function setup() {
const store = createVisualEditorStore()
const onSubmit = vi.fn()
const onQuitEditing = vi.fn()
function Subscriber() {
useMittContext().useSubscribe('quitEditing', onQuitEditing)
return null
}
render(
<VisualEditorContext value={store}>
<MittProvider>
<Subscriber />
<JsonImporter onSubmit={onSubmit} updateBtnWidth={vi.fn()} />
</MittProvider>
</VisualEditorContext>,
)
return { store, onSubmit, onQuitEditing, user: userEvent.setup() }
return { onSubmit, user: userEvent.setup() }
}
const importButton = () => screen.getByRole('button', { name: /nodes.llm.jsonSchema.import/ })
const submitButton = () => screen.getByRole('button', { name: /operation.submit/ })
it.each(['advancedEditing', 'isAddingNewField'] as const)(
'notifies actual editor subscribers when opening after %s changes',
async (flag) => {
const { store, user, onQuitEditing } = setup()
await user.click(importButton())
expect(onQuitEditing).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: /operation.cancel/ }))
act(() => store.setState({ [flag]: true }))
await user.click(importButton())
expect(onQuitEditing).toHaveBeenCalledExactlyOnceWith({})
},
)
it.each(['[]', '42'])('rejects non-object JSON %s', async (value) => {
const { user, onSubmit } = setup()
await user.click(importButton())
await user.click(screen.getByRole('textbox', { name: 'JSON input' }))
await user.click(screen.getByTestId('monaco-editor'))
await user.paste(value)
await user.click(submitButton())
expect(
@@ -68,7 +39,7 @@ it('rejects objects exceeding the real depth limit and accepts a corrected objec
let nested: Record<string, unknown> = { leaf: 'value' }
for (let level = 0; level < JSON_SCHEMA_MAX_DEPTH; level++) nested = { nested }
await user.click(importButton())
const input = screen.getByRole('textbox', { name: 'JSON input' })
const input = screen.getByTestId('monaco-editor')
await user.click(input)
await user.paste(JSON.stringify(nested))
await user.click(submitButton())
@@ -96,7 +67,7 @@ it.each([1, 2])(
return { rows }
}
await user.click(importButton())
const input = screen.getByRole('textbox', { name: 'JSON input' })
const input = screen.getByTestId('monaco-editor')
await user.click(input)
await user.paste(JSON.stringify(exampleWithSchemaDepth(JSON_SCHEMA_MAX_DEPTH + 1)))
await user.click(submitButton())
@@ -126,7 +97,7 @@ it('preserves empty arrays, primitive arrays and first-item schema inference', a
objectFirst: [{ leaf: 'first' }, laterItem],
}
await user.click(importButton())
await user.click(screen.getByRole('textbox', { name: 'JSON input' }))
await user.click(screen.getByTestId('monaco-editor'))
await user.paste(JSON.stringify(example))
await user.click(submitButton())
expect(onSubmit).toHaveBeenCalledExactlyOnceWith(example)
@@ -136,7 +107,7 @@ it('preserves empty arrays, primitive arrays and first-item schema inference', a
it('reports malformed JSON and allows correction without replacing JSON.parse', async () => {
const { user, onSubmit } = setup()
await user.click(importButton())
const input = screen.getByRole('textbox', { name: 'JSON input' })
const input = screen.getByTestId('monaco-editor')
await user.clear(input)
await user.paste('{bad')
await user.click(submitButton())
@@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import type { TemplateTransformNodeType } from '../types'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -11,33 +10,26 @@ import { seedToolProviders } from '@/test/console/tool-providers'
import { BlockEnum, VarType } from '../../../types'
import Panel from '../panel'
// The external code editor is a text-input boundary; variable editing and configuration stay real.
vi.mock(
'@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars',
() => ({
default: ({
value,
onChange,
readOnly,
headerRight,
}: {
value: string
onChange: (value: string) => void
readOnly: boolean
headerRight: ReactNode
}) => (
<div>
{headerRight}
<textarea
aria-label="template-editor"
value={value}
readOnly={readOnly}
onChange={(event) => onChange(event.target.value)}
/>
</div>
),
}),
)
// Only the external Monaco runtime is replaced; Dify's editor wrappers remain real.
vi.mock('@monaco-editor/react', () => ({
default: ({
value,
onChange,
options,
}: {
value: string
onChange: (value: string) => void
options: { readOnly?: boolean }
}) => (
<textarea
aria-label="Template editor"
value={value}
readOnly={options.readOnly}
onChange={(event) => onChange(event.target.value)}
/>
),
loader: { config: vi.fn() },
}))
const payload: TemplateTransformNodeType = {
title: 'Template',
@@ -99,7 +91,7 @@ it('renames real input variables and keeps the template synchronized through sub
const variable = screen.getByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' })
// One change event represents replacing the selected identifier, preserving a valid name throughout.
fireEvent.change(variable, { target: { value: 'renamed_input' } })
expect(screen.getByRole('textbox', { name: 'template-editor' })).toHaveValue(
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveValue(
'{{ renamed_input }}',
)
await user.click(
@@ -111,7 +103,7 @@ it('renames real input variables and keeps the template synchronized through sub
screen.getAllByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' }),
).toHaveLength(2)
expect(variable).toHaveValue('renamed_input')
expect(screen.getByRole('textbox', { name: 'template-editor' })).toHaveValue(
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveValue(
'{{ renamed_input }}',
)
expect(
@@ -119,14 +111,17 @@ it('renames real input variables and keeps the template synchronized through sub
).toHaveAttribute('href', 'https://jinja.palletsprojects.com/en/3.1.x/templates/')
})
it('keeps both variable names and the template readonly when editing is denied', () => {
it('keeps both variable names and the template readonly when editing is denied', async () => {
const user = userEvent.setup()
setup(true)
await user.type(screen.getByRole('textbox', { name: 'Template editor' }), 'changed')
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveValue(payload.template)
expect(
screen.queryByRole('button', {
name: 'common.operation.add workflow.nodes.templateTransform.inputVars',
}),
).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'template-editor' })).toHaveAttribute('readonly')
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveAttribute('readonly')
expect(
screen.getByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' }),
).toBeDisabled()