From c5ee60f1a1f7dac05feee5c46845fb26bd7d7b31 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 3 Nov 2026 02:05:28 +0000 Subject: [PATCH] test(web): replace editor and upload doubles with behavioral tests (#45077) --- .../debug/__tests__/hooks.spec.tsx | 89 ++++++----- .../__tests__/human-input-form.spec.tsx | 140 ++++++++++++------ .../__tests__/json-importer.spec.tsx | 47 ++---- .../__tests__/panel.spec.tsx | 59 ++++---- 4 files changed, 180 insertions(+), 155 deletions(-) diff --git a/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx b/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx index 24a853eb3c8..b489e5733c0 100644 --- a/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx @@ -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) => ( + + {children} + + ), + }) 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 + } + function Session() { + const [formattingChanged, setFormattingChanged] = useState(initiallyChanged) + return ( + + + {formattingChanged ? 'Refresh required' : 'Up to date'} + + ) + } + const user = userEvent.setup() + render( + + + , ) - 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) }, ) }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx index 6d4e36e3665..02fb3892d2f 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx @@ -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>(), +})) + +// The build-time release flag has no runtime control, so vary that configuration boundary directly. vi.mock('@/config', async (importOriginal) => ({ ...(await importOriginal()), get ENABLE_HITL_IM() { @@ -20,38 +24,28 @@ vi.mock('@/config', async (importOriginal) => ({ }, })) -vi.mock('@/app/components/base/file-uploader', () => ({ - FileUploaderInAttachmentWrapper: ({ - onChange, - fileConfig, - }: ComponentProps) => { - 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 ( - <> - - - - ) - }, +vi.mock('@/next/navigation', () => ({ useParams: () => ({}), usePathname: () => '/' })) +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + 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() + const { container } = render( + , + ) 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('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>() + const listUpload = createDeferred>() + const replacementUpload = createDeferred>() + 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( , ) 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('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('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('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() diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx index bf505a6b88c..2dfea76183b 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx @@ -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 }) => ( -