diff --git a/test/feature/agent-qa-interaction.test.tsx b/test/feature/agent-qa-interaction.test.tsx
new file mode 100644
index 00000000..ea61f27d
--- /dev/null
+++ b/test/feature/agent-qa-interaction.test.tsx
@@ -0,0 +1,401 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, waitFor, renderHook, act } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { BrowserRouter } from 'react-router-dom'
+import ChatBox from '../../src/components/ChatBox'
+import '../mocks/proxy.mock'
+import '../mocks/authStore.mock'
+import '../mocks/sse.mock'
+import { useProjectStore } from '../../src/store/projectStore'
+import useChatStoreAdapter from '../../src/hooks/useChatStoreAdapter'
+
+/**
+ * Feature Test: Agent Q&A Interaction
+ *
+ * User Journey: Agent asks question → User replies → Execution continues
+ *
+ * This test suite validates the agent question-and-answer interaction.
+ * When an agent needs clarification, it should pause, ask the user a question,
+ * and then continue execution after receiving the user's response.
+ */
+
+// Mock Electron IPC
+(global as any).ipcRenderer = {
+ invoke: vi.fn((channel) => {
+ if (channel === 'get-system-language') return Promise.resolve('en')
+ if (channel === 'get-browser-port') return Promise.resolve(9222)
+ if (channel === 'get-env-path') return Promise.resolve('/path/to/env')
+ if (channel === 'mcp-list') return Promise.resolve({})
+ if (channel === 'get-file-list') return Promise.resolve([])
+ return Promise.resolve()
+ }),
+}
+
+// Mock window.electronAPI
+Object.defineProperty(window, 'electronAPI', {
+ value: {
+ uploadLog: vi.fn().mockResolvedValue(undefined),
+ selectFile: vi.fn().mockResolvedValue({ success: false }),
+ },
+ writable: true,
+})
+
+// Mock scrollTo for JSDOM
+Element.prototype.scrollTo = vi.fn()
+Element.prototype.scroll = vi.fn()
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('Feature Test: Agent Q&A Interaction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Reset the project store
+ const projectStore = useProjectStore.getState()
+ projectStore.getAllProjects().forEach(project => {
+ projectStore.removeProject(project.id)
+ })
+
+ // Create initial project
+ const projectId = projectStore.createProject(
+ 'Agent Q&A Test Project',
+ 'Testing agent question and answer flow'
+ )
+ expect(projectId).toBeDefined()
+ })
+
+ /**
+ * Test 1: Agent question is displayed to user
+ *
+ * Validates that when an agent needs clarification:
+ * - Agent question message appears in chat
+ * - Question is clearly marked as requiring user response
+ * - Input remains enabled for user to respond
+ */
+ it('displays agent question when agent needs clarification', async () => {
+ const { result, rerender: rerenderHook } = renderHook(() => useChatStoreAdapter())
+ const { chatStore } = result.current
+
+ if (!chatStore) {
+ throw new Error('ChatStore is null')
+ }
+
+ const taskId = chatStore.activeTaskId
+ expect(taskId).toBeDefined()
+
+ const { rerender } = render(
+
+
+
+ )
+
+ // Simulate agent asking a question
+ const agentQuestion = 'Which database would you like to use: PostgreSQL or MySQL?'
+
+ await act(async () => {
+ chatStore.setHasMessages(taskId!, true)
+ // Add initial user message to trigger rendering
+ chatStore.addMessages(taskId!, {
+ id: 'init-user-msg',
+ role: 'user',
+ content: 'Start task',
+ attaches: []
+ })
+ })
+
+ // Wait for initial message to render
+ await waitFor(() => {
+ expect(screen.getByText('Start task')).toBeInTheDocument()
+ })
+
+ // Now add agent question in a separate act
+ await act(async () => {
+ chatStore.addMessages(taskId!, {
+ id: 'agent-question-1',
+ role: 'assistant',
+ content: agentQuestion,
+ step: 'agent_question',
+ data: {
+ question: agentQuestion,
+ options: ['PostgreSQL', 'MySQL']
+ }
+ })
+ chatStore.setActiveAsk(taskId!, 'Agent')
+ rerenderHook()
+ })
+
+ // Force re-render of ChatBox
+ rerender(
+
+
+
+ )
+
+ // Debug: Check what messages are in the store
+ console.log('Messages in store:', JSON.stringify(chatStore.tasks[taskId!].messages, null, 2))
+ console.log('Active Ask:', chatStore.tasks[taskId!].activeAsk)
+
+ // Verify agent question appears in UI
+ await waitFor(() => {
+ expect(screen.getByText(agentQuestion)).toBeInTheDocument()
+ }, { timeout: 3000 })
+
+ // Verify input is enabled for user to respond
+ const textarea = screen.getByPlaceholderText('chat.ask-placeholder')
+ expect(textarea).toBeInTheDocument()
+ expect(textarea).not.toBeDisabled()
+ })
+
+ /**
+ * Test 2: User responds to agent question
+ *
+ * Validates the complete Q&A flow:
+ * - Agent asks question
+ * - User types response
+ * - User sends response
+ * - Response appears in chat
+ * - Agent continues execution
+ */
+ it('allows user to respond to agent question and continues execution', async () => {
+ const user = userEvent.setup()
+ const { result, rerender: rerenderHook } = renderHook(() => useChatStoreAdapter())
+ const { chatStore } = result.current
+
+ if (!chatStore) {
+ throw new Error('ChatStore is null')
+ }
+
+ const taskId = chatStore.activeTaskId
+ expect(taskId).toBeDefined()
+
+ render(
+
+
+
+ )
+
+ // Step 1: Agent asks a question
+ const agentQuestion = 'Should I create the API with REST or GraphQL?'
+
+ await act(async () => {
+ chatStore.setHasMessages(taskId!, true)
+ chatStore.addMessages(taskId!, {
+ id: 'init-user-msg',
+ role: 'user',
+ content: 'Start task',
+ attaches: []
+ })
+
+ chatStore.addMessages(taskId!, {
+ id: 'agent-question-2',
+ role: 'assistant',
+ content: agentQuestion,
+ step: 'agent_question'
+ })
+ chatStore.setActiveAsk(taskId!, 'Agent')
+ rerenderHook()
+ })
+
+ // Verify question appears
+ await waitFor(() => {
+ expect(screen.getByText(agentQuestion)).toBeInTheDocument()
+ })
+
+ // Step 2: User types and sends response
+ const userResponse = 'Please use REST API'
+ const textarea = screen.getByPlaceholderText('chat.ask-placeholder')
+
+ await user.clear(textarea)
+ await user.type(textarea, userResponse)
+
+ // Find and click send button
+ const buttons = screen.getAllByRole('button')
+ const sendButton = buttons.find(btn =>
+ btn.querySelector('svg.lucide-arrow-right')
+ )
+ expect(sendButton).toBeInTheDocument()
+
+ // Click send button
+ await user.click(sendButton!)
+
+ // Step 3: Verify user response appears in chat
+ await waitFor(() => {
+ expect(screen.getByText(userResponse)).toBeInTheDocument()
+ })
+
+ // Step 4: Simulate agent continuing execution
+ await act(async () => {
+ chatStore.addMessages(taskId!, {
+ id: 'agent-continue-1',
+ role: 'assistant',
+ content: 'Great! I will create a REST API for you.',
+ step: 'execution'
+ })
+ rerenderHook()
+ })
+
+ // Verify agent continuation message appears
+ await waitFor(() => {
+ expect(screen.getByText('Great! I will create a REST API for you.')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Test 3: Multiple Q&A exchanges
+ *
+ * Validates that agent can ask multiple questions:
+ * - First question and answer
+ * - Second question and answer
+ * - All messages remain in order
+ */
+ it('handles multiple question and answer exchanges', async () => {
+ const { result, rerender: rerenderHook } = renderHook(() => useChatStoreAdapter())
+ const { chatStore } = result.current
+
+ if (!chatStore) {
+ throw new Error('ChatStore is null')
+ }
+
+ const taskId = chatStore.activeTaskId
+ expect(taskId).toBeDefined()
+
+ render(
+
+
+
+ )
+
+ // First Q&A exchange
+ await act(async () => {
+ chatStore.setHasMessages(taskId!, true)
+ chatStore.addMessages(taskId!, {
+ id: 'init-user-msg',
+ role: 'user',
+ content: 'Start task',
+ attaches: []
+ })
+
+ chatStore.addMessages(taskId!, {
+ id: 'q1',
+ role: 'assistant',
+ content: 'What framework should I use?',
+ step: 'agent_question'
+ })
+ chatStore.setActiveAsk(taskId!, 'Agent')
+ rerenderHook()
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('What framework should I use?')).toBeInTheDocument()
+ })
+
+ await act(async () => {
+ chatStore.addMessages(taskId!, {
+ id: 'a1',
+ role: 'user',
+ content: 'Use React',
+ attaches: []
+ })
+ rerenderHook()
+ })
+
+ // Second Q&A exchange
+ await act(async () => {
+ chatStore.addMessages(taskId!, {
+ id: 'q2',
+ role: 'assistant',
+ content: 'Should I use TypeScript or JavaScript?',
+ step: 'agent_question'
+ })
+ chatStore.setActiveAsk(taskId!, 'Agent')
+ rerenderHook()
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Should I use TypeScript or JavaScript?')).toBeInTheDocument()
+ })
+
+ await act(async () => {
+ chatStore.addMessages(taskId!, {
+ id: 'a2',
+ role: 'user',
+ content: 'Use TypeScript',
+ attaches: []
+ })
+ rerenderHook()
+ })
+
+ // Verify all messages are present
+ await waitFor(() => {
+ expect(screen.getByText('What framework should I use?')).toBeInTheDocument()
+ expect(screen.getByText('Use React')).toBeInTheDocument()
+ expect(screen.getByText('Should I use TypeScript or JavaScript?')).toBeInTheDocument()
+ expect(screen.getByText('Use TypeScript')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Test 4: Agent question with multiple choice options
+ *
+ * Validates that agent can present options:
+ * - Question with structured options is displayed
+ * - User can select or type custom response
+ * - Response is recorded correctly
+ */
+ it('displays agent question with multiple choice options', async () => {
+ const { result, rerender: rerenderHook } = renderHook(() => useChatStoreAdapter())
+ const { chatStore } = result.current
+
+ if (!chatStore) {
+ throw new Error('ChatStore is null')
+ }
+
+ const taskId = chatStore.activeTaskId
+ expect(taskId).toBeDefined()
+
+ render(
+
+
+
+ )
+
+ // Agent asks question with options
+ const questionWithOptions = 'Which testing framework should I use?'
+ const options = ['Jest', 'Vitest', 'Mocha', 'Other']
+
+ await act(async () => {
+ chatStore.setHasMessages(taskId!, true)
+ chatStore.addMessages(taskId!, {
+ id: 'init-user-msg',
+ role: 'user',
+ content: 'Start task',
+ attaches: []
+ })
+
+ chatStore.addMessages(taskId!, {
+ id: 'question-with-options',
+ role: 'assistant',
+ content: questionWithOptions,
+ step: 'agent_question',
+ data: {
+ question: questionWithOptions,
+ options: options
+ }
+ })
+ chatStore.setActiveAsk(taskId!, 'Agent')
+ rerenderHook()
+ })
+
+ // Verify question appears
+ await waitFor(() => {
+ expect(screen.getByText(questionWithOptions)).toBeInTheDocument()
+ })
+
+ // User can still type custom response
+ const textarea = screen.getByPlaceholderText('chat.ask-placeholder')
+ expect(textarea).toBeInTheDocument()
+ expect(textarea).not.toBeDisabled()
+ })
+})
\ No newline at end of file
diff --git a/test/feature/api-key-configuration.test.tsx b/test/feature/api-key-configuration.test.tsx
new file mode 100644
index 00000000..762d9536
--- /dev/null
+++ b/test/feature/api-key-configuration.test.tsx
@@ -0,0 +1,619 @@
+import React from 'react'
+import { describe, it, beforeEach, vi, expect, afterEach } from 'vitest'
+import { BrowserRouter } from 'react-router-dom'
+import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import '../mocks/proxy.mock'
+import SettingModels from '../../src/pages/Setting/Models'
+
+// Basic environment setup
+vi.stubEnv('VITE_USE_LOCAL_PROXY', 'true')
+
+// Router mock
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...actual,
+ useNavigate: () => vi.fn(),
+ useLocation: () => ({ pathname: '/settings' }),
+ }
+})
+
+// i18n mock
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Auth store mock
+vi.mock('@/store/authStore', () => ({
+ useAuthStore: () => ({
+ modelType: 'custom',
+ cloud_model_type: 'gpt-4.1-mini',
+ setModelType: vi.fn(),
+ setCloudModelType: vi.fn(),
+ }),
+}))
+
+// Multiple providers for testing
+vi.mock('@/lib/llm', () => ({
+ INIT_PROVODERS: [
+ {
+ id: 'openai',
+ name: 'OpenAI',
+ description: 'OpenAI provider',
+ apiKey: '',
+ apiHost: '',
+ is_valid: false,
+ model_type: '',
+ },
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ description: 'Anthropic Claude provider',
+ apiKey: '',
+ apiHost: '',
+ is_valid: false,
+ model_type: '',
+ },
+ {
+ id: 'gemini',
+ name: 'Google Gemini',
+ description: 'Google Gemini provider',
+ apiKey: '',
+ apiHost: '',
+ is_valid: false,
+ model_type: '',
+ },
+ ],
+}))
+
+// Toast mock
+vi.mock('sonner', () => ({
+ toast: Object.assign(vi.fn(), {
+ dismiss: vi.fn(),
+ success: vi.fn(),
+ error: vi.fn(),
+ }),
+}))
+
+ // Mock Electron IPC
+ ; (global as any).ipcRenderer = {
+ invoke: vi.fn((channel) => {
+ if (channel === 'get-system-language') return Promise.resolve('en')
+ return Promise.resolve()
+ }),
+ }
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('Custom Model Configuration - Complete User Flows', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ describe('First-time Setup Flow', () => {
+ it('allows user to configure their first custom model from scratch', async () => {
+ const { proxyFetchGet, proxyFetchPost, fetchPost } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ // No providers configured initially
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ // Validation succeeds
+ fetchPost.mockResolvedValueOnce({
+ is_valid: true,
+ is_tool_calls: true,
+ })
+
+ // Provider save succeeds
+ proxyFetchPost.mockImplementation(async (url, data) => {
+ if (url === '/api/provider') {
+ return {
+ id: 1,
+ provider_name: data.provider_name,
+ api_key: data.api_key,
+ endpoint_url: data.endpoint_url,
+ is_valid: true,
+ model_type: data.model_type,
+ prefer: false,
+ }
+ }
+ if (url === '/api/provider/prefer') {
+ return { success: true }
+ }
+ return {}
+ })
+
+ // After save, returns configured provider
+ proxyFetchGet.mockResolvedValueOnce({
+ items: [
+ {
+ id: 1,
+ provider_name: 'openai',
+ api_key: 'sk-test123',
+ endpoint_url: 'https://api.openai.com/v1',
+ is_valid: true,
+ model_type: 'gpt-4',
+ prefer: true,
+ },
+ ],
+ })
+
+ render(
+
+
+
+ )
+
+ // Wait for component to load and find OpenAI section
+ await screen.findByText('OpenAI')
+
+ // Find the OpenAI section
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+
+ // User sees "Not Configured" initially within OpenAI section
+ const notConfiguredButton = within(openaiSection).getByRole('button', {
+ name: /not configured/i,
+ })
+ expect(notConfiguredButton).toBeInTheDocument()
+
+ // User fills in API key (using partial match for placeholder)
+ const apiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i)
+ await user.clear(apiKeyInput)
+ await user.type(apiKeyInput, 'sk-test123')
+
+ // User fills in API host
+ const apiHostInput = within(openaiSection).getByPlaceholderText(/enter-your-api-host/i)
+ await user.clear(apiHostInput)
+ await user.type(apiHostInput, 'https://api.openai.com/v1')
+
+ // User fills in model type
+ const modelTypeInput = within(openaiSection).getByPlaceholderText(/enter-your-model-type/i)
+ await user.clear(modelTypeInput)
+ await user.type(modelTypeInput, 'gpt-4')
+
+ // User clicks Save (find Save button within OpenAI section)
+ const saveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(saveButton)
+
+ // User sees success message and "Default" button
+ await waitFor(() => {
+ const defaultButton = within(openaiSection).getByRole('button', { name: /default/i })
+ expect(defaultButton).toBeInTheDocument()
+ })
+ })
+
+ it('prevents saving when required fields are empty', async () => {
+ const { proxyFetchGet } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ // Fresh empty state
+ proxyFetchGet.mockResolvedValue({ items: [] })
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ // Find the OpenAI section
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+
+ // Ensure inputs are empty first
+ const apiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i) as HTMLInputElement
+ const apiHostInput = within(openaiSection).getByPlaceholderText(/enter-your-api-host/i) as HTMLInputElement
+ const modelTypeInput = within(openaiSection).getByPlaceholderText(/enter-your-model-type/i) as HTMLInputElement
+
+ // Clear any existing values
+ if (apiKeyInput.value) await user.clear(apiKeyInput)
+ if (apiHostInput.value) await user.clear(apiHostInput)
+ if (modelTypeInput.value) await user.clear(modelTypeInput)
+
+ // User clicks Save without filling anything
+ const saveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(saveButton)
+
+ // User sees validation errors
+ expect(await within(openaiSection).findByText(/api-key-can-not-be-empty/i)).toBeInTheDocument()
+ expect(within(openaiSection).getByText(/api-host-can-not-be-empty/i)).toBeInTheDocument()
+ expect(within(openaiSection).getByText(/model-type-can-not-be-empty/i)).toBeInTheDocument()
+ })
+
+ it('shows specific error when only API key is missing', async () => {
+ const { proxyFetchGet } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+
+ // User fills only host and model type
+ const apiHostInput = within(openaiSection).getByPlaceholderText(/enter-your-api-host/i)
+ const modelTypeInput = within(openaiSection).getByPlaceholderText(/enter-your-model-type/i)
+
+ await user.clear(apiHostInput)
+ await user.type(apiHostInput, 'https://api.openai.com/v1')
+ await user.clear(modelTypeInput)
+ await user.type(modelTypeInput, 'gpt-4')
+
+ const saveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(saveButton)
+
+ // Only API key error should show
+ expect(await within(openaiSection).findByText(/api-key-can-not-be-empty/i)).toBeInTheDocument()
+ expect(within(openaiSection).queryByText(/api-host-can-not-be-empty/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Validation Error Handling', () => {
+ it('displays inline error when API validation fails', async () => {
+ const { proxyFetchGet, fetchPost } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ // Validation fails with specific message
+ fetchPost.mockResolvedValueOnce({
+ is_valid: false,
+ is_tool_calls: false,
+ message: 'Invalid API key format',
+ })
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+
+ // User enters invalid credentials
+ const apiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i)
+ const apiHostInput = within(openaiSection).getByPlaceholderText(/enter-your-api-host/i)
+ const modelTypeInput = within(openaiSection).getByPlaceholderText(/enter-your-model-type/i)
+
+ await user.clear(apiKeyInput)
+ await user.type(apiKeyInput, 'invalid-key')
+ await user.clear(apiHostInput)
+ await user.type(apiHostInput, 'https://api.openai.com/v1')
+ await user.clear(modelTypeInput)
+ await user.type(modelTypeInput, 'gpt-4')
+
+ const saveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(saveButton)
+
+ // User sees the error message inline
+ expect(await within(openaiSection).findByText(/invalid api key format/i)).toBeInTheDocument()
+ })
+
+ it('clears previous errors when user corrects input', async () => {
+ const { proxyFetchGet } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+
+ // Trigger validation error first
+ const saveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(saveButton)
+
+ // Error appears
+ expect(await within(openaiSection).findByText(/api-key-can-not-be-empty/i)).toBeInTheDocument()
+
+ // User starts typing in API key field
+ const apiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i)
+ await user.type(apiKeyInput, 'sk-')
+
+ // Error disappears
+ await waitFor(() => {
+ expect(within(openaiSection).queryByText(/api-key-can-not-be-empty/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('handles network errors gracefully', async () => {
+ const { proxyFetchGet, fetchPost } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ // Network failure
+ fetchPost.mockRejectedValueOnce(new Error('Network error'))
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+
+ const apiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i)
+ const apiHostInput = within(openaiSection).getByPlaceholderText(/enter-your-api-host/i)
+ const modelTypeInput = within(openaiSection).getByPlaceholderText(/enter-your-model-type/i)
+
+ await user.clear(apiKeyInput)
+ await user.type(apiKeyInput, 'sk-test')
+ await user.clear(apiHostInput)
+ await user.type(apiHostInput, 'https://api.openai.com/v1')
+ await user.clear(modelTypeInput)
+ await user.type(modelTypeInput, 'gpt-4')
+
+ const saveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(saveButton)
+
+ // User sees network error
+ expect(await within(openaiSection).findByText(/network error/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Multiple Provider Management', () => {
+ it('allows user to configure multiple providers independently', async () => {
+ const { proxyFetchGet, proxyFetchPost, fetchPost } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ // No providers initially
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ // First validation (OpenAI)
+ fetchPost.mockResolvedValueOnce({
+ is_valid: true,
+ is_tool_calls: true,
+ })
+
+ proxyFetchPost.mockResolvedValueOnce({
+ id: 1,
+ provider_name: 'openai',
+ api_key: 'sk-openai',
+ endpoint_url: 'https://api.openai.com/v1',
+ is_valid: true,
+ model_type: 'gpt-4',
+ prefer: false,
+ })
+
+ // After first save
+ proxyFetchGet.mockResolvedValueOnce({
+ items: [
+ {
+ id: 1,
+ provider_name: 'openai',
+ api_key: 'sk-openai',
+ endpoint_url: 'https://api.openai.com/v1',
+ is_valid: true,
+ model_type: 'gpt-4',
+ prefer: true,
+ },
+ ],
+ })
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ // Configure first provider (OpenAI)
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+ const openaiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i)
+ const openaiHostInput = within(openaiSection).getByPlaceholderText(/enter-your-api-host/i)
+ const openaiModelInput = within(openaiSection).getByPlaceholderText(/enter-your-model-type/i)
+
+ await user.clear(openaiKeyInput)
+ await user.type(openaiKeyInput, 'sk-openai')
+ await user.clear(openaiHostInput)
+ await user.type(openaiHostInput, 'https://api.openai.com/v1')
+ await user.clear(openaiModelInput)
+ await user.type(openaiModelInput, 'gpt-4')
+
+ const openaiSaveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(openaiSaveButton)
+
+ // OpenAI now shows as configured
+ await waitFor(() => {
+ const defaultButton = within(openaiSection).getByRole('button', { name: /default/i })
+ expect(defaultButton).toBeInTheDocument()
+ })
+ })
+
+ it('allows user to switch default provider', async () => {
+ const { proxyFetchGet, proxyFetchPost } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ // Two providers already configured
+ proxyFetchGet.mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ provider_name: 'openai',
+ api_key: 'sk-openai',
+ endpoint_url: 'https://api.openai.com/v1',
+ is_valid: true,
+ model_type: 'gpt-4',
+ prefer: true, // Currently default
+ },
+ {
+ id: 2,
+ provider_name: 'anthropic',
+ api_key: 'sk-ant',
+ endpoint_url: 'https://api.anthropic.com',
+ is_valid: true,
+ model_type: 'claude-3',
+ prefer: false,
+ },
+ ],
+ })
+
+ proxyFetchPost.mockResolvedValueOnce({ success: true })
+
+ render(
+
+
+
+ )
+
+ // Wait for component to load
+ await screen.findByText('OpenAI')
+
+ // Find Anthropic section
+ const anthropicSection = screen.getByText('Anthropic').closest('.w-full')!
+
+ // User clicks "Set as Default" on Anthropic
+ const setDefaultButton = within(anthropicSection).getByRole('button', { name: /set as default/i })
+ await user.click(setDefaultButton)
+
+ // Verify the API was called to set new preference
+ await waitFor(() => {
+ expect(proxyFetchPost).toHaveBeenCalledWith('/api/provider/prefer', {
+ provider_id: 2,
+ })
+ })
+ })
+ })
+
+ describe('Password Visibility Toggle', () => {
+ it('allows user to toggle API key visibility', async () => {
+ const { proxyFetchGet } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+ const apiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i) as HTMLInputElement
+
+ // Initially password type
+ expect(apiKeyInput.type).toBe('password')
+
+ // User enters API key
+ await user.clear(apiKeyInput)
+ await user.type(apiKeyInput, 'sk-secret-key-123')
+
+ // Value is correctly entered
+ expect(apiKeyInput.value).toBe('sk-secret-key-123')
+ expect(apiKeyInput.type).toBe('password')
+
+ // Find the eye icon button within the input container
+ const inputContainer = apiKeyInput.closest('.relative')!
+ const eyeButtons = within(inputContainer).getAllByRole('button')
+ const eyeIconButton = eyeButtons.find(btn => btn.querySelector('svg'))
+
+ // User clicks eye icon to reveal
+ if (eyeIconButton) {
+ await user.click(eyeIconButton)
+
+ // Now visible as text
+ await waitFor(() => {
+ expect(apiKeyInput.type).toBe('text')
+ })
+
+ // Click again to hide
+ await user.click(eyeIconButton)
+
+ await waitFor(() => {
+ expect(apiKeyInput.type).toBe('password')
+ })
+ }
+ })
+ })
+
+
+ describe('Loading States', () => {
+ it('shows loading state during validation', async () => {
+ const { proxyFetchGet, fetchPost } = await import('../mocks/proxy.mock')
+ const user = userEvent.setup()
+
+ proxyFetchGet.mockResolvedValueOnce({ items: [] })
+
+ // Delay the validation response
+ let resolveValidation: any
+ fetchPost.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveValidation = resolve
+ })
+ )
+
+ render(
+
+
+
+ )
+
+ await screen.findByText('OpenAI')
+
+ const openaiSection = screen.getByText('OpenAI').closest('.w-full')!
+
+ const apiKeyInput = within(openaiSection).getByPlaceholderText(/enter-your-api-key/i)
+ const apiHostInput = within(openaiSection).getByPlaceholderText(/enter-your-api-host/i)
+ const modelTypeInput = within(openaiSection).getByPlaceholderText(/enter-your-model-type/i)
+
+ await user.clear(apiKeyInput)
+ await user.type(apiKeyInput, 'sk-test')
+ await user.clear(apiHostInput)
+ await user.type(apiHostInput, 'https://api.openai.com/v1')
+ await user.clear(modelTypeInput)
+ await user.type(modelTypeInput, 'gpt-4')
+
+ const saveButton = within(openaiSection).getByRole('button', { name: /save/i })
+ await user.click(saveButton)
+
+ // User sees "Configuring..." text
+ expect(await within(openaiSection).findByText(/configuring/i)).toBeInTheDocument()
+
+ // Save button is disabled during validation
+ expect(saveButton).toBeDisabled()
+
+ // Resolve the validation
+ resolveValidation({
+ is_valid: true,
+ is_tool_calls: true,
+ })
+
+ // Loading state disappears
+ await waitFor(() => {
+ expect(within(openaiSection).queryByText(/configuring/i)).not.toBeInTheDocument()
+ })
+ })
+ })
+
+
+})
\ No newline at end of file
diff --git a/test/feature/file-attachment.test.tsx b/test/feature/file-attachment.test.tsx
new file mode 100644
index 00000000..1382f91d
--- /dev/null
+++ b/test/feature/file-attachment.test.tsx
@@ -0,0 +1,473 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { Inputbox, FileAttachment } from '../../src/components/ChatBox/BottomBox/InputBox'
+import userEvent from '@testing-library/user-event'
+
+/**
+ * Feature Test: File Attachment
+ *
+ * User Journey: User clicks attach → Selects file → File appears in input → Sends with message
+ *
+ * This test suite validates the file attachment functionality in the Inputbox component.
+ * It focuses on adding files, displaying them, and removing them.
+ */
+
+describe('Feature Test: File Attachment', () => {
+ let mockOnFilesChange: ReturnType
+ let mockOnAddFile: ReturnType
+
+ beforeEach(() => {
+ mockOnFilesChange = vi.fn()
+ mockOnAddFile = vi.fn()
+ })
+
+ /**
+ * Test 1: Add file button is visible
+ *
+ * Validates that users can see the add file button:
+ * - Button is rendered
+ * - Button is clickable
+ */
+ it('displays add file button', () => {
+ render(
+
+ )
+
+ // Verify add file button exists by finding all buttons
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBe(2)
+
+ // First button is add file button (contains Plus icon)
+ const addButton = buttons[0]
+ expect(addButton).toBeTruthy()
+ })
+
+ /**
+ * Test 2: Click add file button triggers callback
+ *
+ * Validates that clicking the add button works:
+ * - Clicks button
+ * - onAddFile callback is called
+ */
+ it('triggers onAddFile when add button is clicked', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ // Find all buttons and click the first one (add file button)
+ const buttons = screen.getAllByRole('button')
+ const addButton = buttons[0] // First button is the add file button
+
+ await user.click(addButton)
+
+ // Verify callback was called
+ expect(mockOnAddFile).toHaveBeenCalledTimes(1)
+ })
+
+ /**
+ * Test 3: Display single attached file
+ *
+ * Validates that attached files are displayed:
+ * - File name is shown
+ * - File icon is displayed
+ */
+ it('displays attached file with name and icon', () => {
+ const files: FileAttachment[] = [
+ { fileName: 'test.txt', filePath: '/path/to/test.txt' },
+ ]
+
+ render(
+
+ )
+
+ // Verify file name is displayed
+ expect(screen.getByText('test.txt')).toBeInTheDocument()
+ })
+
+ /**
+ * Test 4: Display multiple attached files
+ *
+ * Validates that multiple files can be attached:
+ * - Shows all file names
+ * - Each file is displayed independently
+ */
+ it('displays multiple attached files', () => {
+ const files: FileAttachment[] = [
+ { fileName: 'document.pdf', filePath: '/path/to/document.pdf' },
+ { fileName: 'image.png', filePath: '/path/to/image.png' },
+ { fileName: 'data.csv', filePath: '/path/to/data.csv' },
+ ]
+
+ render(
+
+ )
+
+ // Verify all file names are displayed
+ expect(screen.getByText('document.pdf')).toBeInTheDocument()
+ expect(screen.getByText('image.png')).toBeInTheDocument()
+ expect(screen.getByText('data.csv')).toBeInTheDocument()
+ })
+
+ /**
+ * Test 5: Remove attached file
+ *
+ * Validates that users can remove files:
+ * - Clicks remove button on file
+ * - onFilesChange is called with updated list
+ */
+ it('removes file when X button is clicked', async () => {
+ const user = userEvent.setup()
+ const files: FileAttachment[] = [
+ { fileName: 'test.txt', filePath: '/path/to/test.txt' },
+ ]
+
+ render(
+
+ )
+
+ // Find the file chip and hover to reveal X button
+ const fileChip = screen.getByText('test.txt').closest('div')
+ expect(fileChip).toBeInTheDocument()
+
+ // Hover over the file chip
+ await user.hover(fileChip!)
+
+ // Find and click the remove link (X icon)
+ const removeLink = fileChip!.querySelector('a')
+ expect(removeLink).toBeInTheDocument()
+ await user.click(removeLink!)
+
+ // Verify onFilesChange was called with empty array
+ expect(mockOnFilesChange).toHaveBeenCalledWith([])
+ })
+
+ /**
+ * Test 6: Remove one file from multiple files
+ *
+ * Validates that removing one file keeps others:
+ * - Multiple files attached
+ * - Removes specific file
+ * - Other files remain
+ */
+ it('removes specific file from multiple files', async () => {
+ const user = userEvent.setup()
+ const files: FileAttachment[] = [
+ { fileName: 'file1.txt', filePath: '/path/to/file1.txt' },
+ { fileName: 'file2.txt', filePath: '/path/to/file2.txt' },
+ { fileName: 'file3.txt', filePath: '/path/to/file3.txt' },
+ ]
+
+ render(
+
+ )
+
+ // Find file2.txt and remove it
+ const file2Chip = screen.getByText('file2.txt').closest('div')
+ await user.hover(file2Chip!)
+ const removeLink = file2Chip!.querySelector('a')
+ await user.click(removeLink!)
+
+ // Verify onFilesChange was called with file2 removed
+ expect(mockOnFilesChange).toHaveBeenCalledWith([
+ { fileName: 'file1.txt', filePath: '/path/to/file1.txt' },
+ { fileName: 'file3.txt', filePath: '/path/to/file3.txt' },
+ ])
+ })
+
+ /**
+ * Test 7: Files persist with message input
+ *
+ * Validates that files and message can coexist:
+ * - User types message
+ * - Files remain attached
+ * - Both are visible
+ */
+ it('maintains file attachments while typing message', () => {
+ const files: FileAttachment[] = [
+ { fileName: 'test.txt', filePath: '/path/to/test.txt' },
+ ]
+
+ const { rerender } = render(
+
+ )
+
+ // Verify file is displayed
+ expect(screen.getByText('test.txt')).toBeInTheDocument()
+
+ // Update with message
+ rerender(
+
+ )
+
+ // Verify both message and file are present
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toBeInTheDocument()
+ expect((textarea as HTMLTextAreaElement).value).toBe('This is a message')
+ expect(screen.getByText('test.txt')).toBeInTheDocument()
+ })
+
+ /**
+ * Test 8: Show remaining files count
+ *
+ * Validates that more than 5 files shows count indicator:
+ * - Attaches 7 files
+ * - First 5 are visible
+ * - Shows "2+" indicator
+ */
+ it('displays remaining count for more than 5 files', () => {
+ const files: FileAttachment[] = [
+ { fileName: 'file1.txt', filePath: '/path/to/file1.txt' },
+ { fileName: 'file2.txt', filePath: '/path/to/file2.txt' },
+ { fileName: 'file3.txt', filePath: '/path/to/file3.txt' },
+ { fileName: 'file4.txt', filePath: '/path/to/file4.txt' },
+ { fileName: 'file5.txt', filePath: '/path/to/file5.txt' },
+ { fileName: 'file6.txt', filePath: '/path/to/file6.txt' },
+ { fileName: 'file7.txt', filePath: '/path/to/file7.txt' },
+ ]
+
+ render(
+
+ )
+
+ // Verify first 5 files are displayed
+ expect(screen.getByText('file1.txt')).toBeInTheDocument()
+ expect(screen.getByText('file2.txt')).toBeInTheDocument()
+ expect(screen.getByText('file3.txt')).toBeInTheDocument()
+ expect(screen.getByText('file4.txt')).toBeInTheDocument()
+ expect(screen.getByText('file5.txt')).toBeInTheDocument()
+
+ // Verify remaining count is shown (2+)
+ expect(screen.getByText('2+')).toBeInTheDocument()
+ })
+
+ /**
+ * Test 9: Different file types show appropriate icons
+ *
+ * Validates that file icons vary by type:
+ * - Image files show image icon
+ * - Text files show document icon
+ */
+ it('displays appropriate icons for different file types', () => {
+ const files: FileAttachment[] = [
+ { fileName: 'photo.jpg', filePath: '/path/to/photo.jpg' },
+ { fileName: 'document.pdf', filePath: '/path/to/document.pdf' },
+ ]
+
+ const { container } = render(
+
+ )
+
+ // Both files should be displayed
+ expect(screen.getByText('photo.jpg')).toBeInTheDocument()
+ expect(screen.getByText('document.pdf')).toBeInTheDocument()
+
+ // Check that SVG icons are rendered (lucide icons render as SVGs)
+ const svgElements = container.querySelectorAll('svg')
+ expect(svgElements.length).toBeGreaterThan(0)
+ })
+
+ /**
+ * Test 10: Disabled state prevents file operations
+ *
+ * Validates that disabled input prevents file actions:
+ * - Add file button is disabled
+ * - File operations are disabled
+ */
+ it('disables file operations when input is disabled', () => {
+ render(
+
+ )
+
+ // Find all buttons
+ const buttons = screen.getAllByRole('button')
+
+ // Add file button (first button) should be disabled
+ expect(buttons[0]).toHaveProperty('disabled', true)
+
+ // Send button (second button) should be disabled
+ expect(buttons[1]).toHaveProperty('disabled', true)
+ })
+
+ /**
+ * Test 11: Privacy mode disables file attachment
+ *
+ * Validates that privacy mode controls file attachment:
+ * - privacy=false disables add file button
+ * - privacy=true enables add file button
+ */
+ it('disables file attachment when privacy is disabled', () => {
+ const { rerender } = render(
+
+ )
+
+ // Add file button should be disabled when privacy is false
+ const buttons = screen.getAllByRole('button')
+ expect(buttons[0]).toHaveProperty('disabled', true)
+
+ // Enable privacy
+ rerender(
+
+ )
+
+ // Add file button should be enabled when privacy is true
+ const updatedButtons = screen.getAllByRole('button')
+ expect(updatedButtons[0]).toHaveProperty('disabled', false)
+ })
+
+ /**
+ * Test 12: Complete file attachment workflow
+ *
+ * Validates the complete user workflow:
+ * - Start with no files
+ * - Add file via callback
+ * - File appears in list
+ * - Type message
+ * - Remove file
+ * - File list is empty
+ */
+ it('completes full file attachment workflow', async () => {
+ const user = userEvent.setup()
+ let currentFiles: FileAttachment[] = []
+ let currentValue = ''
+
+ const handleFilesChange = (files: FileAttachment[]) => {
+ currentFiles = files
+ mockOnFilesChange(files)
+ }
+
+ const handleValueChange = (value: string) => {
+ currentValue = value
+ }
+
+ const { rerender } = render(
+
+ )
+
+ // Step 1: Initially no files
+ expect(screen.queryByText('test.txt')).toBeNull()
+
+ // Step 2: Add file via callback (simulating file picker)
+ const addButton = screen.getAllByRole('button')[0]
+ await user.click(addButton)
+ expect(mockOnAddFile).toHaveBeenCalledTimes(1)
+
+ // Simulate file being added
+ currentFiles = [{ fileName: 'test.txt', filePath: '/path/to/test.txt' }]
+ rerender(
+
+ )
+
+ // Step 3: File appears in list
+ expect(screen.getByText('test.txt')).toBeInTheDocument()
+
+ // Step 4: Type message
+ const textarea = screen.getByRole('textbox')
+ await user.type(textarea, 'Please analyze this file')
+ currentValue = 'Please analyze this file'
+ rerender(
+
+ )
+
+ // Verify both message and file are present
+ expect((textarea as HTMLTextAreaElement).value).toBe('Please analyze this file')
+ expect(screen.getByText('test.txt')).toBeInTheDocument()
+
+ // Step 5: Remove file
+ const fileChip = screen.getByText('test.txt').closest('div')
+ await user.hover(fileChip!)
+ const removeLink = fileChip!.querySelector('a')
+ await user.click(removeLink!)
+
+ // Step 6: Verify file was removed
+ expect(mockOnFilesChange).toHaveBeenCalledWith([])
+ })
+})
diff --git a/test/feature/login.test.tsx b/test/feature/login.test.tsx
new file mode 100644
index 00000000..a7681e63
--- /dev/null
+++ b/test/feature/login.test.tsx
@@ -0,0 +1,497 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { BrowserRouter } from 'react-router-dom'
+import Login from '../../src/pages/Login'
+import * as httpModule from '../../src/api/http'
+import { useAuthStore } from '../../src/store/authStore'
+
+// Mock react-router-dom
+const mockNavigate = vi.fn()
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ useLocation: () => ({ pathname: '/login' }),
+ }
+})
+
+// Mock authStore
+vi.mock('../../src/store/authStore', () => {
+ const mockState = {
+ token: null,
+ username: null,
+ email: null,
+ user_id: null,
+ appearance: 'light',
+ language: 'en',
+ isFirstLaunch: true,
+ modelType: 'cloud' as const,
+ cloud_model_type: 'gpt-4.1' as const,
+ initState: 'permissions' as const,
+ share_token: null,
+ localProxyValue: null,
+ workerListData: {},
+ setAuth: vi.fn(),
+ setModelType: vi.fn(),
+ setLocalProxyValue: vi.fn(),
+ logout: vi.fn(),
+ setAppearance: vi.fn(),
+ setLanguage: vi.fn(),
+ setInitState: vi.fn(),
+ setCloudModelType: vi.fn(),
+ setIsFirstLaunch: vi.fn(),
+ setWorkerList: vi.fn(),
+ checkAgentTool: vi.fn(),
+ getState: vi.fn(),
+ setState: vi.fn(),
+ subscribe: vi.fn(),
+ destroy: vi.fn(),
+ }
+
+ return {
+ useAuthStore: vi.fn(() => mockState),
+ getAuthStore: vi.fn(() => mockState),
+ useWorkerList: vi.fn(() => []),
+ }
+})
+
+// Mock @stackframe/react
+vi.mock('@stackframe/react', () => ({
+ useStackApp: () => null,
+}))
+
+// Mock hasStackKeys
+vi.mock('../../src/lib', () => ({
+ hasStackKeys: () => false,
+}))
+
+// Mock Electron APIs
+Object.defineProperty(window, 'electronAPI', {
+ value: {
+ getPlatform: vi.fn(() => 'win32'),
+ closeWindow: vi.fn(),
+ },
+ writable: true,
+})
+
+Object.defineProperty(window, 'ipcRenderer', {
+ value: {
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+ writable: true,
+})
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+// Create spy on proxyFetchPost
+const proxyFetchPostSpy = vi.spyOn(httpModule, 'proxyFetchPost')
+let authStore: ReturnType // or: let authStore: any
+
+describe('Feature Test: Login Flow', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear()
+
+ authStore = useAuthStore() // always the same mockState object
+
+ vi.mocked(authStore.setAuth).mockClear()
+ vi.mocked(authStore.setModelType).mockClear()
+ vi.mocked(authStore.setLocalProxyValue).mockClear()
+
+ proxyFetchPostSpy.mockClear()
+ proxyFetchPostSpy.mockResolvedValue({
+ code: 0,
+ token: 'test-token',
+ username: 'Test User',
+ user_id: 1,
+ })
+})
+
+
+ /**
+ * Test 1: Display login form
+ *
+ * Verifies that users see all essential login elements:
+ * - Login heading
+ * - Email input field
+ * - Password input field
+ * - Login button
+ * - Sign up link
+ */
+ it('displays the login form with all essential elements', async () => {
+ render(
+
+
+
+ )
+
+ // Verify login heading
+ expect(screen.getByText('layout.login')).toBeInTheDocument()
+
+ // Verify email field
+ const emailInput = screen.getByPlaceholderText('layout.enter-your-email')
+ expect(emailInput).toBeInTheDocument()
+ expect(emailInput).toHaveAttribute('type', 'email')
+
+ // Verify password field
+ const passwordInput = screen.getByPlaceholderText('layout.enter-your-password')
+ expect(passwordInput).toBeInTheDocument()
+ expect(passwordInput).toHaveAttribute('type', 'password')
+
+ // Verify login button
+ const loginButton = screen.getByRole('button', { name: /layout.log-in/i })
+ expect(loginButton).toBeInTheDocument()
+
+ // Verify sign up link
+ const signUpButton = screen.getByRole('button', { name: /layout.sign-up/i })
+ expect(signUpButton).toBeInTheDocument()
+ })
+
+ /**
+ * Test 2: Email validation
+ *
+ * Validates that the system properly validates email input:
+ * - Shows error when email is empty
+ * - Shows error when email format is invalid
+ */
+ it('validates email input and shows appropriate errors', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+
+ )
+
+ const loginButton = screen.getByRole('button', { name: /layout.log-in/i })
+ const passwordInput = screen.getByPlaceholderText('layout.enter-your-password')
+
+ // Try to login with empty email
+ await user.type(passwordInput, 'password123')
+ await user.click(loginButton)
+
+ // Verify email error appears
+ await waitFor(() => {
+ expect(screen.getByText('layout.please-enter-email-address')).toBeInTheDocument()
+ })
+
+ // Try with invalid email format
+ const emailInput = screen.getByPlaceholderText('layout.enter-your-email')
+ await user.clear(emailInput)
+ await user.type(emailInput, 'invalid-email')
+ await user.click(loginButton)
+
+ // Verify invalid email error appears
+ await waitFor(() => {
+ expect(screen.getByText('layout.please-enter-a-valid-email-address')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Test 3: Password validation
+ *
+ * Validates that the system properly validates password input:
+ * - Shows error when password is empty
+ * - Shows error when password is too short (< 6 characters)
+ */
+ it('validates password input and shows appropriate errors', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+
+ )
+
+ const loginButton = screen.getByRole('button', { name: /layout.log-in/i })
+ const emailInput = screen.getByPlaceholderText('layout.enter-your-email')
+
+ // Try to login with empty password
+ await user.type(emailInput, 'test@example.com')
+ await user.click(loginButton)
+
+ // Verify password error appears
+ await waitFor(() => {
+ expect(screen.getByText('layout.please-enter-password')).toBeInTheDocument()
+ })
+
+ // Try with password too short
+ const passwordInput = screen.getByPlaceholderText('layout.enter-your-password')
+ await user.type(passwordInput, '12345')
+ await user.click(loginButton)
+
+ // Verify short password error appears
+ await waitFor(() => {
+ expect(screen.getByText('layout.password-must-be-at-least-8-characters')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Test 4: Password visibility toggle
+ *
+ * Verifies that users can toggle password visibility:
+ * - Password starts hidden
+ * - Clicking eye icon shows password
+ * - Clicking again hides password
+ */
+ it('allows users to toggle password visibility', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+
+ )
+
+ const passwordInput = screen.getByPlaceholderText('layout.enter-your-password')
+
+ // Password should start as hidden (type="password")
+ expect(passwordInput).toHaveAttribute('type', 'password')
+
+ // Find and click the eye icon button (it's the back icon of the password input)
+ const eyeIcons = screen.getAllByRole('img')
+ const eyeIcon = eyeIcons.find(img => img.getAttribute('src')?.includes('eye'))
+
+ if (eyeIcon && eyeIcon.parentElement) {
+ await user.click(eyeIcon.parentElement)
+
+ // Password should now be visible (type="text")
+ await waitFor(() => {
+ expect(passwordInput).toHaveAttribute('type', 'text')
+ })
+ }
+ })
+
+ /**
+ * Test 5: Successful login flow
+ *
+ * This is the core happy-path test that validates the complete login workflow:
+ * 1. User enters valid email and password
+ * 2. User clicks login button
+ * 3. System calls the login API
+ * 4. System stores authentication info
+ * 5. System sets model type to 'cloud'
+ * 6. System redirects to home page
+ */
+ it('successfully logs in user and redirects to home', async () => {
+ const user = userEvent.setup()
+
+ // Mock successful API response - use mockImplementation instead of mockResolvedValueOnce
+ render(
+
+
+
+ )
+
+ // Enter valid credentials
+ const emailInput = screen.getByPlaceholderText('layout.enter-your-email')
+ const passwordInput = screen.getByPlaceholderText('layout.enter-your-password')
+
+ await user.clear(emailInput)
+ await user.type(emailInput, 'test@example.com')
+ await user.clear(passwordInput)
+ await user.type(passwordInput, 'password123')
+
+ // Find and click login button
+ const loginButton = screen.getByRole('button', { name: /layout.log-in/i })
+
+ // Wait a bit for React to process the input changes
+ await waitFor(() => {
+ expect(emailInput).toHaveValue('test@example.com')
+ expect(passwordInput).toHaveValue('password123')
+ })
+
+ await user.click(loginButton)
+
+ // Verify API was called with correct credentials
+ await waitFor(() => {
+ expect(proxyFetchPostSpy).toHaveBeenCalledWith('/api/login', {
+ email: 'test@example.com',
+ password: 'password123',
+ })
+ }, { timeout: 3000 })
+
+ // Verify authentication state was set
+ await waitFor(() => {
+ expect(authStore.setAuth).toHaveBeenCalled()
+ })
+
+ // Verify model type was set to 'cloud'
+ expect(authStore.setModelType).toHaveBeenCalledWith('cloud')
+
+ // Verify navigation to home page
+ expect(mockNavigate).toHaveBeenCalledWith('/')
+ })
+
+ /**
+ * Test 6: Failed login with API error
+ *
+ * Validates error handling when login fails:
+ * 1. User enters credentials
+ * 2. API returns error (code: 10)
+ * 3. System displays error message to user
+ * 4. User is NOT redirected
+ */
+ it('shows error message when login fails', async () => {
+ const user = userEvent.setup()
+
+ // Mock failed API response
+ proxyFetchPostSpy.mockResolvedValueOnce({
+ code: 10,
+ text: 'Invalid credentials',
+ })
+
+ render(
+
+
+
+ )
+
+ // Enter credentials
+ const emailInput = screen.getByPlaceholderText('layout.enter-your-email')
+ const passwordInput = screen.getByPlaceholderText('layout.enter-your-password')
+
+ await user.clear(emailInput)
+ await user.type(emailInput, 'test@example.com')
+ await user.clear(passwordInput)
+ await user.type(passwordInput, 'wrongpassword')
+
+ // Wait for input to be processed
+ await waitFor(() => {
+ expect(passwordInput).toHaveValue('wrongpassword')
+ })
+
+ // Click login button
+ const loginButton = screen.getByRole('button', { name: /layout.log-in/i })
+ await user.click(loginButton)
+
+ // Verify error message appears
+ await waitFor(() => {
+ expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
+ }, { timeout: 3000 })
+
+ // Verify user was NOT redirected
+ expect(mockNavigate).not.toHaveBeenCalled()
+
+ // Verify auth state was NOT set
+ expect(authStore.setAuth).not.toHaveBeenCalled()
+ })
+
+ /**
+ * Test 8: Navigation to signup page
+ *
+ * Verifies that users can navigate to signup:
+ * 1. User clicks the "Sign Up" button
+ * 2. System navigates to /signup route
+ */
+ it('navigates to signup page when signup button is clicked', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+
+ )
+
+ const signUpButton = screen.getByRole('button', { name: /layout.sign-up/i })
+ await user.click(signUpButton)
+
+ // Verify navigation to signup page
+ expect(mockNavigate).toHaveBeenCalledWith('/signup')
+ })
+
+ /**
+ * Test 9: Error clearing on input change
+ *
+ * Validates UX behavior where errors are cleared when user starts typing:
+ * 1. Validation error is shown
+ * 2. User starts typing in the field with error
+ * 3. Error message disappears
+ */
+ it('clears field errors when user starts typing', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+
+ )
+
+ const loginButton = screen.getByRole('button', { name: /layout.log-in/i })
+ const emailInput = screen.getByPlaceholderText('layout.enter-your-email')
+
+ // Trigger validation error by trying to login without email
+ await user.click(loginButton)
+
+ // Verify error appears
+ await waitFor(() => {
+ expect(screen.getByText('layout.please-enter-email-address')).toBeInTheDocument()
+ })
+
+ // Start typing in email field
+ await user.type(emailInput, 't')
+
+ // Error should be cleared
+ await waitFor(() => {
+ expect(screen.queryByText('layout.please-enter-email-address')).not.toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Test 10: Enter key submits form
+ *
+ * Validates keyboard accessibility:
+ * 1. User enters credentials
+ * 2. User presses Enter in password field
+ * 3. Login is triggered (same as clicking button)
+ */
+ it('submits login form when user presses Enter in password field', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+
+ )
+
+ // Enter credentials
+ const emailInput = screen.getByPlaceholderText('layout.enter-your-email')
+ const passwordInput = screen.getByPlaceholderText('layout.enter-your-password')
+
+ await user.clear(emailInput)
+ await user.type(emailInput, 'test@example.com')
+ await user.clear(passwordInput)
+ await user.type(passwordInput, 'password123{Enter}')
+
+ // Verify API was called (login was triggered)
+ await waitFor(() => {
+ expect(proxyFetchPostSpy).toHaveBeenCalledWith('/api/login', {
+ email: 'test@example.com',
+ password: 'password123',
+ })
+ }, { timeout: 3000 })
+ })
+
+ /**
+ * Test 11: Privacy policy link
+ *
+ * Validates that privacy policy link is present and functional:
+ * 1. Privacy policy link is visible
+ * 2. Link points to correct URL
+ */
+ it('displays privacy policy link', () => {
+ render(
+
+
+
+ )
+
+ const privacyLink = screen.getByRole('button', { name: /layout.privacy-policy/i })
+ expect(privacyLink).toBeInTheDocument()
+ })
+})
+
diff --git a/test/feature/mcp-server-config.test.tsx b/test/feature/mcp-server-config.test.tsx
new file mode 100644
index 00000000..dee84236
--- /dev/null
+++ b/test/feature/mcp-server-config.test.tsx
@@ -0,0 +1,414 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import userEvent from '@testing-library/user-event'
+import { BrowserRouter } from 'react-router-dom'
+import SettingMCP from '../../src/pages/Setting/MCP'
+import '../mocks/proxy.mock'
+
+/**
+ * Feature Test: MCP Server Configuration
+ *
+ * User Journey: User opens MCP settings → Adds server → Sees it in list → Deletes it
+ *
+ * This test suite validates the MCP (Model Context Protocol) server configuration UI.
+ * Users should be able to add, view, enable/disable, and remove MCP servers through the settings interface.
+ */
+
+// Mock Electron IPC for MCP operations
+let mcpServers: any[] = []
+
+const mockElectronAPI = {
+ getPlatform: vi.fn(() => 'darwin'),
+ uploadLog: vi.fn().mockResolvedValue(undefined),
+}
+
+
+Object.defineProperty(window, 'electronAPI', {
+ value: mockElectronAPI,
+ writable: true,
+ configurable: true,
+})
+
+ ; (global as any).ipcRenderer = {
+ invoke: vi.fn((channel, ...args) => {
+ if (channel === 'mcp-list') {
+ return Promise.resolve(mcpServers)
+ }
+ if (channel === 'mcp-add') {
+ const newServer = {
+ ...args[0],
+ id: `mcp-${Date.now()}`,
+ enabled: true,
+ }
+ mcpServers.push(newServer)
+ return Promise.resolve({ success: true, server: newServer })
+ }
+ if (channel === 'mcp-delete') {
+ const id = args[0]
+ mcpServers = mcpServers.filter(s => s.id !== id)
+ return Promise.resolve({ success: true })
+ }
+ if (channel === 'mcp-update') {
+ const updated = args[0]
+ const index = mcpServers.findIndex(s => s.id === updated.id)
+ if (index !== -1) {
+ mcpServers[index] = { ...mcpServers[index], ...updated }
+ return Promise.resolve({ success: true, server: mcpServers[index] })
+ }
+ return Promise.resolve({ success: false })
+ }
+ if (channel === 'get-system-language') return Promise.resolve('en')
+ return Promise.resolve()
+ }),
+ on: vi.fn(),
+ off: vi.fn(),
+ }
+
+// Mock react-router-dom
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...actual,
+ useNavigate: () => vi.fn(),
+ }
+})
+
+// Mock i18n
+vi.mock('react-i18next', async (importOriginal) => {
+ // Import the actual module to keep the correct structure for initReactI18next
+ const actual = await importOriginal()
+
+ return {
+ ...actual,
+ // Override only the hook used in components
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: {
+ changeLanguage: () => new Promise(() => { }),
+ },
+ }),
+ }
+})
+// Mock Monaco Editor
+// This prevents Vitest from trying to parse the heavy monaco-editor package
+vi.mock('monaco-editor', () => ({
+ editor: {
+ create: vi.fn(),
+ defineTheme: vi.fn(),
+ },
+ languages: {
+ register: vi.fn(),
+ setMonarchTokensProvider: vi.fn(),
+ registerCompletionItemProvider: vi.fn(),
+ },
+}))
+
+// Mock the React wrapper (if you use @monaco-editor/react)
+// We replace the complex editor with a simple textarea so we can test inputs easily
+vi.mock('@monaco-editor/react', () => ({
+ default: ({ value, onChange }: any) => (
+