mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-30 20:25:33 +00:00
feat: User facing feature testing
This commit is contained in:
parent
6f3772ec17
commit
df482e980f
12 changed files with 4026 additions and 1 deletions
363
test/feature/api-key-configuration.test.tsx
Normal file
363
test/feature/api-key-configuration.test.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
472
test/feature/file-attachment.test.tsx
Normal file
472
test/feature/file-attachment.test.tsx
Normal 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([])
|
||||
})
|
||||
})
|
||||
398
test/feature/history-replay.test.tsx
Normal file
398
test/feature/history-replay.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
448
test/feature/language-theme-toggle.test.tsx
Normal file
448
test/feature/language-theme-toggle.test.tsx
Normal 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
497
test/feature/login.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
||||
480
test/feature/mcp-server-config.test.tsx
Normal file
480
test/feature/mcp-server-config.test.tsx
Normal 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
|
||||
*/
|
||||
349
test/feature/model-switching.test.tsx
Normal file
349
test/feature/model-switching.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
124
test/feature/project-switching.test.tsx
Normal file
124
test/feature/project-switching.test.tsx
Normal 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")
|
||||
});
|
||||
|
||||
});
|
||||
465
test/feature/signup.test.tsx
Normal file
465
test/feature/signup.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
119
test/mocks/apiConfig.mock.ts
Normal file
119
test/mocks/apiConfig.mock.ts
Normal 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
|
||||
}
|
||||
301
test/mocks/projectStore.mock.ts
Normal file
301
test/mocks/projectStore.mock.ts
Normal 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,
|
||||
}
|
||||
|
|
@ -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() })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue