feat: User facing feature testing

This commit is contained in:
Saedbhati 2025-11-28 17:35:00 +05:30
parent 6f3772ec17
commit df482e980f
12 changed files with 4026 additions and 1 deletions

View file

@ -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
})
})

View file

@ -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<typeof vi.fn>
let mockOnAddFile: ReturnType<typeof vi.fn>
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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={[]}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={[]}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// Verify file is displayed
expect(screen.getByText('test.txt')).toBeDefined()
// Update with message
rerender(
<Inputbox
value="This is a message"
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={files}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={[]}
disabled={true}
/>
)
// 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(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={[]}
privacy={false}
/>
)
// Add file button should be disabled when privacy is false
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toHaveProperty('disabled', true)
// Enable privacy
rerender(
<Inputbox
value=""
onAddFile={mockOnAddFile}
onFilesChange={mockOnFilesChange}
files={[]}
privacy={true}
/>
)
// 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(
<Inputbox
value={currentValue}
onChange={handleValueChange}
onAddFile={mockOnAddFile}
onFilesChange={handleFilesChange}
files={currentFiles}
/>
)
// 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(
<Inputbox
value={currentValue}
onChange={handleValueChange}
onAddFile={mockOnAddFile}
onFilesChange={handleFilesChange}
files={currentFiles}
/>
)
// 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(
<Inputbox
value={currentValue}
onChange={handleValueChange}
onAddFile={mockOnAddFile}
onFilesChange={handleFilesChange}
files={currentFiles}
/>
)
// 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([])
})
})

View file

@ -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)
})
})

View file

@ -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
*/

497
test/feature/login.test.tsx Normal file
View file

@ -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 }) => (
<BrowserRouter>{children}</BrowserRouter>
)
// Create spy on proxyFetchPost
const proxyFetchPostSpy = vi.spyOn(httpModule, 'proxyFetchPost')
let authStore: ReturnType<typeof useAuthStore> // 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(
<TestWrapper>
<Login />
</TestWrapper>
)
// 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(
<TestWrapper>
<Login />
</TestWrapper>
)
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(
<TestWrapper>
<Login />
</TestWrapper>
)
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(
<TestWrapper>
<Login />
</TestWrapper>
)
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(
<TestWrapper>
<Login />
</TestWrapper>
)
// 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(
<TestWrapper>
<Login />
</TestWrapper>
)
// 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(
<TestWrapper>
<Login />
</TestWrapper>
)
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(
<TestWrapper>
<Login />
</TestWrapper>
)
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(
<TestWrapper>
<Login />
</TestWrapper>
)
// 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(
<TestWrapper>
<Login />
</TestWrapper>
)
const privacyLink = screen.getByRole('button', { name: /layout.privacy-policy/i })
expect(privacyLink).toBeInTheDocument()
})
})

View file

@ -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<string, string>
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
*/

View file

@ -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')
})
})

View file

@ -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(
<BrowserRouter>
<HeaderWin />
</BrowserRouter>
);
// 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(
<BrowserRouter>
<HeaderWin />
</BrowserRouter>
);
// 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")
});
});

View file

@ -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 }) => (
<BrowserRouter>{children}</BrowserRouter>
)
// 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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
// 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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
// 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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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(
<TestWrapper>
<SignUp />
</TestWrapper>
)
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()
})
})

View file

@ -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
}

View file

@ -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<MockProject>) => {
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,
}

View file

@ -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() })