diff --git a/web/app/components/app/configuration/debug/chat-user-input.spec.tsx b/web/app/components/app/configuration/debug/chat-user-input.spec.tsx
new file mode 100644
index 0000000000..e6678ebf29
--- /dev/null
+++ b/web/app/components/app/configuration/debug/chat-user-input.spec.tsx
@@ -0,0 +1,710 @@
+import type { Inputs, ModelConfig } from '@/models/debug'
+import type { PromptVariable } from '@/types/app'
+import { fireEvent, render, screen } from '@testing-library/react'
+import ChatUserInput from './chat-user-input'
+
+const mockSetInputs = vi.fn()
+const mockUseContext = vi.fn()
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('use-context-selector', () => ({
+ useContext: () => mockUseContext(),
+ createContext: vi.fn(() => ({})),
+}))
+
+vi.mock('@/app/components/base/input', () => ({
+ default: ({ value, onChange, placeholder, autoFocus, maxLength, readOnly, type }: {
+ value: string
+ onChange: (e: { target: { value: string } }) => void
+ placeholder?: string
+ autoFocus?: boolean
+ maxLength?: number
+ readOnly?: boolean
+ type?: string
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/select', () => ({
+ default: ({ defaultValue, onSelect, items, disabled, className }: {
+ defaultValue: string
+ onSelect: (item: { value: string }) => void
+ items: { name: string, value: string }[]
+ allowSearch?: boolean
+ disabled?: boolean
+ className?: string
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/textarea', () => ({
+ default: ({ value, onChange, placeholder, readOnly, className }: {
+ value: string
+ onChange: (e: { target: { value: string } }) => void
+ placeholder?: string
+ readOnly?: boolean
+ className?: string
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
+ default: ({ name, value, required, onChange, readonly }: {
+ name: string
+ value: boolean
+ required?: boolean
+ onChange: (value: boolean) => void
+ readonly?: boolean
+ }) => (
+
+ onChange(e.target.checked)}
+ disabled={readonly}
+ data-required={required}
+ />
+ {name}
+
+ ),
+}))
+
+// Extended type to match runtime behavior (includes 'paragraph', 'checkbox', 'default')
+type ExtendedPromptVariable = {
+ key: string
+ name: string
+ type: 'string' | 'number' | 'select' | 'paragraph' | 'checkbox'
+ required: boolean
+ options?: string[]
+ max_length?: number
+ default?: string | null
+}
+
+const createPromptVariable = (overrides: Partial = {}): ExtendedPromptVariable => ({
+ key: 'test-key',
+ name: 'Test Name',
+ type: 'string',
+ required: false,
+ ...overrides,
+})
+
+const createModelConfig = (promptVariables: ExtendedPromptVariable[] = []): ModelConfig => ({
+ provider: 'openai',
+ model_id: 'gpt-4',
+ mode: 'chat',
+ configs: {
+ prompt_template: '',
+ prompt_variables: promptVariables as PromptVariable[],
+ },
+} as ModelConfig)
+
+const createContextValue = (overrides: Partial<{
+ modelConfig: ModelConfig
+ setInputs: (inputs: Inputs) => void
+ readonly: boolean
+}> = {}) => ({
+ modelConfig: createModelConfig(),
+ setInputs: mockSetInputs,
+ readonly: false,
+ ...overrides,
+})
+
+describe('ChatUserInput', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseContext.mockReturnValue(createContextValue())
+ })
+
+ describe('Rendering', () => {
+ it('should return null when no prompt variables exist', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([]),
+ }))
+
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should return null when prompt variables have empty keys', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: '', name: 'Test' }),
+ createPromptVariable({ key: ' ', name: 'Test2' }),
+ ]),
+ }))
+
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should return null when prompt variables have empty names', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'key1', name: '' }),
+ createPromptVariable({ key: 'key2', name: ' ' }),
+ ]),
+ }))
+
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render string input type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Name')).toBeInTheDocument()
+ })
+
+ it('should render paragraph input type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'description', name: 'Description', type: 'paragraph' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
+ })
+
+ it('should render select input type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('select-input')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ expect(screen.getByText('B')).toBeInTheDocument()
+ expect(screen.getByText('C')).toBeInTheDocument()
+ })
+
+ it('should render number input type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'count', name: 'Count', type: 'number' }),
+ ]),
+ }))
+
+ render()
+ const input = screen.getByTestId('input-Count')
+ expect(input).toBeInTheDocument()
+ expect(input).toHaveAttribute('type', 'number')
+ })
+
+ it('should render checkbox input type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('bool-input-Enabled')).toBeInTheDocument()
+ })
+
+ it('should render multiple input types', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }),
+ createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['X', 'Y'] }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Name')).toBeInTheDocument()
+ expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
+ expect(screen.getByTestId('select-input')).toBeInTheDocument()
+ })
+
+ it('should show optional label for non-required fields', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: false }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByText('panel.optional')).toBeInTheDocument()
+ })
+
+ it('should not show optional label for required fields', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: true }),
+ ]),
+ }))
+
+ render()
+ expect(screen.queryByText('panel.optional')).not.toBeInTheDocument()
+ })
+
+ it('should use key as label when name is not provided', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'myKey', name: '', type: 'string' }),
+ ]),
+ }))
+
+ // This should actually return null because name is empty
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('Input Values', () => {
+ it('should display existing input values for string type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Name')).toHaveValue('John')
+ })
+
+ it('should display existing input values for paragraph type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('textarea-Description')).toHaveValue('Long text here')
+ })
+
+ it('should display existing input values for number type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'count', name: 'Count', type: 'number' }),
+ ]),
+ }))
+
+ render()
+ // Number type input still uses string value internally
+ expect(screen.getByTestId('input-Count')).toHaveValue(42)
+ })
+
+ it('should display checkbox as checked when value is truthy', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }),
+ ]),
+ }))
+
+ render()
+ const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input')
+ expect(checkbox).toBeChecked()
+ })
+
+ it('should display checkbox as unchecked when value is falsy', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }),
+ ]),
+ }))
+
+ render()
+ const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input')
+ expect(checkbox).not.toBeChecked()
+ })
+
+ it('should handle empty string values', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Name')).toHaveValue('')
+ })
+
+ it('should handle undefined values', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Name')).toHaveValue('')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call setInputs when string input changes', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'New Value' } })
+
+ expect(mockSetInputs).toHaveBeenCalledWith({ name: 'New Value' })
+ })
+
+ it('should call setInputs when paragraph input changes', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }),
+ ]),
+ }))
+
+ render()
+ fireEvent.change(screen.getByTestId('textarea-Description'), { target: { value: 'New Description' } })
+
+ expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' })
+ })
+
+ it('should call setInputs when select input changes', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }),
+ ]),
+ }))
+
+ render()
+ fireEvent.change(screen.getByTestId('select-input'), { target: { value: 'B' } })
+
+ expect(mockSetInputs).toHaveBeenCalledWith({ choice: 'B' })
+ })
+
+ it('should call setInputs when number input changes', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'count', name: 'Count', type: 'number' }),
+ ]),
+ }))
+
+ render()
+ fireEvent.change(screen.getByTestId('input-Count'), { target: { value: '100' } })
+
+ expect(mockSetInputs).toHaveBeenCalledWith({ count: '100' })
+ })
+
+ it('should call setInputs when checkbox changes', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }),
+ ]),
+ }))
+
+ render()
+ const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input')!
+ fireEvent.click(checkbox)
+
+ expect(mockSetInputs).toHaveBeenCalledWith({ enabled: true })
+ })
+
+ it('should not call setInputs for unknown keys', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+
+ // The component filters by promptVariableObj, so unknown keys won't trigger updates
+ // This is tested indirectly - only valid keys should trigger setInputs
+ fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Valid' } })
+
+ expect(mockSetInputs).toHaveBeenCalledTimes(1)
+ expect(mockSetInputs).toHaveBeenCalledWith({ name: 'Valid' })
+ })
+ })
+
+ describe('Readonly Mode', () => {
+ it('should set string input as readonly when readonly is true', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ readonly: true,
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Name')).toHaveAttribute('readonly')
+ })
+
+ it('should set paragraph input as readonly when readonly is true', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }),
+ ]),
+ readonly: true,
+ }))
+
+ render()
+ expect(screen.getByTestId('textarea-Description')).toHaveAttribute('readonly')
+ })
+
+ it('should disable select when readonly is true', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B'] }),
+ ]),
+ readonly: true,
+ }))
+
+ render()
+ expect(screen.getByTestId('select-input')).toBeDisabled()
+ })
+
+ it('should disable checkbox when readonly is true', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }),
+ ]),
+ readonly: true,
+ }))
+
+ render()
+ const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input')
+ expect(checkbox).toBeDisabled()
+ })
+ })
+
+ describe('Default Values', () => {
+ it('should initialize inputs with default values when field is empty', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default Name' }),
+ ]),
+ }))
+
+ render()
+
+ expect(mockSetInputs).toHaveBeenCalledWith({ name: 'Default Name' })
+ })
+
+ it('should not override existing values with defaults', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default' }),
+ ]),
+ }))
+
+ render()
+
+ // setInputs should not be called since there's already a value
+ expect(mockSetInputs).not.toHaveBeenCalled()
+ })
+
+ it('should handle multiple default values', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default Name' }),
+ createPromptVariable({ key: 'count', name: 'Count', type: 'number', default: '10' }),
+ ]),
+ }))
+
+ render()
+
+ expect(mockSetInputs).toHaveBeenCalledWith({
+ name: 'Default Name',
+ count: '10',
+ })
+ })
+
+ it('should not set default when default is empty string', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: '' }),
+ ]),
+ }))
+
+ render()
+
+ expect(mockSetInputs).not.toHaveBeenCalled()
+ })
+
+ it('should not set default when default is undefined', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+
+ expect(mockSetInputs).not.toHaveBeenCalled()
+ })
+
+ it('should not set default when default is null', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: null as unknown as string }),
+ ]),
+ }))
+
+ render()
+
+ expect(mockSetInputs).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('AutoFocus', () => {
+ it('should set autoFocus on first string input', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'first', name: 'First', type: 'string' }),
+ createPromptVariable({ key: 'second', name: 'Second', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-First')).toHaveAttribute('data-autofocus', 'true')
+ expect(screen.getByTestId('input-Second')).not.toHaveAttribute('data-autofocus')
+ })
+
+ it('should set autoFocus on first number input when it is the first field', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'count', name: 'Count', type: 'number' }),
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Count')).toHaveAttribute('data-autofocus', 'true')
+ })
+ })
+
+ describe('MaxLength', () => {
+ it('should pass maxLength to string input', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string', max_length: 50 }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Name')).toHaveAttribute('maxLength', '50')
+ })
+
+ it('should pass maxLength to number input', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'count', name: 'Count', type: 'number', max_length: 10 }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Count')).toHaveAttribute('maxLength', '10')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle select with empty options', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: [] }),
+ ]),
+ }))
+
+ render()
+ const select = screen.getByTestId('select-input')
+ expect(select).toBeInTheDocument()
+ expect(select.children).toHaveLength(0)
+ })
+
+ it('should handle select with undefined options', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'choice', name: 'Choice', type: 'select' }),
+ ]),
+ }))
+
+ render()
+ const select = screen.getByTestId('select-input')
+ expect(select).toBeInTheDocument()
+ })
+
+ it('should preserve other input values when updating one field', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
+ createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }),
+ ]),
+ }))
+
+ render()
+ fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Updated' } })
+
+ expect(mockSetInputs).toHaveBeenCalledWith({
+ name: 'Updated',
+ desc: 'Also Existing',
+ })
+ })
+
+ it('should convert non-string values to string for display', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'value', name: 'Value', type: 'string' }),
+ ]),
+ }))
+
+ render()
+ expect(screen.getByTestId('input-Value')).toHaveValue('123')
+ })
+
+ it('should not hide label for checkbox type', () => {
+ mockUseContext.mockReturnValue(createContextValue({
+ modelConfig: createModelConfig([
+ createPromptVariable({ key: 'enabled', name: 'Is Enabled', type: 'checkbox' }),
+ ]),
+ }))
+
+ render()
+ // For checkbox, the label is rendered inside BoolInput, not in the header
+ expect(screen.queryByText('Is Enabled')).toBeInTheDocument()
+ })
+ })
+})