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) => ( +