From df482e980fa1cb5cf8d839bda82a239222387065 Mon Sep 17 00:00:00 2001 From: Saedbhati Date: Fri, 28 Nov 2025 17:35:00 +0530 Subject: [PATCH 1/2] feat: User facing feature testing --- test/feature/api-key-configuration.test.tsx | 363 ++++++++++++++ test/feature/file-attachment.test.tsx | 472 +++++++++++++++++++ test/feature/history-replay.test.tsx | 398 ++++++++++++++++ test/feature/language-theme-toggle.test.tsx | 448 ++++++++++++++++++ test/feature/login.test.tsx | 497 ++++++++++++++++++++ test/feature/mcp-server-config.test.tsx | 480 +++++++++++++++++++ test/feature/model-switching.test.tsx | 349 ++++++++++++++ test/feature/project-switching.test.tsx | 124 +++++ test/feature/signup.test.tsx | 465 ++++++++++++++++++ test/mocks/apiConfig.mock.ts | 119 +++++ test/mocks/projectStore.mock.ts | 301 ++++++++++++ test/mocks/proxy.mock.ts | 11 +- 12 files changed, 4026 insertions(+), 1 deletion(-) create mode 100644 test/feature/api-key-configuration.test.tsx create mode 100644 test/feature/file-attachment.test.tsx create mode 100644 test/feature/history-replay.test.tsx create mode 100644 test/feature/language-theme-toggle.test.tsx create mode 100644 test/feature/login.test.tsx create mode 100644 test/feature/mcp-server-config.test.tsx create mode 100644 test/feature/model-switching.test.tsx create mode 100644 test/feature/project-switching.test.tsx create mode 100644 test/feature/signup.test.tsx create mode 100644 test/mocks/apiConfig.mock.ts create mode 100644 test/mocks/projectStore.mock.ts diff --git a/test/feature/api-key-configuration.test.tsx b/test/feature/api-key-configuration.test.tsx new file mode 100644 index 00000000..1791a13f --- /dev/null +++ b/test/feature/api-key-configuration.test.tsx @@ -0,0 +1,363 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + mockProxyFetchGet, + mockProxyFetchPost, + resetApiConfigMock, + getConfigStore, + getConfigValue, + setConfigStore, +} from '../mocks/apiConfig.mock' + +/** + * Feature Test: API Key Configuration + * + * User Journey: User enters API key → Saves → Key is validated and persisted + * + * This test suite validates the API key configuration functionality. + * It focuses on the backend API interactions for storing and validating API keys. + */ + +// Mock the http module +vi.mock('@/api/http', () => ({ + proxyFetchGet: mockProxyFetchGet, + proxyFetchPost: mockProxyFetchPost, +})) + +describe('Feature Test: API Key Configuration', () => { + beforeEach(() => { + // Reset mock state before each test + resetApiConfigMock() + }) + + /** + * Test 1: Retrieve available config groups + * + * Validates that users can see available API config groups: + * - Fetches config info from backend + * - Returns list of providers with their environment variables + */ + it('retrieves available API configuration groups', async () => { + const configInfo = await mockProxyFetchGet('/api/config/info') + + // Verify config info is returned + expect(configInfo).toBeDefined() + expect(configInfo['OpenAI']).toBeDefined() + expect(configInfo['OpenAI'].env_vars).toContain('OPENAI_API_KEY') + expect(configInfo['Anthropic']).toBeDefined() + expect(configInfo['Anthropic'].env_vars).toContain('ANTHROPIC_API_KEY') + }) + + /** + * Test 2: Retrieve existing config values + * + * Validates that users can see their saved API keys: + * - Fetches stored config values + * - Returns empty array when no configs saved + */ + it('retrieves existing configuration values', async () => { + // Initially no configs + let configs = await mockProxyFetchGet('/api/configs') + expect(configs).toEqual([]) + + // Add a config + setConfigStore([ + { config_name: 'OPENAI_API_KEY', config_value: 'sk-test123456789' }, + ]) + + // Retrieve configs + configs = await mockProxyFetchGet('/api/configs') + expect(configs.length).toBe(1) + expect(configs[0].config_name).toBe('OPENAI_API_KEY') + expect(configs[0].config_value).toBe('sk-test123456789') + }) + + /** + * Test 3: Save valid API key + * + * Validates that users can save a valid API key: + * - Submits API key to backend + * - Backend validates and stores the key + * - Returns success response + */ + it('saves valid API key successfully', async () => { + const result = await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-validkey123456', + config_group: 'OpenAI', + }) + + // Verify success response + expect(result.code).toBe(0) + expect(result.text).toBe('Success') + + // Verify key was stored + const storedValue = getConfigValue('OPENAI_API_KEY') + expect(storedValue).toBe('sk-validkey123456') + }) + + /** + * Test 4: Reject empty API key + * + * Validates that empty API keys are rejected: + * - Submits empty value + * - Backend returns error + * - Key is not stored + */ + it('rejects empty API key value', async () => { + await expect( + mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: '', + config_group: 'OpenAI', + }) + ).rejects.toThrow('Config value cannot be empty') + + // Verify key was not stored + const storedValue = getConfigValue('OPENAI_API_KEY') + expect(storedValue).toBeNull() + }) + + /** + * Test 5: Reject whitespace-only API key + * + * Validates that whitespace-only values are rejected: + * - Submits whitespace value + * - Backend returns error + * - Key is not stored + */ + it('rejects whitespace-only API key value', async () => { + await expect( + mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: ' ', + config_group: 'OpenAI', + }) + ).rejects.toThrow('Config value cannot be empty') + + // Verify key was not stored + const storedValue = getConfigValue('OPENAI_API_KEY') + expect(storedValue).toBeNull() + }) + + /** + * Test 6: Validate API key format + * + * Validates that API keys must meet minimum requirements: + * - Short API keys are rejected + * - Valid-length API keys are accepted + */ + it('validates API key format', async () => { + // Too short API key should fail + await expect( + mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-short', + config_group: 'OpenAI', + }) + ).rejects.toThrow('Invalid API key format') + + // Valid length API key should succeed + const result = await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-validkey123456', + config_group: 'OpenAI', + }) + + expect(result.code).toBe(0) + expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-validkey123456') + }) + + /** + * Test 7: Update existing API key + * + * Validates that users can update their API keys: + * - Saves initial key + * - Updates with new value + * - New value replaces old value + */ + it('updates existing API key', async () => { + // Save initial key + await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-oldkey123456', + config_group: 'OpenAI', + }) + + expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-oldkey123456') + + // Update with new key + await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-newkey789012', + config_group: 'OpenAI', + }) + + // Verify new key replaced old key + expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-newkey789012') + + // Verify only one entry exists + const store = getConfigStore() + const openAIKeys = store.filter((c) => c.config_name === 'OPENAI_API_KEY') + expect(openAIKeys.length).toBe(1) + }) + + /** + * Test 8: Save multiple API keys + * + * Validates that users can configure multiple providers: + * - Saves OpenAI key + * - Saves Anthropic key + * - Both keys are stored independently + */ + it('saves multiple API keys for different providers', async () => { + // Save OpenAI key + await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-openai123456', + config_group: 'OpenAI', + }) + + // Save Anthropic key + await mockProxyFetchPost('/api/configs', { + config_name: 'ANTHROPIC_API_KEY', + config_value: 'sk-ant-123456789012', + config_group: 'Anthropic', + }) + + // Verify both keys are stored + expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-openai123456') + expect(getConfigValue('ANTHROPIC_API_KEY')).toBe('sk-ant-123456789012') + + // Verify store has 2 entries + const store = getConfigStore() + expect(store.length).toBe(2) + }) + + /** + * Test 9: Save custom API configuration + * + * Validates that users can configure custom API endpoints: + * - Saves custom API key + * - Saves custom API URL + * - Both values are stored + */ + it('saves custom API configuration with multiple env vars', async () => { + // Save custom API key + await mockProxyFetchPost('/api/configs', { + config_name: 'CUSTOM_API_KEY', + config_value: 'custom-key-123456', + config_group: 'Custom API', + }) + + // Save custom API URL (not an API key, so no format validation) + await mockProxyFetchPost('/api/configs', { + config_name: 'CUSTOM_API_URL', + config_value: 'https://api.example.com', + config_group: 'Custom API', + }) + + // Verify both values are stored + expect(getConfigValue('CUSTOM_API_KEY')).toBe('custom-key-123456') + expect(getConfigValue('CUSTOM_API_URL')).toBe('https://api.example.com') + }) + + /** + * Test 10: Config persistence + * + * Validates that config values persist: + * - Saves multiple configs + * - Retrieves all configs + * - All values are present + */ + it('persists configuration values across retrievals', async () => { + // Save multiple configs + await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-openai123456', + config_group: 'OpenAI', + }) + + await mockProxyFetchPost('/api/configs', { + config_name: 'GOOGLE_API_KEY', + config_value: 'google-key-123456', + config_group: 'Google', + }) + + // Retrieve all configs + const configs = await mockProxyFetchGet('/api/configs') + + // Verify all configs are present + expect(configs.length).toBe(2) + expect(configs.some((c: any) => c.config_name === 'OPENAI_API_KEY')).toBe(true) + expect(configs.some((c: any) => c.config_name === 'GOOGLE_API_KEY')).toBe(true) + }) + + /** + * Test 11: Config group association + * + * Validates that configs are associated with their groups: + * - Saves config with group + * - Group information is stored + * - Can retrieve by group + */ + it('associates configurations with their config groups', async () => { + await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-openai123456', + config_group: 'OpenAI', + }) + + const store = getConfigStore() + const openAIConfig = store.find((c) => c.config_name === 'OPENAI_API_KEY') + + expect(openAIConfig).toBeDefined() + expect(openAIConfig!.config_group).toBe('OpenAI') + }) + + /** + * Test 12: Complete configuration workflow + * + * Validates the complete user workflow: + * - Fetch available config groups + * - Check existing values (initially empty) + * - Save new API key + * - Retrieve to verify persistence + * - Update API key + * - Verify update persisted + */ + it('completes full configuration workflow', async () => { + // Step 1: Fetch available config groups + const configInfo = await mockProxyFetchGet('/api/config/info') + expect(configInfo['OpenAI']).toBeDefined() + + // Step 2: Check existing values (should be empty) + let configs = await mockProxyFetchGet('/api/configs') + expect(configs.length).toBe(0) + + // Step 3: Save new API key + const saveResult = await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-initial123456', + config_group: 'OpenAI', + }) + expect(saveResult.code).toBe(0) + + // Step 4: Retrieve to verify persistence + configs = await mockProxyFetchGet('/api/configs') + expect(configs.length).toBe(1) + expect(configs[0].config_value).toBe('sk-initial123456') + + // Step 5: Update API key + const updateResult = await mockProxyFetchPost('/api/configs', { + config_name: 'OPENAI_API_KEY', + config_value: 'sk-updated789012', + config_group: 'OpenAI', + }) + expect(updateResult.code).toBe(0) + + // Step 6: Verify update persisted + configs = await mockProxyFetchGet('/api/configs') + expect(configs.length).toBe(1) // Still only one entry + expect(configs[0].config_value).toBe('sk-updated789012') // Updated value + }) +}) diff --git a/test/feature/file-attachment.test.tsx b/test/feature/file-attachment.test.tsx new file mode 100644 index 00000000..27345fa6 --- /dev/null +++ b/test/feature/file-attachment.test.tsx @@ -0,0 +1,472 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +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')).toBeTruthy() + }) + + /** + * 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')).toBeDefined() + expect(screen.getByText('image.png')).toBeDefined() + expect(screen.getByText('data.csv')).toBeDefined() + }) + + /** + * 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).toBeDefined() + + // Hover over the file chip + await user.hover(fileChip!) + + // Find and click the remove link (X icon) + const removeLink = fileChip!.querySelector('a') + expect(removeLink).toBeDefined() + 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')).toBeDefined() + + // Update with message + rerender( + + ) + + // Verify both message and file are present + const textarea = screen.getByRole('textbox') + expect(textarea).toBeDefined() + expect((textarea as HTMLTextAreaElement).value).toBe('This is a message') + expect(screen.getByText('test.txt')).toBeDefined() + }) + + /** + * 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')).toBeDefined() + expect(screen.getByText('file2.txt')).toBeDefined() + expect(screen.getByText('file3.txt')).toBeDefined() + expect(screen.getByText('file4.txt')).toBeDefined() + expect(screen.getByText('file5.txt')).toBeDefined() + + // Verify remaining count is shown (2+) + expect(screen.getByText('2+')).toBeDefined() + }) + + /** + * 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')).toBeDefined() + expect(screen.getByText('document.pdf')).toBeDefined() + + // 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')).toBeDefined() + + // 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')).toBeDefined() + + // 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/history-replay.test.tsx b/test/feature/history-replay.test.tsx new file mode 100644 index 00000000..bc0b1e36 --- /dev/null +++ b/test/feature/history-replay.test.tsx @@ -0,0 +1,398 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mockProjectStore, ProjectType } from '../mocks/projectStore.mock' + +/** + * Feature Test: History Replay + * + * User Journey: User opens history → Selects past project → Replays conversation + * + * This test suite validates the history replay functionality using the mock store. + * It focuses on the replayProject feature and related state management. + */ + +describe('Feature Test: History Replay', () => { + beforeEach(() => { + // Reset mock state before each test + mockProjectStore.__reset() + }) + + /** + * Test 1: Replay project with history ID + * + * Validates that users can replay a project from history: + * - Creates replay project with correct type + * - Sets history ID correctly + * - Project has replay tag in metadata + */ + it('creates replay project with history ID', () => { + const replayProjectId = mockProjectStore.createProject( + 'Replay Project', + 'Replayed from history', + 'replay-123', + ProjectType.REPLAY, + 'history-456' + ) + + // Verify project was created + expect(replayProjectId).toBeDefined() + expect(replayProjectId).toBe('replay-123') + + // Verify project has correct properties + const project = mockProjectStore.getProjectById(replayProjectId) + expect(project).toBeDefined() + expect(project!.name).toBe('Replay Project') + expect(project!.description).toBe('Replayed from history') + + // Verify project has replay metadata + expect(project!.metadata?.tags).toContain('replay') + expect(project!.metadata?.historyId).toBe('history-456') + }) + + /** + * Test 2: History ID storage and retrieval + * + * Validates that history IDs are properly stored and retrieved: + * - Can set history ID for a project + * - Can retrieve history ID for a project + */ + it('stores and retrieves history ID for projects', () => { + // Create a normal project + const projectId = mockProjectStore.createProject('Test Project') + + // Set history ID + mockProjectStore.setHistoryId(projectId, 'history-789') + + // Retrieve history ID + const historyId = mockProjectStore.getHistoryId(projectId) + expect(historyId).toBe('history-789') + + // Verify it's stored in metadata + const project = mockProjectStore.getProjectById(projectId) + expect(project!.metadata?.historyId).toBe('history-789') + }) + + /** + * Test 3: Multiple replay projects + * + * Validates that users can create multiple replay projects: + * - Each replay project is independent + * - Each has its own history ID + * - Replay projects don't interfere with each other + */ + it('creates multiple independent replay projects', () => { + // Create first replay project + const replay1Id = mockProjectStore.createProject( + 'Replay 1', + 'First replay', + 'replay-1', + ProjectType.REPLAY, + 'history-1' + ) + + // Create second replay project + const replay2Id = mockProjectStore.createProject( + 'Replay 2', + 'Second replay', + 'replay-2', + ProjectType.REPLAY, + 'history-2' + ) + + // Verify both projects exist + expect(replay1Id).not.toBe(replay2Id) + + const project1 = mockProjectStore.getProjectById(replay1Id) + const project2 = mockProjectStore.getProjectById(replay2Id) + + // Verify both have replay tags + expect(project1!.metadata?.tags).toContain('replay') + expect(project2!.metadata?.tags).toContain('replay') + + // Verify different history IDs + expect(project1!.metadata?.historyId).toBe('history-1') + expect(project2!.metadata?.historyId).toBe('history-2') + }) + + /** + * Test 4: Replay project is active after creation + * + * Validates that replay project becomes active: + * - Newly created replay project is set as active + * - Can switch between normal and replay projects + */ + it('sets replay project as active after creation', () => { + // Create a normal project + const normalProjectId = mockProjectStore.createProject('Normal Project') + expect(mockProjectStore.activeProjectId).toBe(normalProjectId) + + // Create a replay project + const replayProjectId = mockProjectStore.createProject( + 'Replay Project', + 'Replayed', + 'replay-abc', + ProjectType.REPLAY, + 'history-abc' + ) + + // Verify replay project is now active + expect(mockProjectStore.activeProjectId).toBe(replayProjectId) + + // Verify both projects exist + expect(mockProjectStore.getAllProjects().length).toBe(2) + }) + + /** + * Test 5: Replay project with chat stores + * + * Validates that replay projects can have multiple chat stores: + * - Replay project starts with initial chat store + * - Can create additional chat stores in replay project + * - Chat stores maintain replay context + */ + it('manages chat stores within replay project', () => { + // Create replay project + const replayProjectId = mockProjectStore.createProject( + 'Replay Project', + 'Replayed', + 'replay-def', + ProjectType.REPLAY, + 'history-def' + ) + + // Verify initial chat store exists + const project = mockProjectStore.getProjectById(replayProjectId) + expect(Object.keys(project!.chatStores).length).toBeGreaterThan(0) + + // Create additional chat store + const newChatId = mockProjectStore.createChatStore(replayProjectId) + expect(newChatId).toBeDefined() + + // Verify chat store was created + const updatedProject = mockProjectStore.getProjectById(replayProjectId) + expect(Object.keys(updatedProject!.chatStores).length).toBe(2) + expect(updatedProject!.activeChatId).toBe(newChatId) + }) + + /** + * Test 6: Normal projects don't have replay tag + * + * Validates that normal projects are distinguished from replay projects: + * - Normal projects don't have replay tag + * - Normal projects can have history ID without replay tag + */ + it('distinguishes between normal and replay projects', () => { + // Create normal project + const normalProjectId = mockProjectStore.createProject('Normal Project') + + // Create replay project + const replayProjectId = mockProjectStore.createProject( + 'Replay Project', + 'Replayed', + 'replay-ghi', + ProjectType.REPLAY, + 'history-ghi' + ) + + // Verify normal project doesn't have replay tag + const normalProject = mockProjectStore.getProjectById(normalProjectId) + expect(normalProject!.metadata?.tags).not.toContain('replay') + + // Verify replay project has replay tag + const replayProject = mockProjectStore.getProjectById(replayProjectId) + expect(replayProject!.metadata?.tags).toContain('replay') + }) + + /** + * Test 7: Retrieve history ID returns null for non-existent projects + * + * Validates error handling for history ID retrieval: + * - Returns null for non-existent project + * - Returns null for project without history ID + */ + it('handles missing history ID gracefully', () => { + // Try to get history ID for non-existent project + const historyId1 = mockProjectStore.getHistoryId('non-existent') + expect(historyId1).toBeNull() + + // Create project without history ID + const projectId = mockProjectStore.createProject('Test Project') + const historyId2 = mockProjectStore.getHistoryId(projectId) + + // Should return null or undefined for projects without historyId + expect(historyId2).toBeNull() + }) + + /** + * Test 8: Update history ID for existing project + * + * Validates that history ID can be updated: + * - Can update existing history ID + * - Updated value is persisted + */ + it('updates history ID for existing project', () => { + // Create project with initial history ID + const projectId = mockProjectStore.createProject( + 'Test Project', + 'Description', + 'project-123', + ProjectType.REPLAY, + 'history-old' + ) + + // Verify initial history ID + expect(mockProjectStore.getHistoryId(projectId)).toBe('history-old') + + // Update history ID + mockProjectStore.setHistoryId(projectId, 'history-new') + + // Verify updated history ID + expect(mockProjectStore.getHistoryId(projectId)).toBe('history-new') + + // Verify it's in metadata + const project = mockProjectStore.getProjectById(projectId) + expect(project!.metadata?.historyId).toBe('history-new') + }) + + /** + * Test 9: Replay projects in project list + * + * Validates that replay projects appear in project list: + * - Replay projects are included in getAllProjects + * - Can filter replay projects by tag + */ + it('includes replay projects in project list', () => { + // Create mixed projects + const normal1 = mockProjectStore.createProject('Normal 1') + const replay1 = mockProjectStore.createProject('Replay 1', '', 'replay-1', ProjectType.REPLAY, 'hist-1') + const normal2 = mockProjectStore.createProject('Normal 2') + const replay2 = mockProjectStore.createProject('Replay 2', '', 'replay-2', ProjectType.REPLAY, 'hist-2') + + // Get all projects + const allProjects = mockProjectStore.getAllProjects() + expect(allProjects.length).toBe(4) + + // Filter replay projects + const replayProjects = allProjects.filter(p => p.metadata?.tags?.includes('replay')) + expect(replayProjects.length).toBe(2) + + // Verify replay project IDs + const replayIds = replayProjects.map(p => p.id) + expect(replayIds).toContain(replay1) + expect(replayIds).toContain(replay2) + expect(replayIds).not.toContain(normal1) + expect(replayIds).not.toContain(normal2) + }) + + /** + * Test 10: Remove replay project + * + * Validates that replay projects can be removed: + * - Replay project can be deleted + * - History ID is removed with project + * - Other projects remain unaffected + */ + it('removes replay project and its history ID', () => { + // Create normal and replay projects + const normalId = mockProjectStore.createProject('Normal') + const replayId = mockProjectStore.createProject( + 'Replay', + 'Description', + 'replay-xyz', + ProjectType.REPLAY, + 'history-xyz' + ) + + // Verify both exist + expect(mockProjectStore.getAllProjects().length).toBe(2) + expect(mockProjectStore.getHistoryId(replayId)).toBe('history-xyz') + + // Remove replay project + mockProjectStore.removeProject(replayId) + + // Verify replay project is removed + expect(mockProjectStore.getProjectById(replayId)).toBeNull() + expect(mockProjectStore.getAllProjects().length).toBe(1) + + // Verify normal project still exists + expect(mockProjectStore.getProjectById(normalId)).toBeDefined() + + // Verify can't get history ID for removed project + expect(mockProjectStore.getHistoryId(replayId)).toBeNull() + }) + + /** + * Test 11: Replay project metadata persistence + * + * Validates that replay metadata is properly maintained: + * - Metadata includes status, tags, and historyId + * - Metadata persists through project updates + */ + it('maintains replay metadata through updates', () => { + // Create replay project + const replayId = mockProjectStore.createProject( + 'Replay Project', + 'Original description', + 'replay-persist', + ProjectType.REPLAY, + 'history-persist' + ) + + // Verify initial metadata + let project = mockProjectStore.getProjectById(replayId) + expect(project!.metadata?.tags).toContain('replay') + expect(project!.metadata?.historyId).toBe('history-persist') + expect(project!.metadata?.status).toBe('active') + + // Update project with additional metadata + mockProjectStore.updateProject(replayId, { + name: 'Updated Replay Project', + metadata: { + ...project!.metadata, + priority: 'high', + tags: [...(project!.metadata?.tags || []), 'important'] + } + }) + + // Verify metadata is preserved and updated + project = mockProjectStore.getProjectById(replayId) + expect(project!.name).toBe('Updated Replay Project') + expect(project!.metadata?.tags).toContain('replay') + expect(project!.metadata?.tags).toContain('important') + expect(project!.metadata?.historyId).toBe('history-persist') + expect(project!.metadata?.priority).toBe('high') + }) + + /** + * Test 12: Empty check doesn't affect replay projects + * + * Validates that replay projects are treated appropriately: + * - isEmpty check works for replay projects + * - Replay projects with no messages are considered empty + */ + it('correctly identifies empty replay projects', () => { + // Create replay project + const replayId = mockProjectStore.createProject( + 'Empty Replay', + 'Description', + 'replay-empty', + ProjectType.REPLAY, + 'history-empty' + ) + + // Verify project exists + const project = mockProjectStore.getProjectById(replayId) + expect(project).toBeDefined() + + // Check if project is empty (has only initial chat store, no queued messages) + const isEmpty = mockProjectStore.isEmptyProject(project!) + expect(isEmpty).toBe(true) + + // Add a queued message + mockProjectStore.addQueuedMessage(replayId, 'Test message', []) + + // Verify project is no longer empty + const updatedProject = mockProjectStore.getProjectById(replayId) + const isStillEmpty = mockProjectStore.isEmptyProject(updatedProject!) + expect(isStillEmpty).toBe(false) + }) +}) diff --git a/test/feature/language-theme-toggle.test.tsx b/test/feature/language-theme-toggle.test.tsx new file mode 100644 index 00000000..90aa2766 --- /dev/null +++ b/test/feature/language-theme-toggle.test.tsx @@ -0,0 +1,448 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useAuthStore } from '../../src/store/authStore' + +/** + * Feature Test: Language and Theme Toggle + * + * User Journey: User switches language → UI updates / User switches theme → Styles update + * + * This test suite validates the language and theme switching functionality. + * Users should be able to change the application language and appearance theme. + */ + +describe('Feature Test: Language and Theme Toggle', () => { + beforeEach(() => { + // Reset store to default state + const store = useAuthStore.getState() + store.setLanguage('en') + store.setAppearance('light') + }) + + /** + * Test 1: Default language is English + * + * Validates initial language state: + * - Application starts with English ('en') + * - Language setting is accessible + */ + it('initializes with English language by default', () => { + const { result } = renderHook(() => useAuthStore()) + + expect(result.current.language).toBe('en') + }) + + /** + * Test 2: Default theme is light + * + * Validates initial theme state: + * - Application starts with light theme + * - Theme setting is accessible + */ + it('initializes with light theme by default', () => { + const { result } = renderHook(() => useAuthStore()) + + expect(result.current.appearance).toBe('light') + }) + + /** + * Test 3: Switch language to Chinese + * + * Validates language switching: + * - User changes language from English to Chinese + * - Language setting updates correctly + * - UI text should render in Chinese (tested by i18n) + */ + it('switches language from English to Chinese', () => { + const { result } = renderHook(() => useAuthStore()) + + // Verify starts with English + expect(result.current.language).toBe('en') + + // Switch to Chinese + act(() => { + result.current.setLanguage('zh') + }) + + // Verify language changed + expect(result.current.language).toBe('zh') + }) + + /** + * Test 4: Switch language to Japanese + * + * Validates Japanese language support: + * - Can switch to Japanese ('ja') + * - Language persists + */ + it('switches language to Japanese', () => { + const { result } = renderHook(() => useAuthStore()) + + act(() => { + result.current.setLanguage('ja') + }) + + expect(result.current.language).toBe('ja') + }) + + /** + * Test 5: Switch between multiple languages + * + * Validates language switching flow: + * - English → Chinese → Japanese → English + * - Each switch updates correctly + * - No state corruption + */ + it('switches between multiple languages sequentially', () => { + const { result } = renderHook(() => useAuthStore()) + + // Start with English + expect(result.current.language).toBe('en') + + // Switch to Chinese + act(() => { + result.current.setLanguage('zh') + }) + expect(result.current.language).toBe('zh') + + // Switch to Japanese + act(() => { + result.current.setLanguage('ja') + }) + expect(result.current.language).toBe('ja') + + // Switch back to English + act(() => { + result.current.setLanguage('en') + }) + expect(result.current.language).toBe('en') + }) + + /** + * Test 6: Switch theme to dark mode + * + * Validates dark theme switching: + * - User changes from light to dark theme + * - Appearance setting updates + * - Theme should apply dark styles (tested by CSS) + */ + it('switches theme from light to dark', () => { + const { result } = renderHook(() => useAuthStore()) + + // Verify starts with light + expect(result.current.appearance).toBe('light') + + // Switch to dark + act(() => { + result.current.setAppearance('dark') + }) + + // Verify theme changed + expect(result.current.appearance).toBe('dark') + }) + + /** + * Test 7: Switch theme to system preference + * + * Validates system theme mode: + * - User can select 'system' appearance + * - Theme follows OS preference + */ + it('switches theme to system preference', () => { + const { result } = renderHook(() => useAuthStore()) + + act(() => { + result.current.setAppearance('system') + }) + + expect(result.current.appearance).toBe('system') + }) + + /** + * Test 8: Toggle between all theme options + * + * Validates all theme modes: + * - light → dark → system → light + * - Each mode works correctly + */ + it('cycles through all theme options', () => { + const { result } = renderHook(() => useAuthStore()) + + // Start with light + expect(result.current.appearance).toBe('light') + + // Switch to dark + act(() => { + result.current.setAppearance('dark') + }) + expect(result.current.appearance).toBe('dark') + + // Switch to system + act(() => { + result.current.setAppearance('system') + }) + expect(result.current.appearance).toBe('system') + + // Switch back to light + act(() => { + result.current.setAppearance('light') + }) + expect(result.current.appearance).toBe('light') + }) + + /** + * Test 9: Language and theme are independent + * + * Validates settings independence: + * - Changing language doesn't affect theme + * - Changing theme doesn't affect language + * - Both settings persist separately + */ + it('maintains independence between language and theme settings', () => { + const { result } = renderHook(() => useAuthStore()) + + // Set initial state + act(() => { + result.current.setLanguage('en') + result.current.setAppearance('light') + }) + + expect(result.current.language).toBe('en') + expect(result.current.appearance).toBe('light') + + // Change language, verify theme unchanged + act(() => { + result.current.setLanguage('zh') + }) + + expect(result.current.language).toBe('zh') + expect(result.current.appearance).toBe('light') // Should remain light + + // Change theme, verify language unchanged + act(() => { + result.current.setAppearance('dark') + }) + + expect(result.current.language).toBe('zh') // Should remain zh + expect(result.current.appearance).toBe('dark') + }) + + /** + * Test 10: Settings persist across store access + * + * Validates settings persistence: + * - Multiple components can access same settings + * - Changes in one instance reflect in others + * - State is shared globally + */ + it('persists language and theme settings across store instances', () => { + const { result: result1 } = renderHook(() => useAuthStore()) + + // Set values in first instance + act(() => { + result1.current.setLanguage('ja') + result1.current.setAppearance('dark') + }) + + // Create second instance + const { result: result2 } = renderHook(() => useAuthStore()) + + // Verify second instance has same values + expect(result2.current.language).toBe('ja') + expect(result2.current.appearance).toBe('dark') + + // Change in second instance + act(() => { + result2.current.setLanguage('en') + result2.current.setAppearance('light') + }) + + // Verify first instance is updated + expect(result1.current.language).toBe('en') + expect(result1.current.appearance).toBe('light') + }) + + /** + * Test 11: Supported languages + * + * Validates all supported languages: + * - English (en) + * - Chinese (zh) + * - Japanese (ja) + * - Each language can be selected + */ + it('supports all available languages', () => { + const { result } = renderHook(() => useAuthStore()) + + const supportedLanguages = ['en', 'zh', 'ja'] + + supportedLanguages.forEach(lang => { + act(() => { + result.current.setLanguage(lang) + }) + expect(result.current.language).toBe(lang) + }) + }) + + /** + * Test 12: Supported themes + * + * Validates all supported themes: + * - light + * - dark + * - system + * - Each theme can be selected + */ + it('supports all available themes', () => { + const { result } = renderHook(() => useAuthStore()) + + const supportedThemes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'] + + supportedThemes.forEach(theme => { + act(() => { + result.current.setAppearance(theme) + }) + expect(result.current.appearance).toBe(theme) + }) + }) + + /** + * Test 13: Settings persist after logout + * + * Validates persistence through logout: + * - User sets custom language and theme + * - User logs out + * - Settings remain (not cleared by logout) + */ + it('preserves language and theme settings after logout', () => { + const { result } = renderHook(() => useAuthStore()) + + // Set custom settings + act(() => { + result.current.setLanguage('zh') + result.current.setAppearance('dark') + }) + + expect(result.current.language).toBe('zh') + expect(result.current.appearance).toBe('dark') + + // Logout + act(() => { + result.current.logout() + }) + + // Verify settings are preserved + // (In real implementation, these persist to localStorage) + expect(result.current.language).toBe('zh') + expect(result.current.appearance).toBe('dark') + }) + + /** + * Test 14: Rapid setting changes + * + * Validates handling of rapid changes: + * - Multiple quick language changes + * - Multiple quick theme changes + * - Final state is correct + * - No race conditions + */ + it('handles rapid language and theme changes correctly', () => { + const { result } = renderHook(() => useAuthStore()) + + // Rapid language changes + act(() => { + result.current.setLanguage('en') + result.current.setLanguage('zh') + result.current.setLanguage('ja') + result.current.setLanguage('en') + }) + + // Should end with last value + expect(result.current.language).toBe('en') + + // Rapid theme changes + act(() => { + result.current.setAppearance('light') + result.current.setAppearance('dark') + result.current.setAppearance('system') + result.current.setAppearance('dark') + }) + + // Should end with last value + expect(result.current.appearance).toBe('dark') + }) + + /** + * Test 15: Complete settings workflow + * + * Validates full user journey: + * - Check default settings + * - Change language + * - Change theme + * - Verify both changes persisted + * - Change both again + * - Verify final state + */ + it('completes full language and theme configuration workflow', () => { + const { result } = renderHook(() => useAuthStore()) + + // Step 1: Check defaults + expect(result.current.language).toBe('en') + expect(result.current.appearance).toBe('light') + + // Step 2: Change language to Chinese + act(() => { + result.current.setLanguage('zh') + }) + expect(result.current.language).toBe('zh') + expect(result.current.appearance).toBe('light') // Theme unchanged + + // Step 3: Change theme to dark + act(() => { + result.current.setAppearance('dark') + }) + expect(result.current.language).toBe('zh') // Language unchanged + expect(result.current.appearance).toBe('dark') + + // Step 4: Change both settings + act(() => { + result.current.setLanguage('ja') + result.current.setAppearance('system') + }) + + // Step 5: Verify final state + expect(result.current.language).toBe('ja') + expect(result.current.appearance).toBe('system') + }) +}) + +/** + * Testing Notes: + * + * 1. **Language Support** + * - 'en': English + * - 'zh': Chinese (Simplified) + * - 'ja': Japanese + * - Additional languages can be added as needed + * + * 2. **Theme Options** + * - 'light': Light mode (default) + * - 'dark': Dark mode + * - 'system': Follow OS preference + * + * 3. **Persistence** + * - Settings are stored in localStorage via Zustand persist + * - Settings survive page refresh + * - Settings persist across logout/login + * + * 4. **UI Integration** + * - Language changes trigger i18n re-render + * - Theme changes apply CSS class to root element + * - Settings usually accessed through Settings page + * + * 5. **Real-world Usage** + * - Users access settings through gear icon + * - Language dropdown shows available languages + * - Theme selector shows light/dark/system options + * - Changes apply immediately without page refresh + */ 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..914e59ba --- /dev/null +++ b/test/feature/mcp-server-config.test.tsx @@ -0,0 +1,480 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +/** + * Feature Test: MCP Server Configuration + * + * User Journey: User adds MCP server → Server appears in list → User can delete it + * + * This test suite validates the MCP (Model Context Protocol) server configuration. + * Users should be able to add, view, and remove MCP servers from their settings. + */ + +interface McpServer { + id: string + name: string + command: string + args?: string[] + env?: Record + enabled: boolean +} + +// Mock MCP store +let mcpServers: McpServer[] = [] + +// Mock Electron API for MCP operations +const mockMcpList = vi.fn() +const mockMcpAdd = vi.fn() +const mockMcpDelete = vi.fn() +const mockMcpUpdate = vi.fn() + +// Setup mocks before tests +Object.defineProperty(window, 'electronAPI', { + value: { + getPlatform: vi.fn(() => 'win32'), + mcpList: mockMcpList, + mcpAdd: mockMcpAdd, + mcpDelete: mockMcpDelete, + mcpUpdate: mockMcpUpdate, + }, + writable: true, + configurable: true, +}); + +(global as any).ipcRenderer = { + invoke: vi.fn((channel, ...args) => { + if (channel === 'mcp-list') return mockMcpList() + if (channel === 'mcp-add') return mockMcpAdd(args[0]) + if (channel === 'mcp-delete') return mockMcpDelete(args[0]) + if (channel === 'mcp-update') return mockMcpUpdate(args[0]) + return Promise.resolve() + }), + on: vi.fn(), + off: vi.fn(), +} + +describe('Feature Test: MCP Server Configuration', () => { + beforeEach(() => { + vi.clearAllMocks() + mcpServers = [] + + // Setup mock implementations + mockMcpList.mockImplementation(() => Promise.resolve(mcpServers)) + + mockMcpAdd.mockImplementation((server: McpServer) => { + const newServer = { + ...server, + id: `mcp-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + enabled: true, + } + mcpServers.push(newServer) + return Promise.resolve({ success: true, server: newServer }) + }) + + mockMcpDelete.mockImplementation((id: string) => { + const index = mcpServers.findIndex(s => s.id === id) + if (index !== -1) { + mcpServers.splice(index, 1) + return Promise.resolve({ success: true }) + } + return Promise.resolve({ success: false, error: 'Server not found' }) + }) + + mockMcpUpdate.mockImplementation((server: McpServer) => { + const index = mcpServers.findIndex(s => s.id === server.id) + if (index !== -1) { + mcpServers[index] = { ...mcpServers[index], ...server } + return Promise.resolve({ success: true, server: mcpServers[index] }) + } + return Promise.resolve({ success: false, error: 'Server not found' }) + }) + }) + + /** + * Test 1: Retrieve empty MCP server list + * + * Validates initial state: + * - Can fetch MCP server list + * - List is empty initially + * - No errors occur + */ + it('retrieves empty MCP server list initially', async () => { + const result = await window.electronAPI.mcpList() + + expect(mockMcpList).toHaveBeenCalledTimes(1) + expect(result).toEqual([]) + expect(result.length).toBe(0) + }) + + /** + * Test 2: Add new MCP server + * + * Validates adding a server: + * - User provides server configuration + * - Server is added successfully + * - Server appears in list with generated ID + * - Server is enabled by default + */ + it('adds new MCP server successfully', async () => { + const newServer = { + name: 'GitHub MCP', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_TOKEN: 'test-token-123', + }, + } + + const result = await window.electronAPI.mcpAdd(newServer) + + // Verify server was added + expect(mockMcpAdd).toHaveBeenCalledTimes(1) + expect(mockMcpAdd).toHaveBeenCalledWith(newServer) + expect(result.success).toBe(true) + expect(result.server).toBeDefined() + expect(result.server.id).toBeDefined() + expect(result.server.name).toBe('GitHub MCP') + expect(result.server.enabled).toBe(true) + + // Verify server is in list + const list = await window.electronAPI.mcpList() + expect(list.length).toBe(1) + expect(list[0].name).toBe('GitHub MCP') + }) + + /** + * Test 3: Add multiple MCP servers + * + * Validates multiple servers: + * - Can add multiple servers + * - Each server has unique ID + * - All servers appear in list + */ + it('adds multiple MCP servers', async () => { + // Add first server + await window.electronAPI.mcpAdd({ + name: 'GitHub MCP', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + }) + + // Add second server + await window.electronAPI.mcpAdd({ + name: 'Filesystem MCP', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem'], + }) + + // Add third server + await window.electronAPI.mcpAdd({ + name: 'Brave Search MCP', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + }) + + // Verify all servers are in list + const list = await window.electronAPI.mcpList() + expect(list.length).toBe(3) + expect(list.map(s => s.name)).toContain('GitHub MCP') + expect(list.map(s => s.name)).toContain('Filesystem MCP') + expect(list.map(s => s.name)).toContain('Brave Search MCP') + + // Verify each has unique ID + const ids = list.map(s => s.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(3) + }) + + /** + * Test 4: Delete MCP server + * + * Validates server deletion: + * - User selects server to delete + * - Server is removed from list + * - Deletion is confirmed + * - Other servers remain unaffected + */ + it('deletes MCP server successfully', async () => { + // Add servers + const result1 = await window.electronAPI.mcpAdd({ + name: 'Server 1', + command: 'cmd1', + }) + const result2 = await window.electronAPI.mcpAdd({ + name: 'Server 2', + command: 'cmd2', + }) + + // Verify both are added + let list = await window.electronAPI.mcpList() + expect(list.length).toBe(2) + + // Delete first server + const deleteResult = await window.electronAPI.mcpDelete(result1.server.id) + expect(deleteResult.success).toBe(true) + + // Verify first server is deleted + list = await window.electronAPI.mcpList() + expect(list.length).toBe(1) + expect(list[0].name).toBe('Server 2') + expect(list[0].id).toBe(result2.server.id) + }) + + /** + * Test 5: Update MCP server configuration + * + * Validates server updates: + * - User modifies server settings + * - Changes are saved + * - Server ID remains the same + * - Updated values are reflected + */ + it('updates MCP server configuration', async () => { + // Add initial server + const addResult = await window.electronAPI.mcpAdd({ + name: 'GitHub MCP', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_TOKEN: 'old-token', + }, + }) + + const serverId = addResult.server.id + + // Update server + const updateResult = await window.electronAPI.mcpUpdate({ + id: serverId, + name: 'GitHub MCP (Updated)', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_TOKEN: 'new-token', + }, + enabled: true, + }) + + expect(updateResult.success).toBe(true) + expect(updateResult.server.id).toBe(serverId) + expect(updateResult.server.name).toBe('GitHub MCP (Updated)') + expect(updateResult.server.env.GITHUB_TOKEN).toBe('new-token') + + // Verify changes in list + const list = await window.electronAPI.mcpList() + expect(list.length).toBe(1) + expect(list[0].name).toBe('GitHub MCP (Updated)') + expect(list[0].env.GITHUB_TOKEN).toBe('new-token') + }) + + /** + * Test 6: Enable/disable MCP server + * + * Validates toggling server state: + * - Server can be disabled without deletion + * - Server can be re-enabled + * - Enabled state persists + */ + it('enables and disables MCP server', async () => { + // Add server (enabled by default) + const addResult = await window.electronAPI.mcpAdd({ + name: 'Test MCP', + command: 'test', + }) + + const serverId = addResult.server.id + expect(addResult.server.enabled).toBe(true) + + // Disable server + await window.electronAPI.mcpUpdate({ + id: serverId, + name: 'Test MCP', + command: 'test', + enabled: false, + }) + + let list = await window.electronAPI.mcpList() + expect(list[0].enabled).toBe(false) + + // Re-enable server + await window.electronAPI.mcpUpdate({ + id: serverId, + name: 'Test MCP', + command: 'test', + enabled: true, + }) + + list = await window.electronAPI.mcpList() + expect(list[0].enabled).toBe(true) + }) + + /** + * Test 7: MCP server with environment variables + * + * Validates environment configuration: + * - Server can have environment variables + * - Env vars are stored correctly + * - Multiple env vars are supported + */ + it('stores MCP server with environment variables', async () => { + const server = { + name: 'Custom MCP', + command: 'node', + args: ['server.js'], + env: { + API_KEY: 'test-key-123', + API_URL: 'https://api.example.com', + DEBUG: 'true', + }, + } + + const result = await window.electronAPI.mcpAdd(server) + + expect(result.success).toBe(true) + expect(result.server.env).toEqual({ + API_KEY: 'test-key-123', + API_URL: 'https://api.example.com', + DEBUG: 'true', + }) + + // Verify env vars persist in list + const list = await window.electronAPI.mcpList() + expect(list[0].env).toEqual(server.env) + }) + + /** + * Test 8: Delete non-existent server returns error + * + * Validates error handling: + * - Attempting to delete non-existent server + * - Returns error response + * - Other servers unaffected + */ + it('handles deletion of non-existent server', async () => { + // Add a server + await window.electronAPI.mcpAdd({ + name: 'Existing Server', + command: 'cmd', + }) + + // Try to delete non-existent server + const result = await window.electronAPI.mcpDelete('non-existent-id') + + expect(result.success).toBe(false) + expect(result.error).toBe('Server not found') + + // Verify existing server is unaffected + const list = await window.electronAPI.mcpList() + expect(list.length).toBe(1) + expect(list[0].name).toBe('Existing Server') + }) + + /** + * Test 9: Complete MCP configuration workflow + * + * Validates full user journey: + * - View empty list + * - Add server + * - Verify server appears + * - Update server settings + * - Disable server + * - Re-enable server + * - Delete server + * - Verify list is empty + */ + it('completes full MCP configuration workflow', async () => { + // Step 1: View empty list + let list = await window.electronAPI.mcpList() + expect(list.length).toBe(0) + + // Step 2: Add server + const addResult = await window.electronAPI.mcpAdd({ + name: 'GitHub MCP', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_TOKEN: 'initial-token', + }, + }) + + expect(addResult.success).toBe(true) + const serverId = addResult.server.id + + // Step 3: Verify server appears + list = await window.electronAPI.mcpList() + expect(list.length).toBe(1) + expect(list[0].name).toBe('GitHub MCP') + + // Step 4: Update server settings + await window.electronAPI.mcpUpdate({ + id: serverId, + name: 'GitHub MCP (Production)', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_TOKEN: 'production-token', + }, + enabled: true, + }) + + list = await window.electronAPI.mcpList() + expect(list[0].name).toBe('GitHub MCP (Production)') + expect(list[0].env.GITHUB_TOKEN).toBe('production-token') + + // Step 5: Disable server + await window.electronAPI.mcpUpdate({ + ...list[0], + enabled: false, + }) + + list = await window.electronAPI.mcpList() + expect(list[0].enabled).toBe(false) + + // Step 6: Re-enable server + await window.electronAPI.mcpUpdate({ + ...list[0], + enabled: true, + }) + + list = await window.electronAPI.mcpList() + expect(list[0].enabled).toBe(true) + + // Step 7: Delete server + const deleteResult = await window.electronAPI.mcpDelete(serverId) + expect(deleteResult.success).toBe(true) + + // Step 8: Verify list is empty + list = await window.electronAPI.mcpList() + expect(list.length).toBe(0) + }) +}) + +/** + * Testing Notes: + * + * 1. **MCP Server Structure** + * - id: Unique identifier (generated on creation) + * - name: Display name + * - command: Executable command + * - args: Optional command arguments + * - env: Optional environment variables + * - enabled: Server activation state + * + * 2. **IPC Channels** + * - 'mcp-list': Get all servers + * - 'mcp-add': Add new server + * - 'mcp-delete': Remove server + * - 'mcp-update': Modify server configuration + * + * 3. **User Workflows** + * - Adding servers through UI form + * - Viewing server list in settings + * - Editing server configuration + * - Toggling server enabled/disabled + * - Removing unwanted servers + * + * 4. **Security Considerations** + * - Environment variables may contain sensitive data (API keys) + * - Commands should be validated before execution + * - Server list should persist securely + */ diff --git a/test/feature/model-switching.test.tsx b/test/feature/model-switching.test.tsx new file mode 100644 index 00000000..1da9e22d --- /dev/null +++ b/test/feature/model-switching.test.tsx @@ -0,0 +1,349 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useAuthStore } from '../../src/store/authStore' + +/** + * Feature Test: Model Switching + * + * User Journey: User switches between Cloud/Custom API/Local modes → Configuration takes effect + * + * This test suite validates the model switching functionality. + * It focuses on the store behavior and state management for different model types. + */ + +describe('Feature Test: Model Switching', () => { + beforeEach(() => { + // Reset store state before each test + const store = useAuthStore.getState() + store.setModelType('cloud') + store.setCloudModelType('gpt-4.1') + }) + + /** + * Test 1: Default model type is cloud + * + * Validates that the application starts with cloud model: + * - Default modelType is 'cloud' + * - Default cloud model type is set + */ + it('initializes with cloud model type by default', () => { + const { result } = renderHook(() => useAuthStore()) + + // Verify default model type + expect(result.current.modelType).toBe('cloud') + expect(result.current.cloud_model_type).toBeDefined() + }) + + /** + * Test 2: Switch to local model + * + * Validates that users can switch to local model: + * - Can set modelType to 'local' + * - State updates correctly + */ + it('switches from cloud to local model', () => { + const { result } = renderHook(() => useAuthStore()) + + // Verify starts with cloud + expect(result.current.modelType).toBe('cloud') + + // Switch to local + act(() => { + result.current.setModelType('local') + }) + + // Verify switched to local + expect(result.current.modelType).toBe('local') + }) + + /** + * Test 3: Switch to custom API model + * + * Validates that users can switch to custom API: + * - Can set modelType to 'custom' + * - State persists + */ + it('switches from cloud to custom API model', () => { + const { result } = renderHook(() => useAuthStore()) + + // Verify starts with cloud + expect(result.current.modelType).toBe('cloud') + + // Switch to custom + act(() => { + result.current.setModelType('custom') + }) + + // Verify switched to custom + expect(result.current.modelType).toBe('custom') + }) + + /** + * Test 4: Switch between all model types + * + * Validates that users can switch between all three types: + * - cloud → local → custom → cloud + * - Each switch updates state correctly + */ + it('switches between all model types sequentially', () => { + const { result } = renderHook(() => useAuthStore()) + + // Start with cloud + expect(result.current.modelType).toBe('cloud') + + // Switch to local + act(() => { + result.current.setModelType('local') + }) + expect(result.current.modelType).toBe('local') + + // Switch to custom + act(() => { + result.current.setModelType('custom') + }) + expect(result.current.modelType).toBe('custom') + + // Switch back to cloud + act(() => { + result.current.setModelType('cloud') + }) + expect(result.current.modelType).toBe('cloud') + }) + + /** + * Test 5: Change cloud model type + * + * Validates that users can change the cloud model: + * - Can switch between different cloud models + * - Model type persists + */ + it('changes cloud model type', () => { + const { result } = renderHook(() => useAuthStore()) + + // Verify initial cloud model + expect(result.current.cloud_model_type).toBe('gpt-4.1') + + // Change to GPT-4.1 mini + act(() => { + result.current.setCloudModelType('gpt-4.1-mini') + }) + expect(result.current.cloud_model_type).toBe('gpt-4.1-mini') + + // Change to Claude Sonnet + act(() => { + result.current.setCloudModelType('claude-sonnet-4-5') + }) + expect(result.current.cloud_model_type).toBe('claude-sonnet-4-5') + + // Change to Gemini + act(() => { + result.current.setCloudModelType('gemini/gemini-2.5-pro') + }) + expect(result.current.cloud_model_type).toBe('gemini/gemini-2.5-pro') + }) + + /** + * Test 6: Cloud model type persists when switching model types + * + * Validates that cloud model selection is preserved: + * - Switching to local/custom doesn't change cloud_model_type + * - Can switch back to cloud with same model + */ + it('preserves cloud model type when switching to other model types', () => { + const { result } = renderHook(() => useAuthStore()) + + // Set specific cloud model + act(() => { + result.current.setCloudModelType('claude-sonnet-4-5') + }) + expect(result.current.cloud_model_type).toBe('claude-sonnet-4-5') + + // Switch to local + act(() => { + result.current.setModelType('local') + }) + + // Verify cloud model type is still set + expect(result.current.cloud_model_type).toBe('claude-sonnet-4-5') + + // Switch back to cloud + act(() => { + result.current.setModelType('cloud') + }) + + // Verify cloud model type is still the same + expect(result.current.cloud_model_type).toBe('claude-sonnet-4-5') + }) + + /** + * Test 7: All cloud model types are supported + * + * Validates that all cloud models can be selected: + * - GPT models (4.1, 4.1-mini, 5, 5-mini) + * - Claude models (Sonnet 4-5, Sonnet 4, Haiku 3.5) + * - Gemini models (2.5 Pro, 2.5 Flash, 3 Pro Preview) + */ + it('supports all available cloud model types', () => { + const { result } = renderHook(() => useAuthStore()) + + const cloudModels: Array<'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-3-pro-preview' | 'gpt-4.1-mini' | 'gpt-4.1' | 'claude-sonnet-4-5' | 'claude-sonnet-4-20250514' | 'claude-3-5-haiku-20241022' | 'gpt-5' | 'gpt-5-mini'> = [ + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-5', + 'gpt-5-mini', + 'claude-sonnet-4-5', + 'claude-sonnet-4-20250514', + 'claude-3-5-haiku-20241022', + 'gemini/gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-3-pro-preview' + ] + + cloudModels.forEach(model => { + act(() => { + result.current.setCloudModelType(model) + }) + expect(result.current.cloud_model_type).toBe(model) + }) + }) + + /** + * Test 8: Model type state is independent + * + * Validates that modelType and cloud_model_type are independent: + * - Changing modelType doesn't affect cloud_model_type + * - Changing cloud_model_type doesn't affect modelType + */ + it('maintains independence between modelType and cloud_model_type', () => { + const { result } = renderHook(() => useAuthStore()) + + // Set cloud model while in cloud mode + act(() => { + result.current.setModelType('cloud') + result.current.setCloudModelType('claude-sonnet-4-5') + }) + expect(result.current.modelType).toBe('cloud') + expect(result.current.cloud_model_type).toBe('claude-sonnet-4-5') + + // Switch to custom, cloud_model_type should remain + act(() => { + result.current.setModelType('custom') + }) + expect(result.current.modelType).toBe('custom') + expect(result.current.cloud_model_type).toBe('claude-sonnet-4-5') + + // Change cloud_model_type while in custom mode + act(() => { + result.current.setCloudModelType('gpt-5') + }) + expect(result.current.modelType).toBe('custom') + expect(result.current.cloud_model_type).toBe('gpt-5') + }) + + /** + * Test 9: Multiple switches maintain correct state + * + * Validates that multiple rapid switches work correctly: + * - State updates are consistent + * - No race conditions + */ + it('handles multiple rapid model type switches correctly', () => { + const { result } = renderHook(() => useAuthStore()) + + act(() => { + result.current.setModelType('cloud') + result.current.setModelType('local') + result.current.setModelType('custom') + }) + + // Should end with the last set value + expect(result.current.modelType).toBe('custom') + + act(() => { + result.current.setCloudModelType('gpt-4.1') + result.current.setCloudModelType('claude-sonnet-4-5') + result.current.setCloudModelType('gemini/gemini-2.5-pro') + }) + + // Should end with the last set value + expect(result.current.cloud_model_type).toBe('gemini/gemini-2.5-pro') + }) + + /** + * Test 10: Model configuration persists across store access + * + * Validates that model configuration is persistent: + * - Creating new hook instances shows same values + * - State is shared across all consumers + */ + it('persists model configuration across multiple hook instances', () => { + const { result: result1 } = renderHook(() => useAuthStore()) + + // Set model configuration in first instance + act(() => { + result1.current.setModelType('local') + result1.current.setCloudModelType('claude-sonnet-4-5') + }) + + // Create second instance + const { result: result2 } = renderHook(() => useAuthStore()) + + // Verify second instance has same values + expect(result2.current.modelType).toBe('local') + expect(result2.current.cloud_model_type).toBe('claude-sonnet-4-5') + + // Change in second instance + act(() => { + result2.current.setModelType('custom') + }) + + // Verify first instance is updated + expect(result1.current.modelType).toBe('custom') + }) + + /** + * Test 11: Model type validation + * + * Validates that only valid model types are accepted: + * - 'cloud', 'local', 'custom' are valid + * - State correctly reflects the current model type + */ + it('only accepts valid model types', () => { + const { result } = renderHook(() => useAuthStore()) + + const validTypes: Array<'cloud' | 'local' | 'custom'> = ['cloud', 'local', 'custom'] + + validTypes.forEach(type => { + act(() => { + result.current.setModelType(type) + }) + expect(result.current.modelType).toBe(type) + }) + }) + + /** + * Test 12: Model settings survive logout/login cycle (persistence test) + * + * Validates that model settings are preserved: + * - Model preferences persist through logout + * - Cloud model type persists + */ + it('preserves model settings through logout', () => { + const { result } = renderHook(() => useAuthStore()) + + // Set custom configuration + act(() => { + result.current.setModelType('custom') + result.current.setCloudModelType('claude-sonnet-4-5') + }) + + // Simulate logout + act(() => { + result.current.logout() + }) + + // Verify model settings are still preserved (due to persistence) + // Note: In real implementation, these settings persist to localStorage + expect(result.current.cloud_model_type).toBe('claude-sonnet-4-5') + }) +}) diff --git a/test/feature/project-switching.test.tsx b/test/feature/project-switching.test.tsx new file mode 100644 index 00000000..79a58d26 --- /dev/null +++ b/test/feature/project-switching.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, beforeEach, expect, vi } from "vitest"; +import { render, fireEvent, screen, waitFor } from "@testing-library/react"; +import HeaderWin from "../../src/components/TopBar/index"; +import { BrowserRouter } from "react-router-dom"; +import { mockProjectStore } from "../mocks/projectStore.mock"; +import useChatStoreAdapter from "@/hooks/useChatStoreAdapter"; +import userEvent from "@testing-library/user-event"; + + +const mockNavigate = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + useLocation: () => ({ pathname: "/" }), + }; +}); + +vi.mock("@/hooks/useChatStoreAdapter", () => ({ + default: vi.fn(), +})); + +// mock electron +Object.defineProperty(window, "electronAPI", { + value: { + getPlatform: vi.fn(() => "win32"), + isFullScreen: vi.fn(() => false), + }, +}); +const mockToggle = vi.fn(); + +vi.mock("@/store/sidebarStore", () => ({ + useSidebarStore: () => ({ + toggle: mockToggle, + }), +})); + + +describe("Feature: User switches between projects or tasks", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockProjectStore.__reset(); + }); + + it("should switch project when user clicks New Project button", async () => { + const user = userEvent.setup(); + + mockProjectStore.createProject("Project A"); + mockProjectStore.createProject("Project B"); + + (useChatStoreAdapter as any).mockReturnValue({ + chatStore: { tasks: {}, activeTaskId: null }, + projectStore: mockProjectStore, + }); + + render( + + + + ); + + // Find the new project button by its accessible label + const newProjectBtn = await screen.findByRole("button", { name: /new project/i }); + + await user.click(newProjectBtn); + + // Assert user-visible behavior: navigation to home + expect(mockNavigate).toHaveBeenCalledWith("/"); + + // Assert the visible title changes to show new project + await waitFor(() => { + expect(screen.getByRole("button", { name: /new project/i })).toBeInTheDocument(); + }); + }); + + it("should switch to Task Two when user clicks it in sidebar", async () => { + const user = userEvent.setup(); + + mockProjectStore.createProject("Project A"); + + const chatStore = { + tasks: { + t1: { summaryTask: "Task One", status: "pending", messages: [] }, + t2: { summaryTask: "Task Two", status: "pending", messages: [] }, + }, + activeTaskId: "t1", + setState: vi.fn(), + removeTask: vi.fn(), + }; + + // mutate chatStore correctly + chatStore.setState.mockImplementation((update) => + Object.assign(chatStore, update) + ); + + (useChatStoreAdapter as any).mockReturnValue({ + chatStore, + projectStore: mockProjectStore, + }); + + render( + + + + ); + + // User clicks the task switcher button to open sidebar + const switcher = screen.getByRole("button", { name: /Task One/i }); + await user.click(switcher); + + // Verify sidebar toggle was called (user sees sidebar open) + expect(mockToggle).toHaveBeenCalled(); + + // Note: This test verifies the user interaction flow. + // Once the sidebar component renders task items with accessible roles, + // we should extend this test to: + // 1. Find task items by role (e.g., getByRole("menuitem", { name: "Task Two" })) + // 2. Click the task item + // 3. Assert the UI updates (e.g., switcher button shows "Task Two") +}); + +}); \ No newline at end of file diff --git a/test/feature/signup.test.tsx b/test/feature/signup.test.tsx new file mode 100644 index 00000000..9da059b6 --- /dev/null +++ b/test/feature/signup.test.tsx @@ -0,0 +1,465 @@ +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 SignUp from '../../src/pages/SignUp' +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: '/signup' }), + } +}) + +// 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') + +describe('Feature Test: SignUp Flow - UI Only', () => { + beforeEach(() => { + mockNavigate.mockClear() + // Reset auth store mocks + const authStore = useAuthStore() + vi.mocked(authStore.setAuth).mockClear() + + // Reset and setup proxy mock with default successful implementation + proxyFetchPostSpy.mockClear() + proxyFetchPostSpy.mockResolvedValue({ + code: 0, + message: 'Registration successful', + }) + }) + + /** + * Test 1: Display signup form + * + * Verifies that users see all essential signup elements: + * - SignUp heading + * - Email input field + * - Password input field + * - Invite code input field (optional) + * - Sign up button + * - Login link + */ + it('displays the signup form with all essential elements', async () => { + render( + + + + ) + + // Verify signup heading - use getAllByText since it appears in both heading and button + const signupElements = screen.getAllByText('layout.sign-up') + expect(signupElements.length).toBeGreaterThan(0) + + // 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 invite code field + const inviteCodeInput = screen.getByPlaceholderText('layout.enter-your-invite-code') + expect(inviteCodeInput).toBeInTheDocument() + expect(inviteCodeInput).toHaveAttribute('type', 'text') + + // Verify signup button + const signupButton = screen.getByRole('button', { name: /layout.sign-up/i }) + expect(signupButton).toBeInTheDocument() + + // Verify login link + const loginButton = screen.getByRole('button', { name: /layout.login/i }) + expect(loginButton).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 signupButton = screen.getByRole('button', { name: /layout.sign-up/i }) + const passwordInput = screen.getByPlaceholderText('layout.enter-your-password') + + // Try to signup with empty email + await user.type(passwordInput, 'password123') + await user.click(signupButton) + + // 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(signupButton) + + // 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 (< 8 characters) + */ + it('validates password input and shows appropriate errors', async () => { + const user = userEvent.setup() + + render( + + + + ) + + const signupButton = screen.getByRole('button', { name: /layout.sign-up/i }) + const emailInput = screen.getByPlaceholderText('layout.enter-your-email') + + // Try to signup with empty password + await user.type(emailInput, 'test@example.com') + await user.click(signupButton) + + // Verify password error appears + await waitFor(() => { + expect(screen.getByText('layout.please-enter-password')).toBeInTheDocument() + }) + + // Try with password too short (less than 8 characters) + const passwordInput = screen.getByPlaceholderText('layout.enter-your-password') + await user.type(passwordInput, '1234567') + await user.click(signupButton) + + // 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: Invite code is optional + * + * Verifies that invite code field is optional: + * - Form can be submitted without invite code + * - No error shown for empty invite code + */ + it('allows signup without invite code', async () => { + const user = userEvent.setup() + + render( + + + + ) + + // Enter valid email and password, but no invite code + 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') + + // Wait for input to be processed + await waitFor(() => { + expect(emailInput).toHaveValue('test@example.com') + expect(passwordInput).toHaveValue('password123') + }) + + // Click signup button + const signupButton = screen.getByRole('button', { name: /layout.sign-up/i }) + await user.click(signupButton) + + // Verify API was called without invite_code being required + await waitFor(() => { + expect(proxyFetchPostSpy).toHaveBeenCalledWith('/api/register', { + email: 'test@example.com', + password: 'password123', + invite_code: '', + }) + }, { timeout: 3000 }) + }) + + /** + * Test 6: Navigation to login page + * + * Verifies that users can navigate to login: + * 1. User clicks the "Login" button + * 2. System navigates to /login route + */ + it('navigates to login page when login button is clicked', async () => { + const user = userEvent.setup() + + render( + + + + ) + + const loginButton = screen.getByRole('button', { name: /layout.login/i }) + await user.click(loginButton) + + // Verify navigation to login page + expect(mockNavigate).toHaveBeenCalledWith('/login') + }) + + /** + * Test 7: 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 signupButton = screen.getByRole('button', { name: /layout.sign-up/i }) + const emailInput = screen.getByPlaceholderText('layout.enter-your-email') + + // Trigger validation error by trying to signup without email + await user.click(signupButton) + + // 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 8: 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() + }) + + /** + * Test 9: Form input values are tracked correctly + * + * Verifies that all form inputs properly track user input: + * - Email input value updates + * - Password input value updates + * - Invite code input value updates + */ + it('tracks form input values correctly', async () => { + const user = userEvent.setup() + + render( + + + + ) + + const emailInput = screen.getByPlaceholderText('layout.enter-your-email') + const passwordInput = screen.getByPlaceholderText('layout.enter-your-password') + const inviteCodeInput = screen.getByPlaceholderText('layout.enter-your-invite-code') + + // Type into all fields + await user.type(emailInput, 'user@example.com') + await user.type(passwordInput, 'securePassword123') + await user.type(inviteCodeInput, 'INVITE2024') + + // Verify all values are tracked + await waitFor(() => { + expect(emailInput).toHaveValue('user@example.com') + expect(passwordInput).toHaveValue('securePassword123') + expect(inviteCodeInput).toHaveValue('INVITE2024') + }) + }) + + /** + * Test 10: Signup button shows loading state + * + * Verifies that the signup button displays loading state: + * - Button shows "Signing up..." text during submission + * - Button is disabled during submission + */ + it('shows loading state on signup button during submission', async () => { + const user = userEvent.setup() + + // Mock API to delay response + proxyFetchPostSpy.mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ code: 0 }), 1000)) + ) + + render( + + + + ) + + const emailInput = screen.getByPlaceholderText('layout.enter-your-email') + const passwordInput = screen.getByPlaceholderText('layout.enter-your-password') + + await user.type(emailInput, 'test@example.com') + await user.type(passwordInput, 'password123') + + const signupButton = screen.getByRole('button', { name: /layout.sign-up/i }) + await user.click(signupButton) + + // Verify button shows loading text + await waitFor(() => { + expect(screen.getByText('layout.signing-up')).toBeInTheDocument() + }) + + // Verify button is disabled + expect(signupButton).toBeDisabled() + }) +}) diff --git a/test/mocks/apiConfig.mock.ts b/test/mocks/apiConfig.mock.ts new file mode 100644 index 00000000..e6b0c70d --- /dev/null +++ b/test/mocks/apiConfig.mock.ts @@ -0,0 +1,119 @@ +import { vi } from 'vitest' + +/** + * Mock for API Configuration + * + * This mock simulates the backend API config endpoints used in Setting/API.tsx + * It handles: + * - Config info retrieval (/api/config/info) + * - Config values retrieval (/api/configs) + * - Config value verification and storage (/api/configs POST) + */ + +export interface ConfigItem { + name: string + env_vars: string[] +} + +export interface ConfigValue { + config_name: string + config_value: string + config_group?: string +} + +// In-memory storage for config values +let configStore: ConfigValue[] = [] + +// Predefined config groups (simulating backend /api/config/info response) +const configInfo = { + 'OpenAI': { + env_vars: ['OPENAI_API_KEY'] + }, + 'Anthropic': { + env_vars: ['ANTHROPIC_API_KEY'] + }, + 'Google': { + env_vars: ['GOOGLE_API_KEY'] + }, + 'Custom API': { + env_vars: ['CUSTOM_API_KEY', 'CUSTOM_API_URL'] + } +} + +/** + * Mock for proxyFetchGet - retrieves config info or config values + */ +export const mockProxyFetchGet = vi.fn((url: string) => { + if (url === '/api/config/info') { + return Promise.resolve(configInfo) + } + + if (url === '/api/configs') { + return Promise.resolve(configStore) + } + + return Promise.resolve(null) +}) + +/** + * Mock for proxyFetchPost - stores and validates config values + */ +export const mockProxyFetchPost = vi.fn((url: string, data?: any) => { + if (url === '/api/configs' && data) { + const { config_name, config_value, config_group } = data + + // Validation: empty values should fail + if (!config_value || !config_value.trim()) { + return Promise.reject(new Error('Config value cannot be empty')) + } + + // Validation: API key format (simple validation) + if (config_name.includes('API_KEY') && config_value.length < 10) { + return Promise.reject(new Error('Invalid API key format')) + } + + // Update or add config value + const existingIndex = configStore.findIndex( + (item) => item.config_name === config_name + ) + + if (existingIndex >= 0) { + configStore[existingIndex] = { config_name, config_value, config_group } + } else { + configStore.push({ config_name, config_value, config_group }) + } + + return Promise.resolve({ code: 0, text: 'Success' }) + } + + return Promise.resolve(null) +}) + +/** + * Reset the mock state + */ +export const resetApiConfigMock = () => { + configStore = [] + mockProxyFetchGet.mockClear() + mockProxyFetchPost.mockClear() +} + +/** + * Get current config store (for testing) + */ +export const getConfigStore = () => [...configStore] + +/** + * Set config store (for testing) + */ +export const setConfigStore = (configs: ConfigValue[]) => { + configStore = [...configs] +} + +/** + * Helper to get a specific config value + */ +export const getConfigValue = (config_name: string): string | null => { + const config = configStore.find((item) => item.config_name === config_name) + return config ? config.config_value : null +} diff --git a/test/mocks/projectStore.mock.ts b/test/mocks/projectStore.mock.ts new file mode 100644 index 00000000..e450c18b --- /dev/null +++ b/test/mocks/projectStore.mock.ts @@ -0,0 +1,301 @@ +import { vi } from 'vitest' + +/** + * Mock Project Store + * + * This mock provides a simplified project store implementation for testing. + * It maintains the same interface as the real store but with controllable behavior. + */ + +// Define ProjectType enum locally to avoid circular dependency +export enum ProjectType { + NORMAL = 'normal', + REPLAY = 'replay' +} + +// Mock project data structure +interface MockProject { + id: string + name: string + description?: string + createdAt: number + updatedAt: number + chatStores: { [chatId: string]: any } + chatStoreTimestamps: { [chatId: string]: number } + activeChatId: string | null + queuedMessages: Array<{ task_id: string; content: string; timestamp: number; attaches: File[] }> + metadata?: { + tags?: string[] + priority?: 'low' | 'medium' | 'high' + status?: 'active' | 'completed' | 'archived' + historyId?: string + } +} + +// Mock state +const mockState = { + activeProjectId: null as string | null, + projects: {} as { [projectId: string]: MockProject }, +} + +// Helper to generate unique IDs +let idCounter = 0 +const generateMockId = () => { + idCounter++ + return `mock-project-${idCounter}` +} + +// Helper to create mock chat store +const createMockChatStore = () => { + const chatIdCounter = Math.random().toString(36).substring(7) + return { + id: `mock-chat-${chatIdCounter}`, + getState: vi.fn(() => ({ + tasks: {}, + activeTaskId: null, + updateCount: 0, + })), + setState: vi.fn(), + subscribe: vi.fn(), + destroy: vi.fn(), + } +} + +// Mock project store implementation +export const mockProjectStore = { + activeProjectId: null as string | null, + projects: {} as { [projectId: string]: MockProject }, + + createProject: vi.fn((name: string, description?: string, projectId?: string, type?: ProjectType, historyId?: string) => { + const id = projectId || generateMockId() + const now = Date.now() + const initialChatId = `chat-${id}` + const initialChatStore = createMockChatStore() + + mockState.projects[id] = { + id, + name, + description, + createdAt: now, + updatedAt: now, + chatStores: { [initialChatId]: initialChatStore }, + chatStoreTimestamps: { [initialChatId]: now }, + activeChatId: initialChatId, + queuedMessages: [], + metadata: { + status: 'active', + historyId, + tags: type === ProjectType.REPLAY ? ['replay'] : [], + }, + } + + mockState.activeProjectId = id + mockProjectStore.activeProjectId = id + mockProjectStore.projects = { ...mockState.projects } + + return id + }), + + setActiveProject: vi.fn((projectId: string) => { + if (mockState.projects[projectId]) { + mockState.activeProjectId = projectId + mockProjectStore.activeProjectId = projectId + mockState.projects[projectId].updatedAt = Date.now() + } + }), + + removeProject: vi.fn((projectId: string) => { + if (mockState.projects[projectId]) { + delete mockState.projects[projectId] + + if (mockState.activeProjectId === projectId) { + const remainingIds = Object.keys(mockState.projects) + mockState.activeProjectId = remainingIds.length > 0 ? remainingIds[0] : null + mockProjectStore.activeProjectId = mockState.activeProjectId + } + + mockProjectStore.projects = { ...mockState.projects } + } + }), + + updateProject: vi.fn((projectId: string, updates: Partial) => { + if (mockState.projects[projectId]) { + mockState.projects[projectId] = { + ...mockState.projects[projectId], + ...updates, + updatedAt: Date.now(), + } + mockProjectStore.projects = { ...mockState.projects } + } + }), + + createChatStore: vi.fn((projectId: string, chatName?: string) => { + const project = mockState.projects[projectId] + if (!project) return null + + const chatId = `chat-${Date.now()}-${Math.random().toString(36).substring(7)}` + const newChatStore = createMockChatStore() + const now = Date.now() + + project.chatStores[chatId] = newChatStore + project.chatStoreTimestamps[chatId] = now + project.activeChatId = chatId + project.updatedAt = now + + return chatId + }), + + setActiveChatStore: vi.fn((projectId: string, chatId: string) => { + const project = mockState.projects[projectId] + if (project && project.chatStores[chatId]) { + project.activeChatId = chatId + project.updatedAt = Date.now() + } + }), + + removeChatStore: vi.fn((projectId: string, chatId: string) => { + const project = mockState.projects[projectId] + if (!project) return + + const chatStoreKeys = Object.keys(project.chatStores) + if (chatStoreKeys.length === 1) return // Don't remove last chat store + + delete project.chatStores[chatId] + + if (project.activeChatId === chatId) { + const remainingChats = chatStoreKeys.filter(id => id !== chatId) + project.activeChatId = remainingChats[0] + } + }), + + getChatStore: vi.fn((projectId?: string, chatId?: string) => { + const targetProjectId = projectId || mockState.activeProjectId + if (!targetProjectId || !mockState.projects[targetProjectId]) return null + + const project = mockState.projects[targetProjectId] + const targetChatId = chatId || project.activeChatId + + if (targetChatId && project.chatStores[targetChatId]) { + return project.chatStores[targetChatId] + } + + return null + }), + + getActiveChatStore: vi.fn((projectId?: string) => { + const targetProjectId = projectId || mockState.activeProjectId + if (!targetProjectId || !mockState.projects[targetProjectId]) return null + + const project = mockState.projects[targetProjectId] + if (project.activeChatId && project.chatStores[project.activeChatId]) { + return project.chatStores[project.activeChatId] + } + + return null + }), + + addQueuedMessage: vi.fn((projectId: string, content: string, attaches: File[]) => { + const project = mockState.projects[projectId] + if (!project) return null + + const taskId = `task-${Date.now()}-${Math.random().toString(36).substring(7)}` + project.queuedMessages.push({ + task_id: taskId, + content, + timestamp: Date.now(), + attaches: [...attaches], + }) + project.updatedAt = Date.now() + + return taskId + }), + + removeQueuedMessage: vi.fn((projectId: string, taskId: string) => { + const project = mockState.projects[projectId] + if (!project) return + + project.queuedMessages = project.queuedMessages.filter(m => m.task_id !== taskId) + project.updatedAt = Date.now() + }), + + clearQueuedMessages: vi.fn((projectId: string) => { + const project = mockState.projects[projectId] + if (!project) return + + project.queuedMessages = [] + project.updatedAt = Date.now() + }), + + getAllProjects: vi.fn(() => { + return Object.values(mockState.projects).sort((a, b) => b.updatedAt - a.updatedAt) + }), + + getProjectById: vi.fn((projectId: string) => { + return mockState.projects[projectId] || null + }), + + getAllChatStores: vi.fn((projectId: string) => { + const project = mockState.projects[projectId] + if (!project) return [] + + return Object.entries(project.chatStores) + .map(([chatId, chatStore]) => ({ + chatId, + chatStore, + createdAt: project.chatStoreTimestamps?.[chatId] || 0, + })) + .sort((a, b) => a.createdAt - b.createdAt) + .map(({ chatId, chatStore }) => ({ chatId, chatStore })) + }), + + getProjectTotalTokens: vi.fn((projectId: string) => { + // Mock implementation returns 0 tokens + return 0 + }), + + setHistoryId: vi.fn((projectId: string, historyId: string) => { + const project = mockState.projects[projectId] + if (!project) return + + project.metadata = { + ...project.metadata, + historyId, + } + project.updatedAt = Date.now() + }), + + getHistoryId: vi.fn((projectId: string | null) => { + if (!projectId || !mockState.projects[projectId]) return null + return mockState.projects[projectId].metadata?.historyId || null + }), + + isEmptyProject: vi.fn((project: MockProject) => { + // Simplified empty check for mock + const chatStoreIds = Object.keys(project.chatStores) + return ( + chatStoreIds.length === 1 && + project.queuedMessages.length === 0 + ) + }), + + // Test utilities + __reset: () => { + idCounter = 0 + mockState.activeProjectId = null + mockState.projects = {} + mockProjectStore.activeProjectId = null + mockProjectStore.projects = {} + vi.clearAllMocks() + }, + + __getState: () => mockState, +} + +// Mock the project store module +export const mockUseProjectStore = vi.fn(() => mockProjectStore) + +// Export for use in vi.mock +export default { + useProjectStore: mockUseProjectStore, + ProjectType, +} diff --git a/test/mocks/proxy.mock.ts b/test/mocks/proxy.mock.ts index 03950ee6..8592d7d4 100644 --- a/test/mocks/proxy.mock.ts +++ b/test/mocks/proxy.mock.ts @@ -10,7 +10,16 @@ const mockImplementation = { }), fetchPut: vi.fn(() => Promise.resolve({ success: true })), getBaseURL: vi.fn(() => Promise.resolve('http://localhost:8000')), - proxyFetchPost: vi.fn((url, data) => { + proxyFetchPost: vi.fn().mockImplementation((url, data) => { + // Mock login + if (url.includes('/api/login')) { + return Promise.resolve({ + code: 0, + token: 'test-token', + username: 'Test User', + user_id: 1, + }) + } // Mock history creation if (url.includes('/api/chat/history')) { return Promise.resolve({ id: 'history-' + Date.now() }) From e707aa2ea7cc2d3e567096b9a5108d8fefbbdb78 Mon Sep 17 00:00:00 2001 From: Saedbhati Date: Mon, 1 Dec 2025 12:18:30 +0530 Subject: [PATCH 2/2] update --- test/feature/agent-qa-interaction.test.tsx | 401 +++++++++ test/feature/api-key-configuration.test.tsx | 884 +++++++++++++------- test/feature/file-attachment.test.tsx | 39 +- test/feature/history-replay.test.tsx | 398 --------- test/feature/language-theme-toggle.test.tsx | 448 ---------- test/feature/mcp-server-config.test.tsx | 702 +++++++--------- test/feature/model-switching.test.tsx | 349 -------- test/feature/task-pause-resume.test.tsx | 501 +++++++++++ 8 files changed, 1810 insertions(+), 1912 deletions(-) create mode 100644 test/feature/agent-qa-interaction.test.tsx delete mode 100644 test/feature/history-replay.test.tsx delete mode 100644 test/feature/language-theme-toggle.test.tsx delete mode 100644 test/feature/model-switching.test.tsx create mode 100644 test/feature/task-pause-resume.test.tsx 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 index 1791a13f..762d9536 100644 --- a/test/feature/api-key-configuration.test.tsx +++ b/test/feature/api-key-configuration.test.tsx @@ -1,363 +1,619 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { - mockProxyFetchGet, - mockProxyFetchPost, - resetApiConfigMock, - getConfigStore, - getConfigValue, - setConfigStore, -} from '../mocks/apiConfig.mock' - -/** - * Feature Test: API Key Configuration - * - * User Journey: User enters API key → Saves → Key is validated and persisted - * - * This test suite validates the API key configuration functionality. - * It focuses on the backend API interactions for storing and validating API keys. - */ - -// Mock the http module -vi.mock('@/api/http', () => ({ - proxyFetchGet: mockProxyFetchGet, - proxyFetchPost: mockProxyFetchPost, +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(), + }), })) -describe('Feature Test: API Key Configuration', () => { + // 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(() => { - // Reset mock state before each test - resetApiConfigMock() + vi.clearAllMocks() }) - /** - * Test 1: Retrieve available config groups - * - * Validates that users can see available API config groups: - * - Fetches config info from backend - * - Returns list of providers with their environment variables - */ - it('retrieves available API configuration groups', async () => { - const configInfo = await mockProxyFetchGet('/api/config/info') - - // Verify config info is returned - expect(configInfo).toBeDefined() - expect(configInfo['OpenAI']).toBeDefined() - expect(configInfo['OpenAI'].env_vars).toContain('OPENAI_API_KEY') - expect(configInfo['Anthropic']).toBeDefined() - expect(configInfo['Anthropic'].env_vars).toContain('ANTHROPIC_API_KEY') + afterEach(() => { + cleanup() }) - /** - * Test 2: Retrieve existing config values - * - * Validates that users can see their saved API keys: - * - Fetches stored config values - * - Returns empty array when no configs saved - */ - it('retrieves existing configuration values', async () => { - // Initially no configs - let configs = await mockProxyFetchGet('/api/configs') - expect(configs).toEqual([]) - - // Add a config - setConfigStore([ - { config_name: 'OPENAI_API_KEY', config_value: 'sk-test123456789' }, - ]) - - // Retrieve configs - configs = await mockProxyFetchGet('/api/configs') - expect(configs.length).toBe(1) - expect(configs[0].config_name).toBe('OPENAI_API_KEY') - expect(configs[0].config_value).toBe('sk-test123456789') - }) + 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() - /** - * Test 3: Save valid API key - * - * Validates that users can save a valid API key: - * - Submits API key to backend - * - Backend validates and stores the key - * - Returns success response - */ - it('saves valid API key successfully', async () => { - const result = await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-validkey123456', - config_group: 'OpenAI', - }) + // No providers configured initially + proxyFetchGet.mockResolvedValueOnce({ items: [] }) - // Verify success response - expect(result.code).toBe(0) - expect(result.text).toBe('Success') + // Validation succeeds + fetchPost.mockResolvedValueOnce({ + is_valid: true, + is_tool_calls: true, + }) - // Verify key was stored - const storedValue = getConfigValue('OPENAI_API_KEY') - expect(storedValue).toBe('sk-validkey123456') - }) + // 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 {} + }) - /** - * Test 4: Reject empty API key - * - * Validates that empty API keys are rejected: - * - Submits empty value - * - Backend returns error - * - Key is not stored - */ - it('rejects empty API key value', async () => { - await expect( - mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: '', - config_group: 'OpenAI', + // 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, + }, + ], }) - ).rejects.toThrow('Config value cannot be empty') - // Verify key was not stored - const storedValue = getConfigValue('OPENAI_API_KEY') - expect(storedValue).toBeNull() - }) + render( + + + + ) - /** - * Test 5: Reject whitespace-only API key - * - * Validates that whitespace-only values are rejected: - * - Submits whitespace value - * - Backend returns error - * - Key is not stored - */ - it('rejects whitespace-only API key value', async () => { - await expect( - mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: ' ', - config_group: 'OpenAI', - }) - ).rejects.toThrow('Config value cannot be empty') + // Wait for component to load and find OpenAI section + await screen.findByText('OpenAI') - // Verify key was not stored - const storedValue = getConfigValue('OPENAI_API_KEY') - expect(storedValue).toBeNull() - }) + // Find the OpenAI section + const openaiSection = screen.getByText('OpenAI').closest('.w-full')! - /** - * Test 6: Validate API key format - * - * Validates that API keys must meet minimum requirements: - * - Short API keys are rejected - * - Valid-length API keys are accepted - */ - it('validates API key format', async () => { - // Too short API key should fail - await expect( - mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-short', - config_group: 'OpenAI', + // 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() }) - ).rejects.toThrow('Invalid API key format') - - // Valid length API key should succeed - const result = await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-validkey123456', - config_group: 'OpenAI', }) - expect(result.code).toBe(0) - expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-validkey123456') - }) + it('prevents saving when required fields are empty', async () => { + const { proxyFetchGet } = await import('../mocks/proxy.mock') + const user = userEvent.setup() - /** - * Test 7: Update existing API key - * - * Validates that users can update their API keys: - * - Saves initial key - * - Updates with new value - * - New value replaces old value - */ - it('updates existing API key', async () => { - // Save initial key - await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-oldkey123456', - config_group: 'OpenAI', - }) + // Fresh empty state + proxyFetchGet.mockResolvedValue({ items: [] }) - expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-oldkey123456') + render( + + + + ) - // Update with new key - await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-newkey789012', - config_group: 'OpenAI', - }) + await screen.findByText('OpenAI') - // Verify new key replaced old key - expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-newkey789012') + // Find the OpenAI section + const openaiSection = screen.getByText('OpenAI').closest('.w-full')! - // Verify only one entry exists - const store = getConfigStore() - const openAIKeys = store.filter((c) => c.config_name === 'OPENAI_API_KEY') - expect(openAIKeys.length).toBe(1) - }) + // 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 - /** - * Test 8: Save multiple API keys - * - * Validates that users can configure multiple providers: - * - Saves OpenAI key - * - Saves Anthropic key - * - Both keys are stored independently - */ - it('saves multiple API keys for different providers', async () => { - // Save OpenAI key - await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-openai123456', - config_group: 'OpenAI', - }) + // 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) - // Save Anthropic key - await mockProxyFetchPost('/api/configs', { - config_name: 'ANTHROPIC_API_KEY', - config_value: 'sk-ant-123456789012', - config_group: 'Anthropic', + // 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() }) - // Verify both keys are stored - expect(getConfigValue('OPENAI_API_KEY')).toBe('sk-openai123456') - expect(getConfigValue('ANTHROPIC_API_KEY')).toBe('sk-ant-123456789012') + it('shows specific error when only API key is missing', async () => { + const { proxyFetchGet } = await import('../mocks/proxy.mock') + const user = userEvent.setup() - // Verify store has 2 entries - const store = getConfigStore() - expect(store.length).toBe(2) - }) + proxyFetchGet.mockResolvedValueOnce({ items: [] }) - /** - * Test 9: Save custom API configuration - * - * Validates that users can configure custom API endpoints: - * - Saves custom API key - * - Saves custom API URL - * - Both values are stored - */ - it('saves custom API configuration with multiple env vars', async () => { - // Save custom API key - await mockProxyFetchPost('/api/configs', { - config_name: 'CUSTOM_API_KEY', - config_value: 'custom-key-123456', - config_group: 'Custom API', - }) + render( + + + + ) - // Save custom API URL (not an API key, so no format validation) - await mockProxyFetchPost('/api/configs', { - config_name: 'CUSTOM_API_URL', - config_value: 'https://api.example.com', - config_group: 'Custom API', - }) + 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') - // Verify both values are stored - expect(getConfigValue('CUSTOM_API_KEY')).toBe('custom-key-123456') - expect(getConfigValue('CUSTOM_API_URL')).toBe('https://api.example.com') + 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() + }) }) - /** - * Test 10: Config persistence - * - * Validates that config values persist: - * - Saves multiple configs - * - Retrieves all configs - * - All values are present - */ - it('persists configuration values across retrievals', async () => { - // Save multiple configs - await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-openai123456', - config_group: 'OpenAI', + 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() }) - await mockProxyFetchPost('/api/configs', { - config_name: 'GOOGLE_API_KEY', - config_value: 'google-key-123456', - config_group: 'Google', + 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() + }) }) - // Retrieve all configs - const configs = await mockProxyFetchGet('/api/configs') + 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( + + + + ) - // Verify all configs are present - expect(configs.length).toBe(2) - expect(configs.some((c: any) => c.config_name === 'OPENAI_API_KEY')).toBe(true) - expect(configs.some((c: any) => c.config_name === 'GOOGLE_API_KEY')).toBe(true) + 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() + }) }) - /** - * Test 11: Config group association - * - * Validates that configs are associated with their groups: - * - Saves config with group - * - Group information is stored - * - Can retrieve by group - */ - it('associates configurations with their config groups', async () => { - await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-openai123456', - config_group: 'OpenAI', + 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() + }) }) - const store = getConfigStore() - const openAIConfig = store.find((c) => c.config_name === 'OPENAI_API_KEY') + 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 }) - expect(openAIConfig).toBeDefined() - expect(openAIConfig!.config_group).toBe('OpenAI') - }) + 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) - /** - * Test 12: Complete configuration workflow - * - * Validates the complete user workflow: - * - Fetch available config groups - * - Check existing values (initially empty) - * - Save new API key - * - Retrieve to verify persistence - * - Update API key - * - Verify update persisted - */ - it('completes full configuration workflow', async () => { - // Step 1: Fetch available config groups - const configInfo = await mockProxyFetchGet('/api/config/info') - expect(configInfo['OpenAI']).toBeDefined() - - // Step 2: Check existing values (should be empty) - let configs = await mockProxyFetchGet('/api/configs') - expect(configs.length).toBe(0) - - // Step 3: Save new API key - const saveResult = await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-initial123456', - config_group: 'OpenAI', + // Verify the API was called to set new preference + await waitFor(() => { + expect(proxyFetchPost).toHaveBeenCalledWith('/api/provider/prefer', { + provider_id: 2, + }) + }) }) - expect(saveResult.code).toBe(0) - - // Step 4: Retrieve to verify persistence - configs = await mockProxyFetchGet('/api/configs') - expect(configs.length).toBe(1) - expect(configs[0].config_value).toBe('sk-initial123456') - - // Step 5: Update API key - const updateResult = await mockProxyFetchPost('/api/configs', { - config_name: 'OPENAI_API_KEY', - config_value: 'sk-updated789012', - config_group: 'OpenAI', + }) + + 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') + }) + } }) - expect(updateResult.code).toBe(0) + }) + + + 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( + + + + ) - // Step 6: Verify update persisted - configs = await mockProxyFetchGet('/api/configs') - expect(configs.length).toBe(1) // Still only one entry - expect(configs[0].config_value).toBe('sk-updated789012') // Updated value + 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 index 27345fa6..1382f91d 100644 --- a/test/feature/file-attachment.test.tsx +++ b/test/feature/file-attachment.test.tsx @@ -1,5 +1,6 @@ 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' @@ -98,7 +99,7 @@ describe('Feature Test: File Attachment', () => { ) // Verify file name is displayed - expect(screen.getByText('test.txt')).toBeTruthy() + expect(screen.getByText('test.txt')).toBeInTheDocument() }) /** @@ -125,9 +126,9 @@ describe('Feature Test: File Attachment', () => { ) // Verify all file names are displayed - expect(screen.getByText('document.pdf')).toBeDefined() - expect(screen.getByText('image.png')).toBeDefined() - expect(screen.getByText('data.csv')).toBeDefined() + expect(screen.getByText('document.pdf')).toBeInTheDocument() + expect(screen.getByText('image.png')).toBeInTheDocument() + expect(screen.getByText('data.csv')).toBeInTheDocument() }) /** @@ -154,14 +155,14 @@ describe('Feature Test: File Attachment', () => { // Find the file chip and hover to reveal X button const fileChip = screen.getByText('test.txt').closest('div') - expect(fileChip).toBeDefined() + 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).toBeDefined() + expect(removeLink).toBeInTheDocument() await user.click(removeLink!) // Verify onFilesChange was called with empty array @@ -229,7 +230,7 @@ describe('Feature Test: File Attachment', () => { ) // Verify file is displayed - expect(screen.getByText('test.txt')).toBeDefined() + expect(screen.getByText('test.txt')).toBeInTheDocument() // Update with message rerender( @@ -243,9 +244,9 @@ describe('Feature Test: File Attachment', () => { // Verify both message and file are present const textarea = screen.getByRole('textbox') - expect(textarea).toBeDefined() + expect(textarea).toBeInTheDocument() expect((textarea as HTMLTextAreaElement).value).toBe('This is a message') - expect(screen.getByText('test.txt')).toBeDefined() + expect(screen.getByText('test.txt')).toBeInTheDocument() }) /** @@ -277,14 +278,14 @@ describe('Feature Test: File Attachment', () => { ) // Verify first 5 files are displayed - expect(screen.getByText('file1.txt')).toBeDefined() - expect(screen.getByText('file2.txt')).toBeDefined() - expect(screen.getByText('file3.txt')).toBeDefined() - expect(screen.getByText('file4.txt')).toBeDefined() - expect(screen.getByText('file5.txt')).toBeDefined() + 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+')).toBeDefined() + expect(screen.getByText('2+')).toBeInTheDocument() }) /** @@ -310,8 +311,8 @@ describe('Feature Test: File Attachment', () => { ) // Both files should be displayed - expect(screen.getByText('photo.jpg')).toBeDefined() - expect(screen.getByText('document.pdf')).toBeDefined() + 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') @@ -440,7 +441,7 @@ describe('Feature Test: File Attachment', () => { ) // Step 3: File appears in list - expect(screen.getByText('test.txt')).toBeDefined() + expect(screen.getByText('test.txt')).toBeInTheDocument() // Step 4: Type message const textarea = screen.getByRole('textbox') @@ -458,7 +459,7 @@ describe('Feature Test: File Attachment', () => { // Verify both message and file are present expect((textarea as HTMLTextAreaElement).value).toBe('Please analyze this file') - expect(screen.getByText('test.txt')).toBeDefined() + expect(screen.getByText('test.txt')).toBeInTheDocument() // Step 5: Remove file const fileChip = screen.getByText('test.txt').closest('div') diff --git a/test/feature/history-replay.test.tsx b/test/feature/history-replay.test.tsx deleted file mode 100644 index bc0b1e36..00000000 --- a/test/feature/history-replay.test.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { mockProjectStore, ProjectType } from '../mocks/projectStore.mock' - -/** - * Feature Test: History Replay - * - * User Journey: User opens history → Selects past project → Replays conversation - * - * This test suite validates the history replay functionality using the mock store. - * It focuses on the replayProject feature and related state management. - */ - -describe('Feature Test: History Replay', () => { - beforeEach(() => { - // Reset mock state before each test - mockProjectStore.__reset() - }) - - /** - * Test 1: Replay project with history ID - * - * Validates that users can replay a project from history: - * - Creates replay project with correct type - * - Sets history ID correctly - * - Project has replay tag in metadata - */ - it('creates replay project with history ID', () => { - const replayProjectId = mockProjectStore.createProject( - 'Replay Project', - 'Replayed from history', - 'replay-123', - ProjectType.REPLAY, - 'history-456' - ) - - // Verify project was created - expect(replayProjectId).toBeDefined() - expect(replayProjectId).toBe('replay-123') - - // Verify project has correct properties - const project = mockProjectStore.getProjectById(replayProjectId) - expect(project).toBeDefined() - expect(project!.name).toBe('Replay Project') - expect(project!.description).toBe('Replayed from history') - - // Verify project has replay metadata - expect(project!.metadata?.tags).toContain('replay') - expect(project!.metadata?.historyId).toBe('history-456') - }) - - /** - * Test 2: History ID storage and retrieval - * - * Validates that history IDs are properly stored and retrieved: - * - Can set history ID for a project - * - Can retrieve history ID for a project - */ - it('stores and retrieves history ID for projects', () => { - // Create a normal project - const projectId = mockProjectStore.createProject('Test Project') - - // Set history ID - mockProjectStore.setHistoryId(projectId, 'history-789') - - // Retrieve history ID - const historyId = mockProjectStore.getHistoryId(projectId) - expect(historyId).toBe('history-789') - - // Verify it's stored in metadata - const project = mockProjectStore.getProjectById(projectId) - expect(project!.metadata?.historyId).toBe('history-789') - }) - - /** - * Test 3: Multiple replay projects - * - * Validates that users can create multiple replay projects: - * - Each replay project is independent - * - Each has its own history ID - * - Replay projects don't interfere with each other - */ - it('creates multiple independent replay projects', () => { - // Create first replay project - const replay1Id = mockProjectStore.createProject( - 'Replay 1', - 'First replay', - 'replay-1', - ProjectType.REPLAY, - 'history-1' - ) - - // Create second replay project - const replay2Id = mockProjectStore.createProject( - 'Replay 2', - 'Second replay', - 'replay-2', - ProjectType.REPLAY, - 'history-2' - ) - - // Verify both projects exist - expect(replay1Id).not.toBe(replay2Id) - - const project1 = mockProjectStore.getProjectById(replay1Id) - const project2 = mockProjectStore.getProjectById(replay2Id) - - // Verify both have replay tags - expect(project1!.metadata?.tags).toContain('replay') - expect(project2!.metadata?.tags).toContain('replay') - - // Verify different history IDs - expect(project1!.metadata?.historyId).toBe('history-1') - expect(project2!.metadata?.historyId).toBe('history-2') - }) - - /** - * Test 4: Replay project is active after creation - * - * Validates that replay project becomes active: - * - Newly created replay project is set as active - * - Can switch between normal and replay projects - */ - it('sets replay project as active after creation', () => { - // Create a normal project - const normalProjectId = mockProjectStore.createProject('Normal Project') - expect(mockProjectStore.activeProjectId).toBe(normalProjectId) - - // Create a replay project - const replayProjectId = mockProjectStore.createProject( - 'Replay Project', - 'Replayed', - 'replay-abc', - ProjectType.REPLAY, - 'history-abc' - ) - - // Verify replay project is now active - expect(mockProjectStore.activeProjectId).toBe(replayProjectId) - - // Verify both projects exist - expect(mockProjectStore.getAllProjects().length).toBe(2) - }) - - /** - * Test 5: Replay project with chat stores - * - * Validates that replay projects can have multiple chat stores: - * - Replay project starts with initial chat store - * - Can create additional chat stores in replay project - * - Chat stores maintain replay context - */ - it('manages chat stores within replay project', () => { - // Create replay project - const replayProjectId = mockProjectStore.createProject( - 'Replay Project', - 'Replayed', - 'replay-def', - ProjectType.REPLAY, - 'history-def' - ) - - // Verify initial chat store exists - const project = mockProjectStore.getProjectById(replayProjectId) - expect(Object.keys(project!.chatStores).length).toBeGreaterThan(0) - - // Create additional chat store - const newChatId = mockProjectStore.createChatStore(replayProjectId) - expect(newChatId).toBeDefined() - - // Verify chat store was created - const updatedProject = mockProjectStore.getProjectById(replayProjectId) - expect(Object.keys(updatedProject!.chatStores).length).toBe(2) - expect(updatedProject!.activeChatId).toBe(newChatId) - }) - - /** - * Test 6: Normal projects don't have replay tag - * - * Validates that normal projects are distinguished from replay projects: - * - Normal projects don't have replay tag - * - Normal projects can have history ID without replay tag - */ - it('distinguishes between normal and replay projects', () => { - // Create normal project - const normalProjectId = mockProjectStore.createProject('Normal Project') - - // Create replay project - const replayProjectId = mockProjectStore.createProject( - 'Replay Project', - 'Replayed', - 'replay-ghi', - ProjectType.REPLAY, - 'history-ghi' - ) - - // Verify normal project doesn't have replay tag - const normalProject = mockProjectStore.getProjectById(normalProjectId) - expect(normalProject!.metadata?.tags).not.toContain('replay') - - // Verify replay project has replay tag - const replayProject = mockProjectStore.getProjectById(replayProjectId) - expect(replayProject!.metadata?.tags).toContain('replay') - }) - - /** - * Test 7: Retrieve history ID returns null for non-existent projects - * - * Validates error handling for history ID retrieval: - * - Returns null for non-existent project - * - Returns null for project without history ID - */ - it('handles missing history ID gracefully', () => { - // Try to get history ID for non-existent project - const historyId1 = mockProjectStore.getHistoryId('non-existent') - expect(historyId1).toBeNull() - - // Create project without history ID - const projectId = mockProjectStore.createProject('Test Project') - const historyId2 = mockProjectStore.getHistoryId(projectId) - - // Should return null or undefined for projects without historyId - expect(historyId2).toBeNull() - }) - - /** - * Test 8: Update history ID for existing project - * - * Validates that history ID can be updated: - * - Can update existing history ID - * - Updated value is persisted - */ - it('updates history ID for existing project', () => { - // Create project with initial history ID - const projectId = mockProjectStore.createProject( - 'Test Project', - 'Description', - 'project-123', - ProjectType.REPLAY, - 'history-old' - ) - - // Verify initial history ID - expect(mockProjectStore.getHistoryId(projectId)).toBe('history-old') - - // Update history ID - mockProjectStore.setHistoryId(projectId, 'history-new') - - // Verify updated history ID - expect(mockProjectStore.getHistoryId(projectId)).toBe('history-new') - - // Verify it's in metadata - const project = mockProjectStore.getProjectById(projectId) - expect(project!.metadata?.historyId).toBe('history-new') - }) - - /** - * Test 9: Replay projects in project list - * - * Validates that replay projects appear in project list: - * - Replay projects are included in getAllProjects - * - Can filter replay projects by tag - */ - it('includes replay projects in project list', () => { - // Create mixed projects - const normal1 = mockProjectStore.createProject('Normal 1') - const replay1 = mockProjectStore.createProject('Replay 1', '', 'replay-1', ProjectType.REPLAY, 'hist-1') - const normal2 = mockProjectStore.createProject('Normal 2') - const replay2 = mockProjectStore.createProject('Replay 2', '', 'replay-2', ProjectType.REPLAY, 'hist-2') - - // Get all projects - const allProjects = mockProjectStore.getAllProjects() - expect(allProjects.length).toBe(4) - - // Filter replay projects - const replayProjects = allProjects.filter(p => p.metadata?.tags?.includes('replay')) - expect(replayProjects.length).toBe(2) - - // Verify replay project IDs - const replayIds = replayProjects.map(p => p.id) - expect(replayIds).toContain(replay1) - expect(replayIds).toContain(replay2) - expect(replayIds).not.toContain(normal1) - expect(replayIds).not.toContain(normal2) - }) - - /** - * Test 10: Remove replay project - * - * Validates that replay projects can be removed: - * - Replay project can be deleted - * - History ID is removed with project - * - Other projects remain unaffected - */ - it('removes replay project and its history ID', () => { - // Create normal and replay projects - const normalId = mockProjectStore.createProject('Normal') - const replayId = mockProjectStore.createProject( - 'Replay', - 'Description', - 'replay-xyz', - ProjectType.REPLAY, - 'history-xyz' - ) - - // Verify both exist - expect(mockProjectStore.getAllProjects().length).toBe(2) - expect(mockProjectStore.getHistoryId(replayId)).toBe('history-xyz') - - // Remove replay project - mockProjectStore.removeProject(replayId) - - // Verify replay project is removed - expect(mockProjectStore.getProjectById(replayId)).toBeNull() - expect(mockProjectStore.getAllProjects().length).toBe(1) - - // Verify normal project still exists - expect(mockProjectStore.getProjectById(normalId)).toBeDefined() - - // Verify can't get history ID for removed project - expect(mockProjectStore.getHistoryId(replayId)).toBeNull() - }) - - /** - * Test 11: Replay project metadata persistence - * - * Validates that replay metadata is properly maintained: - * - Metadata includes status, tags, and historyId - * - Metadata persists through project updates - */ - it('maintains replay metadata through updates', () => { - // Create replay project - const replayId = mockProjectStore.createProject( - 'Replay Project', - 'Original description', - 'replay-persist', - ProjectType.REPLAY, - 'history-persist' - ) - - // Verify initial metadata - let project = mockProjectStore.getProjectById(replayId) - expect(project!.metadata?.tags).toContain('replay') - expect(project!.metadata?.historyId).toBe('history-persist') - expect(project!.metadata?.status).toBe('active') - - // Update project with additional metadata - mockProjectStore.updateProject(replayId, { - name: 'Updated Replay Project', - metadata: { - ...project!.metadata, - priority: 'high', - tags: [...(project!.metadata?.tags || []), 'important'] - } - }) - - // Verify metadata is preserved and updated - project = mockProjectStore.getProjectById(replayId) - expect(project!.name).toBe('Updated Replay Project') - expect(project!.metadata?.tags).toContain('replay') - expect(project!.metadata?.tags).toContain('important') - expect(project!.metadata?.historyId).toBe('history-persist') - expect(project!.metadata?.priority).toBe('high') - }) - - /** - * Test 12: Empty check doesn't affect replay projects - * - * Validates that replay projects are treated appropriately: - * - isEmpty check works for replay projects - * - Replay projects with no messages are considered empty - */ - it('correctly identifies empty replay projects', () => { - // Create replay project - const replayId = mockProjectStore.createProject( - 'Empty Replay', - 'Description', - 'replay-empty', - ProjectType.REPLAY, - 'history-empty' - ) - - // Verify project exists - const project = mockProjectStore.getProjectById(replayId) - expect(project).toBeDefined() - - // Check if project is empty (has only initial chat store, no queued messages) - const isEmpty = mockProjectStore.isEmptyProject(project!) - expect(isEmpty).toBe(true) - - // Add a queued message - mockProjectStore.addQueuedMessage(replayId, 'Test message', []) - - // Verify project is no longer empty - const updatedProject = mockProjectStore.getProjectById(replayId) - const isStillEmpty = mockProjectStore.isEmptyProject(updatedProject!) - expect(isStillEmpty).toBe(false) - }) -}) diff --git a/test/feature/language-theme-toggle.test.tsx b/test/feature/language-theme-toggle.test.tsx deleted file mode 100644 index 90aa2766..00000000 --- a/test/feature/language-theme-toggle.test.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { useAuthStore } from '../../src/store/authStore' - -/** - * Feature Test: Language and Theme Toggle - * - * User Journey: User switches language → UI updates / User switches theme → Styles update - * - * This test suite validates the language and theme switching functionality. - * Users should be able to change the application language and appearance theme. - */ - -describe('Feature Test: Language and Theme Toggle', () => { - beforeEach(() => { - // Reset store to default state - const store = useAuthStore.getState() - store.setLanguage('en') - store.setAppearance('light') - }) - - /** - * Test 1: Default language is English - * - * Validates initial language state: - * - Application starts with English ('en') - * - Language setting is accessible - */ - it('initializes with English language by default', () => { - const { result } = renderHook(() => useAuthStore()) - - expect(result.current.language).toBe('en') - }) - - /** - * Test 2: Default theme is light - * - * Validates initial theme state: - * - Application starts with light theme - * - Theme setting is accessible - */ - it('initializes with light theme by default', () => { - const { result } = renderHook(() => useAuthStore()) - - expect(result.current.appearance).toBe('light') - }) - - /** - * Test 3: Switch language to Chinese - * - * Validates language switching: - * - User changes language from English to Chinese - * - Language setting updates correctly - * - UI text should render in Chinese (tested by i18n) - */ - it('switches language from English to Chinese', () => { - const { result } = renderHook(() => useAuthStore()) - - // Verify starts with English - expect(result.current.language).toBe('en') - - // Switch to Chinese - act(() => { - result.current.setLanguage('zh') - }) - - // Verify language changed - expect(result.current.language).toBe('zh') - }) - - /** - * Test 4: Switch language to Japanese - * - * Validates Japanese language support: - * - Can switch to Japanese ('ja') - * - Language persists - */ - it('switches language to Japanese', () => { - const { result } = renderHook(() => useAuthStore()) - - act(() => { - result.current.setLanguage('ja') - }) - - expect(result.current.language).toBe('ja') - }) - - /** - * Test 5: Switch between multiple languages - * - * Validates language switching flow: - * - English → Chinese → Japanese → English - * - Each switch updates correctly - * - No state corruption - */ - it('switches between multiple languages sequentially', () => { - const { result } = renderHook(() => useAuthStore()) - - // Start with English - expect(result.current.language).toBe('en') - - // Switch to Chinese - act(() => { - result.current.setLanguage('zh') - }) - expect(result.current.language).toBe('zh') - - // Switch to Japanese - act(() => { - result.current.setLanguage('ja') - }) - expect(result.current.language).toBe('ja') - - // Switch back to English - act(() => { - result.current.setLanguage('en') - }) - expect(result.current.language).toBe('en') - }) - - /** - * Test 6: Switch theme to dark mode - * - * Validates dark theme switching: - * - User changes from light to dark theme - * - Appearance setting updates - * - Theme should apply dark styles (tested by CSS) - */ - it('switches theme from light to dark', () => { - const { result } = renderHook(() => useAuthStore()) - - // Verify starts with light - expect(result.current.appearance).toBe('light') - - // Switch to dark - act(() => { - result.current.setAppearance('dark') - }) - - // Verify theme changed - expect(result.current.appearance).toBe('dark') - }) - - /** - * Test 7: Switch theme to system preference - * - * Validates system theme mode: - * - User can select 'system' appearance - * - Theme follows OS preference - */ - it('switches theme to system preference', () => { - const { result } = renderHook(() => useAuthStore()) - - act(() => { - result.current.setAppearance('system') - }) - - expect(result.current.appearance).toBe('system') - }) - - /** - * Test 8: Toggle between all theme options - * - * Validates all theme modes: - * - light → dark → system → light - * - Each mode works correctly - */ - it('cycles through all theme options', () => { - const { result } = renderHook(() => useAuthStore()) - - // Start with light - expect(result.current.appearance).toBe('light') - - // Switch to dark - act(() => { - result.current.setAppearance('dark') - }) - expect(result.current.appearance).toBe('dark') - - // Switch to system - act(() => { - result.current.setAppearance('system') - }) - expect(result.current.appearance).toBe('system') - - // Switch back to light - act(() => { - result.current.setAppearance('light') - }) - expect(result.current.appearance).toBe('light') - }) - - /** - * Test 9: Language and theme are independent - * - * Validates settings independence: - * - Changing language doesn't affect theme - * - Changing theme doesn't affect language - * - Both settings persist separately - */ - it('maintains independence between language and theme settings', () => { - const { result } = renderHook(() => useAuthStore()) - - // Set initial state - act(() => { - result.current.setLanguage('en') - result.current.setAppearance('light') - }) - - expect(result.current.language).toBe('en') - expect(result.current.appearance).toBe('light') - - // Change language, verify theme unchanged - act(() => { - result.current.setLanguage('zh') - }) - - expect(result.current.language).toBe('zh') - expect(result.current.appearance).toBe('light') // Should remain light - - // Change theme, verify language unchanged - act(() => { - result.current.setAppearance('dark') - }) - - expect(result.current.language).toBe('zh') // Should remain zh - expect(result.current.appearance).toBe('dark') - }) - - /** - * Test 10: Settings persist across store access - * - * Validates settings persistence: - * - Multiple components can access same settings - * - Changes in one instance reflect in others - * - State is shared globally - */ - it('persists language and theme settings across store instances', () => { - const { result: result1 } = renderHook(() => useAuthStore()) - - // Set values in first instance - act(() => { - result1.current.setLanguage('ja') - result1.current.setAppearance('dark') - }) - - // Create second instance - const { result: result2 } = renderHook(() => useAuthStore()) - - // Verify second instance has same values - expect(result2.current.language).toBe('ja') - expect(result2.current.appearance).toBe('dark') - - // Change in second instance - act(() => { - result2.current.setLanguage('en') - result2.current.setAppearance('light') - }) - - // Verify first instance is updated - expect(result1.current.language).toBe('en') - expect(result1.current.appearance).toBe('light') - }) - - /** - * Test 11: Supported languages - * - * Validates all supported languages: - * - English (en) - * - Chinese (zh) - * - Japanese (ja) - * - Each language can be selected - */ - it('supports all available languages', () => { - const { result } = renderHook(() => useAuthStore()) - - const supportedLanguages = ['en', 'zh', 'ja'] - - supportedLanguages.forEach(lang => { - act(() => { - result.current.setLanguage(lang) - }) - expect(result.current.language).toBe(lang) - }) - }) - - /** - * Test 12: Supported themes - * - * Validates all supported themes: - * - light - * - dark - * - system - * - Each theme can be selected - */ - it('supports all available themes', () => { - const { result } = renderHook(() => useAuthStore()) - - const supportedThemes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'] - - supportedThemes.forEach(theme => { - act(() => { - result.current.setAppearance(theme) - }) - expect(result.current.appearance).toBe(theme) - }) - }) - - /** - * Test 13: Settings persist after logout - * - * Validates persistence through logout: - * - User sets custom language and theme - * - User logs out - * - Settings remain (not cleared by logout) - */ - it('preserves language and theme settings after logout', () => { - const { result } = renderHook(() => useAuthStore()) - - // Set custom settings - act(() => { - result.current.setLanguage('zh') - result.current.setAppearance('dark') - }) - - expect(result.current.language).toBe('zh') - expect(result.current.appearance).toBe('dark') - - // Logout - act(() => { - result.current.logout() - }) - - // Verify settings are preserved - // (In real implementation, these persist to localStorage) - expect(result.current.language).toBe('zh') - expect(result.current.appearance).toBe('dark') - }) - - /** - * Test 14: Rapid setting changes - * - * Validates handling of rapid changes: - * - Multiple quick language changes - * - Multiple quick theme changes - * - Final state is correct - * - No race conditions - */ - it('handles rapid language and theme changes correctly', () => { - const { result } = renderHook(() => useAuthStore()) - - // Rapid language changes - act(() => { - result.current.setLanguage('en') - result.current.setLanguage('zh') - result.current.setLanguage('ja') - result.current.setLanguage('en') - }) - - // Should end with last value - expect(result.current.language).toBe('en') - - // Rapid theme changes - act(() => { - result.current.setAppearance('light') - result.current.setAppearance('dark') - result.current.setAppearance('system') - result.current.setAppearance('dark') - }) - - // Should end with last value - expect(result.current.appearance).toBe('dark') - }) - - /** - * Test 15: Complete settings workflow - * - * Validates full user journey: - * - Check default settings - * - Change language - * - Change theme - * - Verify both changes persisted - * - Change both again - * - Verify final state - */ - it('completes full language and theme configuration workflow', () => { - const { result } = renderHook(() => useAuthStore()) - - // Step 1: Check defaults - expect(result.current.language).toBe('en') - expect(result.current.appearance).toBe('light') - - // Step 2: Change language to Chinese - act(() => { - result.current.setLanguage('zh') - }) - expect(result.current.language).toBe('zh') - expect(result.current.appearance).toBe('light') // Theme unchanged - - // Step 3: Change theme to dark - act(() => { - result.current.setAppearance('dark') - }) - expect(result.current.language).toBe('zh') // Language unchanged - expect(result.current.appearance).toBe('dark') - - // Step 4: Change both settings - act(() => { - result.current.setLanguage('ja') - result.current.setAppearance('system') - }) - - // Step 5: Verify final state - expect(result.current.language).toBe('ja') - expect(result.current.appearance).toBe('system') - }) -}) - -/** - * Testing Notes: - * - * 1. **Language Support** - * - 'en': English - * - 'zh': Chinese (Simplified) - * - 'ja': Japanese - * - Additional languages can be added as needed - * - * 2. **Theme Options** - * - 'light': Light mode (default) - * - 'dark': Dark mode - * - 'system': Follow OS preference - * - * 3. **Persistence** - * - Settings are stored in localStorage via Zustand persist - * - Settings survive page refresh - * - Settings persist across logout/login - * - * 4. **UI Integration** - * - Language changes trigger i18n re-render - * - Theme changes apply CSS class to root element - * - Settings usually accessed through Settings page - * - * 5. **Real-world Usage** - * - Users access settings through gear icon - * - Language dropdown shows available languages - * - Theme selector shows light/dark/system options - * - Changes apply immediately without page refresh - */ diff --git a/test/feature/mcp-server-config.test.tsx b/test/feature/mcp-server-config.test.tsx index 914e59ba..dee84236 100644 --- a/test/feature/mcp-server-config.test.tsx +++ b/test/feature/mcp-server-config.test.tsx @@ -1,480 +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 adds MCP server → Server appears in list → User can delete it + * 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. - * Users should be able to add, view, and remove MCP servers from their settings. + * 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. */ -interface McpServer { - id: string - name: string - command: string - args?: string[] - env?: Record - enabled: boolean -} +// Mock Electron IPC for MCP operations +let mcpServers: any[] = [] -// Mock MCP store -let mcpServers: McpServer[] = [] +const mockElectronAPI = { + getPlatform: vi.fn(() => 'darwin'), + uploadLog: vi.fn().mockResolvedValue(undefined), +} -// Mock Electron API for MCP operations -const mockMcpList = vi.fn() -const mockMcpAdd = vi.fn() -const mockMcpDelete = vi.fn() -const mockMcpUpdate = vi.fn() -// Setup mocks before tests Object.defineProperty(window, 'electronAPI', { - value: { - getPlatform: vi.fn(() => 'win32'), - mcpList: mockMcpList, - mcpAdd: mockMcpAdd, - mcpDelete: mockMcpDelete, - mcpUpdate: mockMcpUpdate, - }, + value: mockElectronAPI, writable: true, configurable: true, -}); - -(global as any).ipcRenderer = { - invoke: vi.fn((channel, ...args) => { - if (channel === 'mcp-list') return mockMcpList() - if (channel === 'mcp-add') return mockMcpAdd(args[0]) - if (channel === 'mcp-delete') return mockMcpDelete(args[0]) - if (channel === 'mcp-update') return mockMcpUpdate(args[0]) - return Promise.resolve() - }), - on: vi.fn(), - off: vi.fn(), -} - -describe('Feature Test: MCP Server Configuration', () => { - beforeEach(() => { - vi.clearAllMocks() - mcpServers = [] - - // Setup mock implementations - mockMcpList.mockImplementation(() => Promise.resolve(mcpServers)) +}) - mockMcpAdd.mockImplementation((server: McpServer) => { - const newServer = { - ...server, - id: `mcp-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, - enabled: true, + ; (global as any).ipcRenderer = { + invoke: vi.fn((channel, ...args) => { + if (channel === 'mcp-list') { + return Promise.resolve(mcpServers) } - mcpServers.push(newServer) - return Promise.resolve({ success: true, server: newServer }) - }) - - mockMcpDelete.mockImplementation((id: string) => { - const index = mcpServers.findIndex(s => s.id === id) - if (index !== -1) { - mcpServers.splice(index, 1) + 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 }) } - return Promise.resolve({ success: false, error: 'Server not found' }) - }) - - mockMcpUpdate.mockImplementation((server: McpServer) => { - const index = mcpServers.findIndex(s => s.id === server.id) - if (index !== -1) { - mcpServers[index] = { ...mcpServers[index], ...server } - return Promise.resolve({ success: true, server: mcpServers[index] }) + 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 }) } - return Promise.resolve({ success: false, error: 'Server not found' }) - }) - }) + 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(), + } +}) - /** - * Test 1: Retrieve empty MCP server list - * - * Validates initial state: - * - Can fetch MCP server list - * - List is empty initially - * - No errors occur - */ - it('retrieves empty MCP server list initially', async () => { - const result = await window.electronAPI.mcpList() +// 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) => ( +