diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx new file mode 100644 index 0000000000..f935a203fe --- /dev/null +++ b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx @@ -0,0 +1,878 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AssistantTypePicker from './index' +import type { AgentConfig } from '@/models/debug' +import { AgentStrategy } from '@/types/app' + +// Type definition for AgentSetting props +type AgentSettingProps = { + isChatModel: boolean + payload: AgentConfig + isFunctionCall: boolean + onCancel: () => void + onSave: (payload: AgentConfig) => void +} + +// Track mock calls for props validation +let mockAgentSettingProps: AgentSettingProps | null = null + +// Mock AgentSetting component (complex modal with external hooks) +jest.mock('../agent/agent-setting', () => { + return function MockAgentSetting(props: AgentSettingProps) { + mockAgentSettingProps = props + return ( +
+ + +
+ ) + } +}) + +// Test utilities +const defaultAgentConfig: AgentConfig = { + enabled: true, + max_iteration: 3, + strategy: AgentStrategy.functionCall, + tools: [], +} + +const defaultProps = { + value: 'chat', + disabled: false, + onChange: jest.fn(), + isFunctionCall: true, + isChatModel: true, + agentConfig: defaultAgentConfig, + onAgentSettingChange: jest.fn(), +} + +const renderComponent = (props: Partial> = {}) => { + const mergedProps = { ...defaultProps, ...props } + return render() +} + +// Helper to get option element by description (which is unique per option) +const getOptionByDescription = (descriptionRegex: RegExp) => { + const description = screen.getByText(descriptionRegex) + return description.parentElement as HTMLElement +} + +describe('AssistantTypePicker', () => { + beforeEach(() => { + jest.clearAllMocks() + mockAgentSettingProps = null + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument() + }) + + it('should render chat assistant by default when value is "chat"', () => { + // Arrange & Act + renderComponent({ value: 'chat' }) + + // Assert + expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument() + }) + + it('should render agent assistant when value is "agent"', () => { + // Arrange & Act + renderComponent({ value: 'agent' }) + + // Assert + expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should use provided value prop', () => { + // Arrange & Act + renderComponent({ value: 'agent' }) + + // Assert + expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument() + }) + + it('should handle agentConfig prop', () => { + // Arrange + const customAgentConfig: AgentConfig = { + enabled: true, + max_iteration: 10, + strategy: AgentStrategy.react, + tools: [], + } + + // Act + expect(() => { + renderComponent({ agentConfig: customAgentConfig }) + }).not.toThrow() + + // Assert + expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument() + }) + + it('should handle undefined agentConfig prop', () => { + // Arrange & Act + expect(() => { + renderComponent({ agentConfig: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should open dropdown when clicking trigger', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + + // Assert - Both options should be visible + await waitFor(() => { + const chatOptions = screen.getAllByText(/chatAssistant.name/i) + const agentOptions = screen.getAllByText(/agentAssistant.name/i) + expect(chatOptions.length).toBeGreaterThan(1) + expect(agentOptions.length).toBeGreaterThan(0) + }) + }) + + it('should call onChange when selecting chat assistant', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + renderComponent({ value: 'agent', onChange }) + + // Act - Open dropdown + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) + + // Wait for dropdown to open and find chat option + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + }) + + // Find and click the chat option by its unique description + const chatOption = getOptionByDescription(/chatAssistant.description/i) + await user.click(chatOption) + + // Assert + expect(onChange).toHaveBeenCalledWith('chat') + }) + + it('should call onChange when selecting agent assistant', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + renderComponent({ value: 'chat', onChange }) + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + // Wait for dropdown to open and click agent option + await waitFor(() => { + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + + const agentOption = getOptionByDescription(/agentAssistant.description/i) + await user.click(agentOption) + + // Assert + expect(onChange).toHaveBeenCalledWith('agent') + }) + + it('should close dropdown when selecting chat assistant', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent' }) + + // Act - Open dropdown + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) + + // Wait for dropdown and select chat + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + }) + + const chatOption = getOptionByDescription(/chatAssistant.description/i) + await user.click(chatOption) + + // Assert - Dropdown should close (descriptions should not be visible) + await waitFor(() => { + expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument() + }) + }) + + it('should not close dropdown when selecting agent assistant', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'chat' }) + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + + // Wait for dropdown and select agent + await waitFor(() => { + const agentOptions = screen.getAllByText(/agentAssistant.name/i) + expect(agentOptions.length).toBeGreaterThan(0) + }) + + const agentOptions = screen.getAllByText(/agentAssistant.name/i) + await user.click(agentOptions[0].closest('div')!) + + // Assert - Dropdown should remain open (agent settings should be visible) + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + }) + + it('should not call onChange when clicking same value', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + renderComponent({ value: 'chat', onChange }) + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + + // Wait for dropdown and click same option + await waitFor(() => { + const chatOptions = screen.getAllByText(/chatAssistant.name/i) + expect(chatOptions.length).toBeGreaterThan(1) + }) + + const chatOptions = screen.getAllByText(/chatAssistant.name/i) + await user.click(chatOptions[1].closest('div')!) + + // Assert + expect(onChange).not.toHaveBeenCalled() + }) + }) + + // Disabled state + describe('Disabled State', () => { + it('should not respond to clicks when disabled', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + renderComponent({ disabled: true, onChange }) + + // Act - Open dropdown (dropdown can still open when disabled) + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + + // Act - Try to click an option + const agentOption = getOptionByDescription(/agentAssistant.description/i) + await user.click(agentOption) + + // Assert - onChange should not be called (options are disabled) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should not show agent config UI when disabled', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent', disabled: true }) + + // Act - Open dropdown + const trigger = screen.getByText(/agentAssistant.name/i).closest('div') + await user.click(trigger!) + + // Assert - Agent settings option should not be visible + await waitFor(() => { + expect(screen.queryByText(/agent.setting.name/i)).not.toBeInTheDocument() + }) + }) + + it('should show agent config UI when not disabled', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent', disabled: false }) + + // Act - Open dropdown + const trigger = screen.getByText(/agentAssistant.name/i).closest('div') + await user.click(trigger!) + + // Assert - Agent settings option should be visible + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + }) + }) + + // Agent Settings Modal + describe('Agent Settings Modal', () => { + it('should open agent settings modal when clicking agent config UI', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent', disabled: false }) + + // Act - Open dropdown + const trigger = screen.getByText(/agentAssistant.name/i).closest('div') + await user.click(trigger!) + + // Click agent settings + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') + await user.click(agentSettingsTrigger!) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + }) + }) + + it('should not open agent settings when value is not agent', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'chat', disabled: false }) + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + }) + + // Assert - Agent settings modal should not appear (value is 'chat') + expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + }) + + it('should call onAgentSettingChange when saving agent settings', async () => { + // Arrange + const user = userEvent.setup() + const onAgentSettingChange = jest.fn() + renderComponent({ value: 'agent', disabled: false, onAgentSettingChange }) + + // Act - Open dropdown and agent settings + const trigger = screen.getByText(/agentAssistant.name/i).closest('div') + await user.click(trigger!) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') + await user.click(agentSettingsTrigger!) + + // Wait for modal and click save + await waitFor(() => { + expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + }) + + const saveButton = screen.getByText('Save') + await user.click(saveButton) + + // Assert + expect(onAgentSettingChange).toHaveBeenCalledWith({ max_iteration: 5 }) + }) + + it('should close modal when saving agent settings', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent', disabled: false }) + + // Act - Open dropdown, agent settings, and save + const trigger = screen.getByText(/agentAssistant.name/i).closest('div') + await user.click(trigger!) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') + await user.click(agentSettingsTrigger!) + + await waitFor(() => { + expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + }) + + const saveButton = screen.getByText('Save') + await user.click(saveButton) + + // Assert + await waitFor(() => { + expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + }) + }) + + it('should close modal when canceling agent settings', async () => { + // Arrange + const user = userEvent.setup() + const onAgentSettingChange = jest.fn() + renderComponent({ value: 'agent', disabled: false, onAgentSettingChange }) + + // Act - Open dropdown, agent settings, and cancel + const trigger = screen.getByText(/agentAssistant.name/i).closest('div') + await user.click(trigger!) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') + await user.click(agentSettingsTrigger!) + + await waitFor(() => { + expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + }) + + const cancelButton = screen.getByText('Cancel') + await user.click(cancelButton) + + // Assert + await waitFor(() => { + expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + }) + expect(onAgentSettingChange).not.toHaveBeenCalled() + }) + + it('should close dropdown when opening agent settings', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent', disabled: false }) + + // Act - Open dropdown and agent settings + const trigger = screen.getByText(/agentAssistant.name/i).closest('div') + await user.click(trigger!) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') + await user.click(agentSettingsTrigger!) + + // Assert - Modal should be open and dropdown should close + await waitFor(() => { + expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + }) + + // The dropdown should be closed (agent settings description should not be visible) + await waitFor(() => { + const descriptions = screen.queryAllByText(/agent.setting.description/i) + expect(descriptions.length).toBe(0) + }) + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle rapid toggle clicks', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + await user.click(trigger!) + await user.click(trigger!) + + // Assert - Should not crash + expect(trigger).toBeInTheDocument() + }) + + it('should handle multiple rapid selection changes', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + renderComponent({ value: 'chat', onChange }) + + // Act - Open and select agent + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + + // Click agent option - this stays open because value is 'agent' + const agentOption = getOptionByDescription(/agentAssistant.description/i) + await user.click(agentOption) + + // Assert - onChange should have been called once to switch to agent + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(1) + }) + expect(onChange).toHaveBeenCalledWith('agent') + }) + + it('should handle missing callback functions gracefully', async () => { + // Arrange + const user = userEvent.setup() + + // Act & Assert - Should not crash + expect(() => { + renderComponent({ + onChange: undefined!, + onAgentSettingChange: undefined!, + }) + }).not.toThrow() + + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + }) + + it('should handle empty agentConfig', async () => { + // Arrange & Act + expect(() => { + renderComponent({ agentConfig: {} as AgentConfig }) + }).not.toThrow() + + // Assert + expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument() + }) + + describe('should render with different prop combinations', () => { + const combinations = [ + { value: 'chat' as const, disabled: true, isFunctionCall: true, isChatModel: true }, + { value: 'agent' as const, disabled: false, isFunctionCall: false, isChatModel: false }, + { value: 'agent' as const, disabled: true, isFunctionCall: true, isChatModel: false }, + { value: 'chat' as const, disabled: false, isFunctionCall: false, isChatModel: true }, + ] + + it.each(combinations)( + 'value=$value, disabled=$disabled, isFunctionCall=$isFunctionCall, isChatModel=$isChatModel', + (combo) => { + // Arrange & Act + renderComponent(combo) + + // Assert + const expectedText = combo.value === 'agent' ? 'agentAssistant.name' : 'chatAssistant.name' + expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument() + }, + ) + }) + }) + + // Accessibility + describe('Accessibility', () => { + it('should render interactive dropdown items', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + // Assert - Both options should be visible and clickable + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + + // Verify we can interact with option elements using helper function + const chatOption = getOptionByDescription(/chatAssistant.description/i) + const agentOption = getOptionByDescription(/agentAssistant.description/i) + expect(chatOption).toBeInTheDocument() + expect(agentOption).toBeInTheDocument() + }) + }) + + // SelectItem Component + describe('SelectItem Component', () => { + it('should show checked state for selected option', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'chat' }) + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + // Assert - Both options should be visible with radio components + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + + // The SelectItem components render with different visual states + // based on isChecked prop - we verify both options are rendered + const chatOption = getOptionByDescription(/chatAssistant.description/i) + const agentOption = getOptionByDescription(/agentAssistant.description/i) + expect(chatOption).toBeInTheDocument() + expect(agentOption).toBeInTheDocument() + }) + + it('should render description text', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i).closest('div') + await user.click(trigger!) + + // Assert - Descriptions should be visible + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + }) + + it('should show Radio component for each option', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + // Assert - Radio components should be present (both options visible) + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + }) + }) + + // Props Validation for AgentSetting + describe('AgentSetting Props', () => { + it('should pass isFunctionCall and isChatModel props to AgentSetting', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ + value: 'agent', + isFunctionCall: true, + isChatModel: false, + }) + + // Act - Open dropdown and trigger AgentSetting + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) + + // Assert - Verify AgentSetting receives correct props + await waitFor(() => { + expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + }) + + expect(mockAgentSettingProps).not.toBeNull() + expect(mockAgentSettingProps!.isFunctionCall).toBe(true) + expect(mockAgentSettingProps!.isChatModel).toBe(false) + }) + + it('should pass agentConfig payload to AgentSetting', async () => { + // Arrange + const user = userEvent.setup() + const customConfig: AgentConfig = { + enabled: true, + max_iteration: 10, + strategy: AgentStrategy.react, + tools: [], + } + + renderComponent({ + value: 'agent', + agentConfig: customConfig, + }) + + // Act - Open AgentSetting + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) + + // Assert - Verify payload was passed + await waitFor(() => { + expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + }) + + expect(mockAgentSettingProps).not.toBeNull() + expect(mockAgentSettingProps!.payload).toEqual(customConfig) + }) + }) + + // Keyboard Navigation + describe('Keyboard Navigation', () => { + it('should support closing dropdown with Escape key', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + }) + + // Press Escape + await user.keyboard('{Escape}') + + // Assert - Dropdown should close + await waitFor(() => { + expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument() + }) + }) + + it('should allow keyboard focus on trigger element', () => { + // Arrange + renderComponent() + + // Act - Get trigger and verify it can receive focus + const trigger = screen.getByText(/chatAssistant.name/i) + + // Assert - Element should be focusable + expect(trigger).toBeInTheDocument() + expect(trigger.parentElement).toBeInTheDocument() + }) + + it('should allow keyboard focus on dropdown options', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + }) + + // Get options + const chatOption = getOptionByDescription(/chatAssistant.description/i) + const agentOption = getOptionByDescription(/agentAssistant.description/i) + + // Assert - Options should be focusable + expect(chatOption).toBeInTheDocument() + expect(agentOption).toBeInTheDocument() + + // Verify options can receive focus + act(() => { + chatOption.focus() + }) + expect(document.activeElement).toBe(chatOption) + }) + + it('should maintain keyboard accessibility for all interactive elements', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent' }) + + // Act - Open dropdown + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) + + // Assert - Agent settings button should be focusable + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettings = screen.getByText(/agent.setting.name/i) + expect(agentSettings).toBeInTheDocument() + }) + }) + + // ARIA Attributes + describe('ARIA Attributes', () => { + it('should have proper ARIA state for dropdown', async () => { + // Arrange + const user = userEvent.setup() + const { container } = renderComponent() + + // Act - Check initial state + const portalContainer = container.querySelector('[data-state]') + expect(portalContainer).toHaveAttribute('data-state', 'closed') + + // Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + // Assert - State should change to open + await waitFor(() => { + const openPortal = container.querySelector('[data-state="open"]') + expect(openPortal).toBeInTheDocument() + }) + }) + + it('should have proper data-state attribute', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert - Portal should have data-state for accessibility + const portalContainer = container.querySelector('[data-state]') + expect(portalContainer).toBeInTheDocument() + expect(portalContainer).toHaveAttribute('data-state') + + // Should start in closed state + expect(portalContainer).toHaveAttribute('data-state', 'closed') + }) + + it('should maintain accessible structure for screen readers', () => { + // Arrange & Act + renderComponent({ value: 'chat' }) + + // Assert - Text content should be accessible + expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument() + + // Icons should have proper structure + const { container } = renderComponent() + const icons = container.querySelectorAll('svg') + expect(icons.length).toBeGreaterThan(0) + }) + + it('should provide context through text labels', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open dropdown + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + + // Assert - All options should have descriptive text + await waitFor(() => { + expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument() + expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument() + }) + + // Title text should be visible + expect(screen.getByText(/assistantType.name/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx new file mode 100644 index 0000000000..f76145f901 --- /dev/null +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx @@ -0,0 +1,1020 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { createRef } from 'react' +import DebugWithSingleModel from './index' +import type { DebugWithSingleModelRefType } from './index' +import type { ChatItem } from '@/app/components/base/chat/types' +import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ProviderContextState } from '@/context/provider-context' +import type { DatasetConfigs, ModelConfig } from '@/models/debug' +import { PromptMode } from '@/models/debug' +import { type Collection, CollectionType } from '@/app/components/tools/types' +import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app' + +// ============================================================================ +// Test Data Factories (Following testing.md guidelines) +// ============================================================================ + +/** + * Factory function for creating mock ModelConfig with type safety + */ +function createMockModelConfig(overrides: Partial = {}): ModelConfig { + return { + provider: 'openai', + model_id: 'gpt-3.5-turbo', + mode: ModelModeType.chat, + configs: { + prompt_template: 'Test template', + prompt_variables: [ + { key: 'var1', name: 'Variable 1', type: 'text', required: false }, + ], + }, + chat_prompt_config: { + prompt: [], + }, + completion_prompt_config: { + prompt: { text: '' }, + conversation_histories_role: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + }, + more_like_this: null, + opening_statement: '', + suggested_questions: [], + sensitive_word_avoidance: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + suggested_questions_after_answer: null, + retriever_resource: null, + annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 0, + file_size_limit: 0, + image_file_size_limit: 0, + video_file_size_limit: 0, + workflow_file_upload_limit: 0, + }, + dataSets: [], + agentConfig: { + enabled: false, + max_iteration: 5, + tools: [], + strategy: AgentStrategy.react, + }, + ...overrides, + } +} + +/** + * Factory function for creating mock ChatItem list + * Note: Currently unused but kept for potential future test cases + */ +// eslint-disable-next-line unused-imports/no-unused-vars +function createMockChatList(items: Partial[] = []): ChatItem[] { + return items.map((item, index) => ({ + id: `msg-${index}`, + content: 'Test message', + isAnswer: false, + message_files: [], + ...item, + })) +} + +/** + * Factory function for creating mock Collection list + */ +function createMockCollections(collections: Partial[] = []): Collection[] { + return collections.map((collection, index) => ({ + id: `collection-${index}`, + name: `Collection ${index}`, + icon: 'icon-url', + type: 'tool', + ...collection, + } as Collection)) +} + +/** + * Factory function for creating mock Provider Context + */ +function createMockProviderContext(overrides: Partial = {}): ProviderContextState { + return { + textGenerationModelList: [ + { + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + icon_large: { en_US: 'icon', zh_Hans: 'icon' }, + status: ModelStatusEnum.active, + models: [ + { + model: 'gpt-3.5-turbo', + label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, + model_type: ModelTypeEnum.textGeneration, + features: [ModelFeatureEnum.vision], + fetch_from: ConfigurationMethodEnum.predefinedModel, + model_properties: {}, + deprecated: false, + }, + ], + }, + ], + hasSettedApiKey: true, + modelProviders: [], + speech2textDefaultModel: null, + ttsDefaultModel: null, + agentThoughtDefaultModel: null, + updateModelList: jest.fn(), + onPlanInfoChanged: jest.fn(), + refreshModelProviders: jest.fn(), + refreshLicenseLimit: jest.fn(), + ...overrides, + } as ProviderContextState +} + +// ============================================================================ +// Mock External Dependencies ONLY (Following testing.md guidelines) +// ============================================================================ + +// Mock service layer (API calls) +jest.mock('@/service/base', () => ({ + ssePost: jest.fn(() => Promise.resolve()), + post: jest.fn(() => Promise.resolve({ data: {} })), + get: jest.fn(() => Promise.resolve({ data: {} })), + del: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), +})) + +jest.mock('@/service/fetch', () => ({ + fetch: jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), +})) + +const mockFetchConversationMessages = jest.fn() +const mockFetchSuggestedQuestions = jest.fn() +const mockStopChatMessageResponding = jest.fn() + +jest.mock('@/service/debug', () => ({ + fetchConversationMessages: (...args: any[]) => mockFetchConversationMessages(...args), + fetchSuggestedQuestions: (...args: any[]) => mockFetchSuggestedQuestions(...args), + stopChatMessageResponding: (...args: any[]) => mockStopChatMessageResponding(...args), +})) + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + usePathname: () => '/test', + useParams: () => ({}), +})) + +// Mock complex context providers +const mockDebugConfigContext = { + appId: 'test-app-id', + isAPIKeySet: true, + isTrailFinished: false, + mode: AppModeEnum.CHAT, + modelModeType: ModelModeType.chat, + promptMode: PromptMode.simple, + setPromptMode: jest.fn(), + isAdvancedMode: false, + isAgent: false, + isFunctionCall: false, + isOpenAI: true, + collectionList: createMockCollections([ + { id: 'test-provider', name: 'Test Tool', icon: 'icon-url' }, + ]), + canReturnToSimpleMode: false, + setCanReturnToSimpleMode: jest.fn(), + chatPromptConfig: {}, + completionPromptConfig: {}, + currentAdvancedPrompt: [], + showHistoryModal: jest.fn(), + conversationHistoriesRole: { user_prefix: 'user', assistant_prefix: 'assistant' }, + setConversationHistoriesRole: jest.fn(), + setCurrentAdvancedPrompt: jest.fn(), + hasSetBlockStatus: { context: false, history: false, query: false }, + conversationId: null, + setConversationId: jest.fn(), + introduction: '', + setIntroduction: jest.fn(), + suggestedQuestions: [], + setSuggestedQuestions: jest.fn(), + controlClearChatMessage: 0, + setControlClearChatMessage: jest.fn(), + prevPromptConfig: { prompt_template: '', prompt_variables: [] }, + setPrevPromptConfig: jest.fn(), + moreLikeThisConfig: { enabled: false }, + setMoreLikeThisConfig: jest.fn(), + suggestedQuestionsAfterAnswerConfig: { enabled: false }, + setSuggestedQuestionsAfterAnswerConfig: jest.fn(), + speechToTextConfig: { enabled: false }, + setSpeechToTextConfig: jest.fn(), + textToSpeechConfig: { enabled: false, voice: '', language: '' }, + setTextToSpeechConfig: jest.fn(), + citationConfig: { enabled: false }, + setCitationConfig: jest.fn(), + moderationConfig: { enabled: false }, + annotationConfig: { id: '', enabled: false, score_threshold: 0.7, embedding_model: { embedding_model_name: '', embedding_provider_name: '' } }, + setAnnotationConfig: jest.fn(), + setModerationConfig: jest.fn(), + externalDataToolsConfig: [], + setExternalDataToolsConfig: jest.fn(), + formattingChanged: false, + setFormattingChanged: jest.fn(), + inputs: { var1: 'test input' }, + setInputs: jest.fn(), + query: '', + setQuery: jest.fn(), + completionParams: { max_tokens: 100, temperature: 0.7 }, + setCompletionParams: jest.fn(), + modelConfig: createMockModelConfig({ + agentConfig: { + enabled: false, + max_iteration: 5, + tools: [{ + tool_name: 'test-tool', + provider_id: 'test-provider', + provider_type: CollectionType.builtIn, + provider_name: 'test-provider', + tool_label: 'Test Tool', + tool_parameters: {}, + enabled: true, + }], + strategy: AgentStrategy.react, + }, + }), + setModelConfig: jest.fn(), + dataSets: [], + showSelectDataSet: jest.fn(), + setDataSets: jest.fn(), + datasetConfigs: { + retrieval_model: 'single', + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0.7, + datasets: { datasets: [] }, + } as DatasetConfigs, + datasetConfigsRef: { current: null } as any, + setDatasetConfigs: jest.fn(), + hasSetContextVar: false, + isShowVisionConfig: false, + visionConfig: { enabled: false, number_limits: 2, detail: 'low' as any, transfer_methods: [] }, + setVisionConfig: jest.fn(), + isAllowVideoUpload: false, + isShowDocumentConfig: false, + isShowAudioConfig: false, + rerankSettingModalOpen: false, + setRerankSettingModalOpen: jest.fn(), +} + +jest.mock('@/context/debug-configuration', () => ({ + useDebugConfigurationContext: jest.fn(() => mockDebugConfigContext), +})) + +const mockProviderContext = createMockProviderContext() + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: jest.fn(() => mockProviderContext), +})) + +const mockAppContext = { + userProfile: { + id: 'user-1', + avatar_url: 'https://example.com/avatar.png', + name: 'Test User', + email: 'test@example.com', + }, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceDatasetOperator: false, + mutateUserProfile: jest.fn(), +} + +jest.mock('@/context/app-context', () => ({ + useAppContext: jest.fn(() => mockAppContext), +})) + +const mockFeatures = { + moreLikeThis: { enabled: false }, + opening: { enabled: false, opening_statement: '', suggested_questions: [] }, + moderation: { enabled: false }, + speech2text: { enabled: false }, + text2speech: { enabled: false }, + file: { enabled: false }, + suggested: { enabled: false }, + citation: { enabled: false }, + annotationReply: { enabled: false }, +} + +jest.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: jest.fn((selector) => { + if (typeof selector === 'function') + return selector({ features: mockFeatures }) + return mockFeatures + }), +})) + +const mockConfigFromDebugContext = { + pre_prompt: 'Test prompt', + prompt_type: 'simple', + user_input_form: [], + dataset_query_variable: '', + opening_statement: '', + more_like_this: { enabled: false }, + suggested_questions: [], + suggested_questions_after_answer: { enabled: false }, + text_to_speech: { enabled: false }, + speech_to_text: { enabled: false }, + retriever_resource: { enabled: false }, + sensitive_word_avoidance: { enabled: false }, + agent_mode: {}, + dataset_configs: {}, + file_upload: { enabled: false }, + annotation_reply: { enabled: false }, + supportAnnotation: true, + appId: 'test-app-id', + supportCitationHitInfo: true, +} + +jest.mock('../hooks', () => ({ + useConfigFromDebugContext: jest.fn(() => mockConfigFromDebugContext), + useFormattingChangedSubscription: jest.fn(), +})) + +const mockSetShowAppConfigureFeaturesModal = jest.fn() + +jest.mock('@/app/components/app/store', () => ({ + useStore: jest.fn((selector) => { + if (typeof selector === 'function') + return selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal }) + return mockSetShowAppConfigureFeaturesModal + }), +})) + +// Mock event emitter context +jest.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: jest.fn(() => ({ + eventEmitter: null, + })), +})) + +// Mock toast context +jest.mock('@/app/components/base/toast', () => ({ + useToastContext: jest.fn(() => ({ + notify: jest.fn(), + })), +})) + +// Mock hooks/use-timestamp +jest.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: jest.fn(() => ({ + formatTime: jest.fn((timestamp: number) => new Date(timestamp).toLocaleString()), + })), +})) + +// Mock audio player manager +jest.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ + AudioPlayerManager: { + getInstance: jest.fn(() => ({ + getAudioPlayer: jest.fn(), + resetAudioPlayer: jest.fn(), + })), + }, +})) + +// Mock external APIs that might be used +globalThis.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})) + +// Mock Chat component (complex with many dependencies) +// This is a pragmatic mock that tests the integration at DebugWithSingleModel level +jest.mock('@/app/components/base/chat/chat', () => { + return function MockChat({ + chatList, + isResponding, + onSend, + onRegenerate, + onStopResponding, + suggestedQuestions, + questionIcon, + answerIcon, + onAnnotationAdded, + onAnnotationEdited, + onAnnotationRemoved, + switchSibling, + onFeatureBarClick, + }: any) { + return ( +
+
+ {chatList?.map((item: any) => ( +
+ {item.content} +
+ ))} +
+ {questionIcon &&
{questionIcon}
} + {answerIcon &&
{answerIcon}
} +