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}
}
+
+ )
+ }
+})
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('DebugWithSingleModel', () => {
+ let ref: React.RefObject
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ref = createRef()
+
+ // Reset mock implementations
+ mockFetchConversationMessages.mockResolvedValue({ data: [] })
+ mockFetchSuggestedQuestions.mockResolvedValue({ data: [] })
+ mockStopChatMessageResponding.mockResolvedValue({})
+ })
+
+ // Rendering Tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(} />)
+
+ // Verify Chat component is rendered
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ expect(screen.getByTestId('chat-input')).toBeInTheDocument()
+ expect(screen.getByTestId('send-button')).toBeInTheDocument()
+ })
+
+ it('should render with custom checkCanSend prop', () => {
+ const checkCanSend = jest.fn(() => true)
+
+ render(} checkCanSend={checkCanSend} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Props Tests
+ describe('Props', () => {
+ it('should respect checkCanSend returning true', async () => {
+ const checkCanSend = jest.fn(() => true)
+
+ render(} checkCanSend={checkCanSend} />)
+
+ const sendButton = screen.getByTestId('send-button')
+ fireEvent.click(sendButton)
+
+ await waitFor(() => {
+ expect(checkCanSend).toHaveBeenCalled()
+ })
+ })
+
+ it('should prevent send when checkCanSend returns false', async () => {
+ const checkCanSend = jest.fn(() => false)
+
+ render(} checkCanSend={checkCanSend} />)
+
+ const sendButton = screen.getByTestId('send-button')
+ fireEvent.click(sendButton)
+
+ await waitFor(() => {
+ expect(checkCanSend).toHaveBeenCalled()
+ expect(checkCanSend).toHaveReturnedWith(false)
+ })
+ })
+ })
+
+ // Context Integration Tests
+ describe('Context Integration', () => {
+ it('should use debug configuration context', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ render(} />)
+
+ expect(useDebugConfigurationContext).toHaveBeenCalled()
+ })
+
+ it('should use provider context for model list', () => {
+ const { useProviderContext } = require('@/context/provider-context')
+
+ render(} />)
+
+ expect(useProviderContext).toHaveBeenCalled()
+ })
+
+ it('should use app context for user profile', () => {
+ const { useAppContext } = require('@/context/app-context')
+
+ render(} />)
+
+ expect(useAppContext).toHaveBeenCalled()
+ })
+
+ it('should use features from features hook', () => {
+ const { useFeatures } = require('@/app/components/base/features/hooks')
+
+ render(} />)
+
+ expect(useFeatures).toHaveBeenCalled()
+ })
+
+ it('should use config from debug context hook', () => {
+ const { useConfigFromDebugContext } = require('../hooks')
+
+ render(} />)
+
+ expect(useConfigFromDebugContext).toHaveBeenCalled()
+ })
+
+ it('should subscribe to formatting changes', () => {
+ const { useFormattingChangedSubscription } = require('../hooks')
+
+ render(} />)
+
+ expect(useFormattingChangedSubscription).toHaveBeenCalled()
+ })
+ })
+
+ // Model Configuration Tests
+ describe('Model Configuration', () => {
+ it('should merge features into config correctly when all features enabled', () => {
+ const { useFeatures } = require('@/app/components/base/features/hooks')
+
+ useFeatures.mockReturnValue((selector: any) => {
+ const features = {
+ moreLikeThis: { enabled: true },
+ opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] },
+ moderation: { enabled: true },
+ speech2text: { enabled: true },
+ text2speech: { enabled: true },
+ file: { enabled: true },
+ suggested: { enabled: true },
+ citation: { enabled: true },
+ annotationReply: { enabled: true },
+ }
+ return typeof selector === 'function' ? selector({ features }) : features
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle opening feature disabled correctly', () => {
+ const { useFeatures } = require('@/app/components/base/features/hooks')
+
+ useFeatures.mockReturnValue((selector: any) => {
+ const features = {
+ ...mockFeatures,
+ opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] },
+ }
+ return typeof selector === 'function' ? selector({ features }) : features
+ })
+
+ render(} />)
+
+ // When opening is disabled, opening_statement should be empty
+ expect(screen.queryByText('Should not appear')).not.toBeInTheDocument()
+ })
+
+ it('should handle model without vision support', () => {
+ const { useProviderContext } = require('@/context/provider-context')
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ 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: [], // No vision support
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ status: ModelStatusEnum.active,
+ load_balancing_enabled: false,
+ },
+ ],
+ },
+ ],
+ }))
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle missing model in provider list', () => {
+ const { useProviderContext } = require('@/context/provider-context')
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ textGenerationModelList: [
+ {
+ provider: 'different-provider',
+ label: { en_US: 'Different Provider', zh_Hans: '不同提供商' },
+ icon_small: { en_US: 'icon', zh_Hans: 'icon' },
+ icon_large: { en_US: 'icon', zh_Hans: 'icon' },
+ status: ModelStatusEnum.active,
+ models: [],
+ },
+ ],
+ }))
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Input Forms Tests
+ describe('Input Forms', () => {
+ it('should filter out api type prompt variables', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ configs: {
+ prompt_template: 'Test',
+ prompt_variables: [
+ { key: 'var1', name: 'Var 1', type: 'text', required: false },
+ { key: 'var2', name: 'Var 2', type: 'api', required: false },
+ { key: 'var3', name: 'Var 3', type: 'select', required: false },
+ ],
+ },
+ }),
+ })
+
+ render(} />)
+
+ // Component should render successfully with filtered variables
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle empty prompt variables', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ configs: {
+ prompt_template: 'Test',
+ prompt_variables: [],
+ },
+ }),
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Tool Icons Tests
+ describe('Tool Icons', () => {
+ it('should map tool icons from collection list', () => {
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle empty tools list', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ agentConfig: {
+ enabled: false,
+ max_iteration: 5,
+ tools: [],
+ strategy: AgentStrategy.react,
+ },
+ }),
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle missing collection for tool', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ agentConfig: {
+ enabled: false,
+ max_iteration: 5,
+ tools: [{
+ tool_name: 'unknown-tool',
+ provider_id: 'unknown-provider',
+ provider_type: CollectionType.builtIn,
+ provider_name: 'unknown-provider',
+ tool_label: 'Unknown Tool',
+ tool_parameters: {},
+ enabled: true,
+ }],
+ strategy: AgentStrategy.react,
+ },
+ }),
+ collectionList: [],
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Edge Cases
+ describe('Edge Cases', () => {
+ it('should handle empty inputs', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ inputs: {},
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle missing user profile', () => {
+ const { useAppContext } = require('@/context/app-context')
+
+ useAppContext.mockReturnValue({
+ ...mockAppContext,
+ userProfile: {
+ id: '',
+ avatar_url: '',
+ name: '',
+ email: '',
+ },
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle null completion params', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ completionParams: {},
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Imperative Handle Tests
+ describe('Imperative Handle', () => {
+ it('should expose handleRestart method via ref', () => {
+ render(} />)
+
+ expect(ref.current).not.toBeNull()
+ expect(ref.current?.handleRestart).toBeDefined()
+ expect(typeof ref.current?.handleRestart).toBe('function')
+ })
+
+ it('should call handleRestart when invoked via ref', () => {
+ render(} />)
+
+ expect(() => {
+ ref.current?.handleRestart()
+ }).not.toThrow()
+ })
+ })
+
+ // Memory and Performance Tests
+ describe('Memory and Performance', () => {
+ it('should properly memoize component', () => {
+ const { rerender } = render(} />)
+
+ // Re-render with same props
+ rerender(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should have displayName set for debugging', () => {
+ expect(DebugWithSingleModel).toBeDefined()
+ // memo wraps the component
+ expect(typeof DebugWithSingleModel).toBe('object')
+ })
+ })
+
+ // Async Operations Tests
+ describe('Async Operations', () => {
+ it('should handle API calls during message send', async () => {
+ mockFetchConversationMessages.mockResolvedValue({ data: [] })
+
+ render(} />)
+
+ const textarea = screen.getByRole('textbox', { hidden: true })
+ fireEvent.change(textarea, { target: { value: 'Test message' } })
+
+ // Component should render without errors during async operations
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle API errors gracefully', async () => {
+ mockFetchConversationMessages.mockRejectedValue(new Error('API Error'))
+
+ render(} />)
+
+ // Component should still render even if API calls fail
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // File Upload Tests
+ describe('File Upload', () => {
+ it('should not include files when vision is not supported', () => {
+ const { useProviderContext } = require('@/context/provider-context')
+ const { useFeatures } = require('@/app/components/base/features/hooks')
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ 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: [], // No vision
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ status: ModelStatusEnum.active,
+ load_balancing_enabled: false,
+ },
+ ],
+ },
+ ],
+ }))
+
+ useFeatures.mockReturnValue((selector: any) => {
+ const features = {
+ ...mockFeatures,
+ file: { enabled: true }, // File upload enabled
+ }
+ return typeof selector === 'function' ? selector({ features }) : features
+ })
+
+ render(} />)
+
+ // Should render but not allow file uploads
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should support files when vision is enabled', () => {
+ const { useProviderContext } = require('@/context/provider-context')
+ const { useFeatures } = require('@/app/components/base/features/hooks')
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ 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-4-vision',
+ label: { en_US: 'GPT-4 Vision', zh_Hans: 'GPT-4 Vision' },
+ model_type: ModelTypeEnum.textGeneration,
+ features: [ModelFeatureEnum.vision],
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ status: ModelStatusEnum.active,
+ load_balancing_enabled: false,
+ },
+ ],
+ },
+ ],
+ }))
+
+ useFeatures.mockReturnValue((selector: any) => {
+ const features = {
+ ...mockFeatures,
+ file: { enabled: true },
+ }
+ return typeof selector === 'function' ? selector({ features }) : features
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/billing/upgrade-btn/index.spec.tsx b/web/app/components/billing/upgrade-btn/index.spec.tsx
new file mode 100644
index 0000000000..f52cc97b01
--- /dev/null
+++ b/web/app/components/billing/upgrade-btn/index.spec.tsx
@@ -0,0 +1,625 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import UpgradeBtn from './index'
+
+// ✅ Import real project components (DO NOT mock these)
+// PremiumBadge, Button, SparklesSoft are all base components
+
+// ✅ Mock i18n with actual translations instead of returning keys
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ 'billing.upgradeBtn.encourage': 'Upgrade to Pro',
+ 'billing.upgradeBtn.encourageShort': 'Upgrade',
+ 'billing.upgradeBtn.plain': 'Upgrade Plan',
+ 'custom.label.key': 'Custom Label',
+ 'custom.key': 'Custom Text',
+ 'custom.short.key': 'Short Custom',
+ 'custom.all': 'All Custom Props',
+ }
+ return translations[key] || key
+ },
+ }),
+}))
+
+// ✅ Mock external dependencies only
+const mockSetShowPricingModal = jest.fn()
+jest.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowPricingModal: mockSetShowPricingModal,
+ }),
+}))
+
+// Mock gtag for tracking tests
+let mockGtag: jest.Mock | undefined
+
+describe('UpgradeBtn', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockGtag = jest.fn()
+ ;(window as any).gtag = mockGtag
+ })
+
+ afterEach(() => {
+ delete (window as any).gtag
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render without crashing with default props', () => {
+ // Act
+ render()
+
+ // Assert - should render with default text
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should render premium badge by default', () => {
+ // Act
+ render()
+
+ // Assert - PremiumBadge renders with text content
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should render plain button when isPlain is true', () => {
+ // Act
+ render()
+
+ // Assert - Button should be rendered with plain text
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument()
+ })
+
+ it('should render short text when isShort is true', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/^upgrade$/i)).toBeInTheDocument()
+ })
+
+ it('should render custom label when labelKey is provided', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/custom label/i)).toBeInTheDocument()
+ })
+
+ it('should render custom label in plain button when labelKey is provided with isPlain', () => {
+ // Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(screen.getByText(/custom label/i)).toBeInTheDocument()
+ })
+ })
+
+ // Props tests (REQUIRED)
+ describe('Props', () => {
+ it('should apply custom className to premium badge', () => {
+ // Arrange
+ const customClass = 'custom-upgrade-btn'
+
+ // Act
+ const { container } = render()
+
+ // Assert - Check the root element has the custom class
+ const rootElement = container.firstChild as HTMLElement
+ expect(rootElement).toHaveClass(customClass)
+ })
+
+ it('should apply custom className to plain button', () => {
+ // Arrange
+ const customClass = 'custom-button-class'
+
+ // Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass(customClass)
+ })
+
+ it('should apply custom style to premium badge', () => {
+ // Arrange
+ const customStyle = { backgroundColor: 'red', padding: '10px' }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const rootElement = container.firstChild as HTMLElement
+ expect(rootElement).toHaveStyle(customStyle)
+ })
+
+ it('should apply custom style to plain button', () => {
+ // Arrange
+ const customStyle = { backgroundColor: 'blue', margin: '5px' }
+
+ // Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveStyle(customStyle)
+ })
+
+ it('should render with size "s"', () => {
+ // Act
+ render()
+
+ // Assert - Component renders successfully with size prop
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should render with size "m" by default', () => {
+ // Act
+ render()
+
+ // Assert - Component renders successfully
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should render with size "custom"', () => {
+ // Act
+ render()
+
+ // Assert - Component renders successfully with custom size
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call custom onClick when provided and premium badge is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockSetShowPricingModal).not.toHaveBeenCalled()
+ })
+
+ it('should call custom onClick when provided and plain button is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render()
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockSetShowPricingModal).not.toHaveBeenCalled()
+ })
+
+ it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ // Assert
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should track gtag event when loc is provided and badge is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const loc = 'header-navigation'
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert
+ expect(mockGtag).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc,
+ })
+ })
+
+ it('should track gtag event when loc is provided and plain button is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const loc = 'footer-section'
+
+ // Act
+ render()
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ // Assert
+ expect(mockGtag).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc,
+ })
+ })
+
+ it('should not track gtag event when loc is not provided', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should not track gtag event when gtag is not available', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ delete (window as any).gtag
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert - should not throw error
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should call both custom onClick and track gtag when both are provided', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+ const loc = 'settings-page'
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc,
+ })
+ })
+ })
+
+ // Edge Cases (REQUIRED)
+ describe('Edge Cases', () => {
+ it('should handle undefined className', () => {
+ // Act
+ render()
+
+ // Assert - should render without error
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should handle undefined style', () => {
+ // Act
+ render()
+
+ // Assert - should render without error
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should handle undefined onClick', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert - should fall back to setShowPricingModal
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle undefined loc', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert - should not attempt to track gtag
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should handle undefined labelKey', () => {
+ // Act
+ render()
+
+ // Assert - should use default label
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should handle empty string className', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should handle empty string loc', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert - empty loc should not trigger gtag
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should handle empty string labelKey', () => {
+ // Act
+ render()
+
+ // Assert - empty labelKey is falsy, so it falls back to default label
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+ })
+
+ // Prop Combinations
+ describe('Prop Combinations', () => {
+ it('should handle isPlain with isShort', () => {
+ // Act
+ render()
+
+ // Assert - isShort should not affect plain button text
+ expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument()
+ })
+
+ it('should handle isPlain with custom labelKey', () => {
+ // Act
+ render()
+
+ // Assert - labelKey should override plain text
+ expect(screen.getByText(/custom text/i)).toBeInTheDocument()
+ expect(screen.queryByText(/upgrade plan/i)).not.toBeInTheDocument()
+ })
+
+ it('should handle isShort with custom labelKey', () => {
+ // Act
+ render()
+
+ // Assert - labelKey should override isShort behavior
+ expect(screen.getByText(/short custom/i)).toBeInTheDocument()
+ expect(screen.queryByText(/^upgrade$/i)).not.toBeInTheDocument()
+ })
+
+ it('should handle all custom props together', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+ const customStyle = { margin: '10px' }
+ const customClass = 'all-custom'
+
+ // Act
+ const { container } = render(
+ ,
+ )
+ const badge = screen.getByText(/all custom props/i).closest('div')
+ await user.click(badge!)
+
+ // Assert
+ const rootElement = container.firstChild as HTMLElement
+ expect(rootElement).toHaveClass(customClass)
+ expect(rootElement).toHaveStyle(customStyle)
+ expect(screen.getByText(/all custom props/i)).toBeInTheDocument()
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc: 'test-loc',
+ })
+ })
+ })
+
+ // Accessibility Tests
+ describe('Accessibility', () => {
+ it('should be keyboard accessible with plain button', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render()
+ const button = screen.getByRole('button')
+
+ // Tab to button
+ await user.tab()
+ expect(button).toHaveFocus()
+
+ // Press Enter
+ await user.keyboard('{Enter}')
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should be keyboard accessible with Space key', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render()
+
+ // Tab to button and press Space
+ await user.tab()
+ await user.keyboard(' ')
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should be clickable for premium badge variant', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+
+ // Click badge
+ await user.click(badge!)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should have proper button role when isPlain is true', () => {
+ // Act
+ render()
+
+ // Assert - Plain button should have button role
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ })
+ })
+
+ // Performance Tests
+ describe('Performance', () => {
+ it('should not rerender when props do not change', () => {
+ // Arrange
+ const { rerender } = render()
+ const firstRender = screen.getByText(/upgrade to pro/i)
+
+ // Act - Rerender with same props
+ rerender()
+
+ // Assert - Component should still be in document
+ expect(firstRender).toBeInTheDocument()
+ expect(screen.getByText(/upgrade to pro/i)).toBe(firstRender)
+ })
+
+ it('should rerender when props change', () => {
+ // Arrange
+ const { rerender } = render()
+ expect(screen.getByText(/custom text/i)).toBeInTheDocument()
+
+ // Act - Rerender with different labelKey
+ rerender()
+
+ // Assert - Should show new label
+ expect(screen.getByText(/custom label/i)).toBeInTheDocument()
+ expect(screen.queryByText(/custom text/i)).not.toBeInTheDocument()
+ })
+
+ it('should handle rapid rerenders efficiently', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act - Multiple rapid rerenders
+ for (let i = 0; i < 10; i++)
+ rerender()
+
+ // Assert - Component should still render correctly
+ expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
+ })
+
+ it('should be memoized with React.memo', () => {
+ // Arrange
+ const TestWrapper = ({ children }: { children: React.ReactNode }) => {children}
+
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ const firstElement = screen.getByText(/upgrade to pro/i)
+
+ // Act - Rerender parent with same props
+ rerender(
+
+
+ ,
+ )
+
+ // Assert - Element reference should be stable due to memo
+ expect(screen.getByText(/upgrade to pro/i)).toBe(firstElement)
+ })
+ })
+
+ // Integration Tests
+ describe('Integration', () => {
+ it('should work with modal context for pricing modal', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should integrate onClick with analytics tracking', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render()
+ const badge = screen.getByText(/upgrade to pro/i).closest('div')
+ await user.click(badge!)
+
+ // Assert - Both onClick and gtag should be called
+ await waitFor(() => {
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc: 'integration-test',
+ })
+ })
+ })
+ })
+})
diff --git a/web/app/components/explore/installed-app/index.spec.tsx b/web/app/components/explore/installed-app/index.spec.tsx
new file mode 100644
index 0000000000..61ef575183
--- /dev/null
+++ b/web/app/components/explore/installed-app/index.spec.tsx
@@ -0,0 +1,738 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { AppModeEnum } from '@/types/app'
+import { AccessMode } from '@/models/access-control'
+
+// Mock external dependencies BEFORE imports
+jest.mock('use-context-selector', () => ({
+ useContext: jest.fn(),
+ createContext: jest.fn(() => ({})),
+}))
+jest.mock('@/context/web-app-context', () => ({
+ useWebAppStore: jest.fn(),
+}))
+jest.mock('@/service/access-control', () => ({
+ useGetUserCanAccessApp: jest.fn(),
+}))
+jest.mock('@/service/use-explore', () => ({
+ useGetInstalledAppAccessModeByAppId: jest.fn(),
+ useGetInstalledAppParams: jest.fn(),
+ useGetInstalledAppMeta: jest.fn(),
+}))
+
+import { useContext } from 'use-context-selector'
+import InstalledApp from './index'
+import { useWebAppStore } from '@/context/web-app-context'
+import { useGetUserCanAccessApp } from '@/service/access-control'
+import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
+import type { InstalledApp as InstalledAppType } from '@/models/explore'
+
+/**
+ * Mock child components for unit testing
+ *
+ * RATIONALE FOR MOCKING:
+ * - TextGenerationApp: 648 lines, complex batch processing, task management, file uploads
+ * - ChatWithHistory: 576-line custom hook, complex conversation/history management, 30+ context values
+ *
+ * These components are too complex to test as real components. Using real components would:
+ * 1. Require mocking dozens of their dependencies (services, contexts, hooks)
+ * 2. Make tests fragile and coupled to child component implementation details
+ * 3. Violate the principle of testing one component in isolation
+ *
+ * For a container component like InstalledApp, its responsibility is to:
+ * - Correctly route to the appropriate child component based on app mode
+ * - Pass the correct props to child components
+ * - Handle loading/error states before rendering children
+ *
+ * The internal logic of ChatWithHistory and TextGenerationApp should be tested
+ * in their own dedicated test files.
+ */
+jest.mock('@/app/components/share/text-generation', () => ({
+ __esModule: true,
+ default: ({ isInstalledApp, installedAppInfo, isWorkflow }: {
+ isInstalledApp?: boolean
+ installedAppInfo?: InstalledAppType
+ isWorkflow?: boolean
+ }) => (
+
+ Text Generation App
+ {isWorkflow && ' (Workflow)'}
+ {isInstalledApp && ` - ${installedAppInfo?.id}`}
+
+ ),
+}))
+
+jest.mock('@/app/components/base/chat/chat-with-history', () => ({
+ __esModule: true,
+ default: ({ installedAppInfo, className }: {
+ installedAppInfo?: InstalledAppType
+ className?: string
+ }) => (
+
+ Chat With History - {installedAppInfo?.id}
+
+ ),
+}))
+
+describe('InstalledApp', () => {
+ const mockUpdateAppInfo = jest.fn()
+ const mockUpdateWebAppAccessMode = jest.fn()
+ const mockUpdateAppParams = jest.fn()
+ const mockUpdateWebAppMeta = jest.fn()
+ const mockUpdateUserCanAccessApp = jest.fn()
+
+ const mockInstalledApp = {
+ id: 'installed-app-123',
+ app: {
+ id: 'app-123',
+ name: 'Test App',
+ mode: AppModeEnum.CHAT,
+ icon_type: 'emoji' as const,
+ icon: '🚀',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ description: 'Test description',
+ use_icon_as_answer_icon: false,
+ },
+ uninstallable: true,
+ is_pinned: false,
+ }
+
+ const mockAppParams = {
+ user_input_form: [],
+ file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
+ system_parameters: {},
+ }
+
+ const mockAppMeta = {
+ tool_icons: {},
+ }
+
+ const mockWebAppAccessMode = {
+ accessMode: AccessMode.PUBLIC,
+ }
+
+ const mockUserCanAccessApp = {
+ result: true,
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ // Mock useContext
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [mockInstalledApp],
+ isFetchingInstalledApps: false,
+ })
+
+ // Mock useWebAppStore
+ ;(useWebAppStore as unknown as jest.Mock).mockImplementation((
+ selector: (state: {
+ updateAppInfo: jest.Mock
+ updateWebAppAccessMode: jest.Mock
+ updateAppParams: jest.Mock
+ updateWebAppMeta: jest.Mock
+ updateUserCanAccessApp: jest.Mock
+ }) => unknown,
+ ) => {
+ const state = {
+ updateAppInfo: mockUpdateAppInfo,
+ updateWebAppAccessMode: mockUpdateWebAppAccessMode,
+ updateAppParams: mockUpdateAppParams,
+ updateWebAppMeta: mockUpdateWebAppMeta,
+ updateUserCanAccessApp: mockUpdateUserCanAccessApp,
+ }
+ return selector(state)
+ })
+
+ // Mock service hooks with default success states
+ ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: mockWebAppAccessMode,
+ error: null,
+ })
+
+ ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: mockAppParams,
+ error: null,
+ })
+
+ ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: mockAppMeta,
+ error: null,
+ })
+
+ ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
+ data: mockUserCanAccessApp,
+ error: null,
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
+ })
+
+ it('should render loading state when fetching app params', () => {
+ ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
+ isFetching: true,
+ data: null,
+ error: null,
+ })
+
+ const { container } = render()
+ const svg = container.querySelector('svg.spin-animation')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should render loading state when fetching app meta', () => {
+ ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
+ isFetching: true,
+ data: null,
+ error: null,
+ })
+
+ const { container } = render()
+ const svg = container.querySelector('svg.spin-animation')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should render loading state when fetching web app access mode', () => {
+ ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
+ isFetching: true,
+ data: null,
+ error: null,
+ })
+
+ const { container } = render()
+ const svg = container.querySelector('svg.spin-animation')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should render loading state when fetching installed apps', () => {
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [mockInstalledApp],
+ isFetchingInstalledApps: true,
+ })
+
+ const { container } = render()
+ const svg = container.querySelector('svg.spin-animation')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should render app not found (404) when installedApp does not exist', () => {
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ expect(screen.getByText(/404/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Error States', () => {
+ it('should render error when app params fails to load', () => {
+ const error = new Error('Failed to load app params')
+ ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: null,
+ error,
+ })
+
+ render()
+ expect(screen.getByText(/Failed to load app params/)).toBeInTheDocument()
+ })
+
+ it('should render error when app meta fails to load', () => {
+ const error = new Error('Failed to load app meta')
+ ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: null,
+ error,
+ })
+
+ render()
+ expect(screen.getByText(/Failed to load app meta/)).toBeInTheDocument()
+ })
+
+ it('should render error when web app access mode fails to load', () => {
+ const error = new Error('Failed to load access mode')
+ ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: null,
+ error,
+ })
+
+ render()
+ expect(screen.getByText(/Failed to load access mode/)).toBeInTheDocument()
+ })
+
+ it('should render error when user access check fails', () => {
+ const error = new Error('Failed to check user access')
+ ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
+ data: null,
+ error,
+ })
+
+ render()
+ expect(screen.getByText(/Failed to check user access/)).toBeInTheDocument()
+ })
+
+ it('should render no permission (403) when user cannot access app', () => {
+ ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
+ data: { result: false },
+ error: null,
+ })
+
+ render()
+ expect(screen.getByText(/403/)).toBeInTheDocument()
+ expect(screen.getByText(/no permission/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('App Mode Rendering', () => {
+ it('should render ChatWithHistory for CHAT mode', () => {
+ render()
+ expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
+ expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
+ })
+
+ it('should render ChatWithHistory for ADVANCED_CHAT mode', () => {
+ const advancedChatApp = {
+ ...mockInstalledApp,
+ app: {
+ ...mockInstalledApp.app,
+ mode: AppModeEnum.ADVANCED_CHAT,
+ },
+ }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [advancedChatApp],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
+ expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
+ })
+
+ it('should render ChatWithHistory for AGENT_CHAT mode', () => {
+ const agentChatApp = {
+ ...mockInstalledApp,
+ app: {
+ ...mockInstalledApp.app,
+ mode: AppModeEnum.AGENT_CHAT,
+ },
+ }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [agentChatApp],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
+ expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
+ })
+
+ it('should render TextGenerationApp for COMPLETION mode', () => {
+ const completionApp = {
+ ...mockInstalledApp,
+ app: {
+ ...mockInstalledApp.app,
+ mode: AppModeEnum.COMPLETION,
+ },
+ }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [completionApp],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
+ expect(screen.getByText(/Text Generation App/)).toBeInTheDocument()
+ expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
+ })
+
+ it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
+ const workflowApp = {
+ ...mockInstalledApp,
+ app: {
+ ...mockInstalledApp.app,
+ mode: AppModeEnum.WORKFLOW,
+ },
+ }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [workflowApp],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
+ expect(screen.getByText(/Workflow/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should use id prop to find installed app', () => {
+ const app1 = { ...mockInstalledApp, id: 'app-1' }
+ const app2 = { ...mockInstalledApp, id: 'app-2' }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [app1, app2],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ expect(screen.getByText(/app-2/)).toBeInTheDocument()
+ })
+
+ it('should handle id that does not match any installed app', () => {
+ render()
+ expect(screen.getByText(/404/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Effects', () => {
+ it('should update app info when installedApp is available', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateAppInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ app_id: 'installed-app-123',
+ site: expect.objectContaining({
+ title: 'Test App',
+ icon_type: 'emoji',
+ icon: '🚀',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ prompt_public: false,
+ copyright: '',
+ show_workflow_steps: true,
+ use_icon_as_answer_icon: false,
+ }),
+ plan: 'basic',
+ custom_config: null,
+ }),
+ )
+ })
+ })
+
+ it('should update app info to null when installedApp is not found', async () => {
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateAppInfo).toHaveBeenCalledWith(null)
+ })
+ })
+
+ it('should update app params when data is available', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
+ })
+ })
+
+ it('should update app meta when data is available', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta)
+ })
+ })
+
+ it('should update web app access mode when data is available', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
+ })
+ })
+
+ it('should update user can access app when data is available', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
+ })
+ })
+
+ it('should update user can access app to false when result is false', async () => {
+ ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
+ data: { result: false },
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
+ })
+ })
+
+ it('should update user can access app to false when data is null', async () => {
+ ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
+ data: null,
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
+ })
+ })
+
+ it('should not update app params when data is null', async () => {
+ ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: null,
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateAppInfo).toHaveBeenCalled()
+ })
+
+ expect(mockUpdateAppParams).not.toHaveBeenCalled()
+ })
+
+ it('should not update app meta when data is null', async () => {
+ ;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: null,
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateAppInfo).toHaveBeenCalled()
+ })
+
+ expect(mockUpdateWebAppMeta).not.toHaveBeenCalled()
+ })
+
+ it('should not update access mode when data is null', async () => {
+ ;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: null,
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockUpdateAppInfo).toHaveBeenCalled()
+ })
+
+ expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty installedApps array', () => {
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ expect(screen.getByText(/404/)).toBeInTheDocument()
+ })
+
+ it('should handle multiple installed apps and find the correct one', () => {
+ const otherApp = {
+ ...mockInstalledApp,
+ id: 'other-app-id',
+ app: {
+ ...mockInstalledApp.app,
+ name: 'Other App',
+ },
+ }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [otherApp, mockInstalledApp],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+ // Should find and render the correct app
+ expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
+ expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
+ })
+
+ it('should apply correct CSS classes to container', () => {
+ const { container } = render()
+ const mainDiv = container.firstChild as HTMLElement
+ expect(mainDiv).toHaveClass('h-full', 'bg-background-default', 'py-2', 'pl-0', 'pr-2', 'sm:p-2')
+ })
+
+ it('should apply correct CSS classes to ChatWithHistory', () => {
+ render()
+ const chatComponent = screen.getByTestId('chat-with-history')
+ expect(chatComponent).toHaveClass('overflow-hidden', 'rounded-2xl', 'shadow-md')
+ })
+
+ it('should handle rapid id prop changes', async () => {
+ const app1 = { ...mockInstalledApp, id: 'app-1' }
+ const app2 = { ...mockInstalledApp, id: 'app-2' }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [app1, app2],
+ isFetchingInstalledApps: false,
+ })
+
+ const { rerender } = render()
+ expect(screen.getByText(/app-1/)).toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText(/app-2/)).toBeInTheDocument()
+ })
+
+ it('should call service hooks with correct appId', () => {
+ render()
+
+ expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith('installed-app-123')
+ expect(useGetInstalledAppParams).toHaveBeenCalledWith('installed-app-123')
+ expect(useGetInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
+ expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
+ appId: 'app-123',
+ isInstalledApp: true,
+ })
+ })
+
+ it('should call service hooks with null when installedApp is not found', () => {
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [],
+ isFetchingInstalledApps: false,
+ })
+
+ render()
+
+ expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith(null)
+ expect(useGetInstalledAppParams).toHaveBeenCalledWith(null)
+ expect(useGetInstalledAppMeta).toHaveBeenCalledWith(null)
+ expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
+ appId: undefined,
+ isInstalledApp: true,
+ })
+ })
+ })
+
+ describe('Component Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ // React.memo wraps the component with a special $$typeof symbol
+ const componentType = (InstalledApp as React.MemoExoticComponent).$$typeof
+ expect(componentType).toBeDefined()
+ })
+
+ it('should re-render when props change', () => {
+ const { rerender } = render()
+ expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
+
+ // Change to a different app
+ const differentApp = {
+ ...mockInstalledApp,
+ id: 'different-app-456',
+ app: {
+ ...mockInstalledApp.app,
+ name: 'Different App',
+ },
+ }
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [differentApp],
+ isFetchingInstalledApps: false,
+ })
+
+ rerender()
+ expect(screen.getByText(/different-app-456/)).toBeInTheDocument()
+ })
+
+ it('should maintain component stability across re-renders with same props', () => {
+ const { rerender } = render()
+ const initialCallCount = mockUpdateAppInfo.mock.calls.length
+
+ // Rerender with same props - useEffect may still run due to dependencies
+ rerender()
+
+ // Component should render successfully
+ expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
+
+ // Mock calls might increase due to useEffect, but component should be stable
+ expect(mockUpdateAppInfo.mock.calls.length).toBeGreaterThanOrEqual(initialCallCount)
+ })
+ })
+
+ describe('Render Priority', () => {
+ it('should show error before loading state', () => {
+ ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
+ isFetching: true,
+ data: null,
+ error: new Error('Some error'),
+ })
+
+ render()
+ // Error should take precedence over loading
+ expect(screen.getByText(/Some error/)).toBeInTheDocument()
+ })
+
+ it('should show error before permission check', () => {
+ ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
+ isFetching: false,
+ data: null,
+ error: new Error('Params error'),
+ })
+ ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
+ data: { result: false },
+ error: null,
+ })
+
+ render()
+ // Error should take precedence over permission
+ expect(screen.getByText(/Params error/)).toBeInTheDocument()
+ expect(screen.queryByText(/403/)).not.toBeInTheDocument()
+ })
+
+ it('should show permission error before 404', () => {
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [],
+ isFetchingInstalledApps: false,
+ })
+ ;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
+ data: { result: false },
+ error: null,
+ })
+
+ render()
+ // Permission should take precedence over 404
+ expect(screen.getByText(/403/)).toBeInTheDocument()
+ expect(screen.queryByText(/404/)).not.toBeInTheDocument()
+ })
+
+ it('should show loading before 404', () => {
+ ;(useContext as jest.Mock).mockReturnValue({
+ installedApps: [],
+ isFetchingInstalledApps: false,
+ })
+ ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
+ isFetching: true,
+ data: null,
+ error: null,
+ })
+
+ const { container } = render()
+ // Loading should take precedence over 404
+ const svg = container.querySelector('svg.spin-animation')
+ expect(svg).toBeInTheDocument()
+ expect(screen.queryByText(/404/)).not.toBeInTheDocument()
+ })
+ })
+})