From c6602459e7fbcf9b1e9e9056fdd1aa1f11e8395f Mon Sep 17 00:00:00 2001 From: a7m-1st Date: Fri, 3 Oct 2025 03:52:56 +0300 Subject: [PATCH 1/3] feat: unit test install logic main process --- test/README.md | 423 +++++++++++ test/mocks/electronMocks.ts | 442 ++++++++++++ test/mocks/environmentMocks.ts | 680 ++++++++++++++++++ test/mocks/testUtils.ts | 282 ++++++++ test/setup.ts | 14 + test/unit/electron/install-deps.test.ts | 609 ++++++++++++++++ .../electron/main/domReadyHandlers.test.ts | 539 ++++++++++++++ .../main/installationStateLogic.test.ts | 357 +++++++++ .../electron/main/processUtilsDemo.test.ts | 199 +++++ .../electron/main/windowLifecycle.test.ts | 407 +++++++++++ test/unit/examples/installationFlow.test.ts | 382 ++++++++++ test/unit/hooks/useInstallationSetup.test.ts | 394 ++++++++++ test/unit/store/installationStore.test.ts | 420 +++++++++++ vitest.config.ts | 1 - 14 files changed, 5148 insertions(+), 1 deletion(-) create mode 100644 test/README.md create mode 100644 test/mocks/electronMocks.ts create mode 100644 test/mocks/environmentMocks.ts create mode 100644 test/mocks/testUtils.ts create mode 100644 test/unit/electron/install-deps.test.ts create mode 100644 test/unit/electron/main/domReadyHandlers.test.ts create mode 100644 test/unit/electron/main/installationStateLogic.test.ts create mode 100644 test/unit/electron/main/processUtilsDemo.test.ts create mode 100644 test/unit/electron/main/windowLifecycle.test.ts create mode 100644 test/unit/examples/installationFlow.test.ts create mode 100644 test/unit/hooks/useInstallationSetup.test.ts create mode 100644 test/unit/store/installationStore.test.ts diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..64d2cf8c --- /dev/null +++ b/test/README.md @@ -0,0 +1,423 @@ +# Installation Flow Testing Environment + +This comprehensive testing environment allows you to test all installation flows end-to-end with mocked `uv sync`, `uvicorn`, and Electron APIs. It simulates different system states and provides utilities to change the environment during tests. + +## Overview + +The testing environment consists of three main components: + +1. **Electron API Mocks** (`test/mocks/electronMocks.ts`) - Mock Electron's preload APIs +2. **Environment State Mocks** (`test/mocks/environmentMocks.ts`) - Mock filesystem, processes, and system state +3. **Test Scenarios** - Predefined scenarios for different installation flows + +## Quick Start + +```typescript +import { setupElectronMocks, TestScenarios } from '../mocks/electronMocks' +import { setupMockEnvironment } from '../mocks/environmentMocks' + +describe('My Installation Test', () => { + let electronAPI: MockedElectronAPI + let mockEnv: ReturnType + + beforeEach(() => { + // Set up mocks + const { electronAPI: api } = setupElectronMocks() + electronAPI = api + mockEnv = setupMockEnvironment() + }) + + it('should handle version update', async () => { + // Apply scenario + TestScenarios.versionUpdate(electronAPI) + + // Your test code here + }) +}) +``` + +## Electron API Mocks + +### Available Mock Methods + +- `checkAndInstallDepsOnUpdate()` - Simulates dependency installation +- `getInstallationStatus()` - Returns current installation status +- `exportLog()` - Simulates log export functionality +- Event listeners for installation events + +### Simulation Functions + +```typescript +// Simulate installation events +electronAPI.simulateInstallationStart() +electronAPI.simulateInstallationLog('stdout', 'Installing packages...') +electronAPI.simulateInstallationComplete(true) // or false for failure + +// Simulate system changes +electronAPI.simulateVersionChange('2.0.0') +electronAPI.simulateVenvRemoval() +electronAPI.simulateUvicornStartup() +``` + +### Mock State Control + +```typescript +// Control the mock state directly +electronAPI.mockState.venvExists = false +electronAPI.mockState.isInstalling = true +electronAPI.mockState.toolInstalled = false +``` + +## Environment State Mocks + +### Filesystem Mock + +Controls file system operations: + +```typescript +// Control file existence +mockEnv.mockState.filesystem.venvExists = false +mockEnv.mockState.filesystem.versionFileExists = true +mockEnv.mockState.filesystem.installedLockExists = false + +// Control file contents +mockEnv.mockState.filesystem.versionFileContent = '0.9.0' +``` + +### Process Mock + +Controls process spawning and execution: + +```typescript +// Control tool availability +mockEnv.mockState.processes.uvAvailable = false +mockEnv.mockState.processes.bunAvailable = true +mockEnv.mockState.processes.uvicornRunning = false + +// Control network connectivity +mockEnv.mockState.network.canConnectToDefault = false +mockEnv.mockState.network.canConnectToMirror = true +``` + +## Predefined Test Scenarios + +### Electron API Scenarios + +Use `TestScenarios` from `electronMocks.ts`: + +```typescript +// Fresh installation - no .venv, no version file +TestScenarios.freshInstall(electronAPI) + +// Version update - version file exists but version changed +TestScenarios.versionUpdate(electronAPI) + +// .venv removed - version file exists but .venv is missing +TestScenarios.venvRemoved(electronAPI) + +// Installation in progress - when user opens app during installation +TestScenarios.installationInProgress(electronAPI) + +// Installation error scenario +TestScenarios.installationError(electronAPI) + +// Uvicorn startup with dependency installation +TestScenarios.uvicornDepsInstall(electronAPI) + +// All good - no installation needed +TestScenarios.allGood(electronAPI) +``` + +### Environment Scenarios + +Use `mockEnv.scenarios` from `environmentMocks.ts`: + +```typescript +// Fresh installation +mockEnv.scenarios.freshInstall() + +// Version update +mockEnv.scenarios.versionUpdate('0.9.0', '1.0.0') + +// .venv removed +mockEnv.scenarios.venvRemoved() + +// Network issues +mockEnv.scenarios.networkIssues() + +// Complete failure +mockEnv.scenarios.completeFailure() + +// Uvicorn startup installation +mockEnv.scenarios.uvicornStartupInstall() + +// Installation in progress +mockEnv.scenarios.installationInProgress() +``` + +## Testing Different Installation States + +### Installation Store States + +Test all possible states from `installationStore.ts`: + +- `'idle'` - Initial state +- `'checking-permissions'` - Checking system permissions +- `'showing-carousel'` - Showing onboarding carousel +- `'installing'` - Installation in progress +- `'error'` - Installation failed +- `'completed'` - Installation successful + +```typescript +import { useInstallationStore } from '@/store/installationStore' + +it('should transition through all states', () => { + const store = useInstallationStore.getState() + + expect(store.state).toBe('idle') + + store.startInstallation() + expect(store.state).toBe('installing') + + store.setError('Installation failed') + expect(store.state).toBe('error') + + store.retryInstallation() + expect(store.state).toBe('installing') + + store.setSuccess() + expect(store.state).toBe('completed') +}) +``` + +## Specific Test Cases + +### 1. Testing .venv Removal + +```typescript +it('should handle .venv removal', async () => { + // Simulate .venv being removed + TestScenarios.venvRemoved(electronAPI) + // or + mockEnv.scenarios.venvRemoved() + + // Test your component/hook + const result = await electronAPI.checkAndInstallDepsOnUpdate() + expect(result.success).toBe(true) +}) +``` + +### 2. Testing Version File Changes + +```typescript +it('should handle version file changes', async () => { + // Simulate version change + TestScenarios.versionUpdate(electronAPI) + // or + mockEnv.scenarios.versionUpdate('0.9.0', '1.0.0') + + // Your test assertions +}) +``` + +### 3. Testing Uvicorn Startup Installation + +```typescript +it('should handle uvicorn starting with dependency installation', async () => { + // Simulate uvicorn detecting missing dependencies + TestScenarios.uvicornDepsInstall(electronAPI) + + // Trigger uvicorn startup + electronAPI.simulateUvicornStartup() + + // Wait for installation events + await waitFor(() => { + expect(mockInstallationStore.startInstallation).toHaveBeenCalled() + }) +}) +``` + +### 4. Testing UI Installation States + +```typescript +it('should show correct UI for each installation state', () => { + const { result } = renderHook(() => useInstallationStore()) + + // Test idle state + expect(result.current.state).toBe('idle') + expect(result.current.isVisible).toBe(false) + + // Test installing state + act(() => result.current.startInstallation()) + expect(result.current.state).toBe('installing') + expect(result.current.isVisible).toBe(true) + + // Test error state + act(() => result.current.setError('Installation failed')) + expect(result.current.state).toBe('error') + expect(result.current.error).toBe('Installation failed') + + // Test completed state + act(() => result.current.setSuccess()) + expect(result.current.state).toBe('completed') + expect(result.current.progress).toBe(100) +}) +``` + +## Advanced Testing Patterns + +### Testing Event Sequences + +```typescript +it('should handle complete installation flow', async () => { + const events: string[] = [] + + // Set up event tracking + electronAPI.onInstallDependenciesStart(() => events.push('start')) + electronAPI.onInstallDependenciesLog(() => events.push('log')) + electronAPI.onInstallDependenciesComplete(() => events.push('complete')) + + // Trigger installation + await electronAPI.checkAndInstallDepsOnUpdate() + + // Verify event sequence + expect(events).toEqual(['start', 'log', 'log', 'complete']) +}) +``` + +### Testing Error Recovery + +```typescript +it('should recover from installation errors', async () => { + // Set up error scenario + TestScenarios.installationError(electronAPI) + + const store = useInstallationStore.getState() + + // Trigger installation + await store.performInstallation() + expect(store.state).toBe('error') + + // Simulate retry + TestScenarios.allGood(electronAPI) // Fix the environment + store.retryInstallation() + + await waitFor(() => { + expect(store.state).toBe('completed') + }) +}) +``` + +### Testing Concurrent Operations + +```typescript +it('should handle concurrent installation attempts', async () => { + const store = useInstallationStore.getState() + + // Start multiple installations + const promise1 = store.performInstallation() + const promise2 = store.performInstallation() + + // Should handle gracefully + const [result1, result2] = await Promise.all([promise1, promise2]) + + expect(store.state).toBe('completed') +}) +``` + +## Debugging Tests + +### Logging Mock State + +```typescript +// Log current mock state +console.log('Electron API State:', electronAPI.mockState) +console.log('Environment State:', mockEnv.mockState) + +// Check what functions were called +console.log('checkAndInstallDepsOnUpdate calls:', + electronAPI.checkAndInstallDepsOnUpdate.mock.calls) +``` + +### Waiting for Async Operations + +```typescript +import { waitForStateChange } from '../mocks/environmentMocks' + +// Wait for specific state changes +await waitForStateChange( + () => mockEnv.mockState.processes.uvSyncInProgress, + true, + 1000 // timeout +) +``` + +## Running the Tests + +```bash +# Run all installation tests +npm test test/unit/store/installationStore.test.ts +npm test test/unit/hooks/useInstallationSetup.test.ts +npm test test/unit/electron/install-deps.test.ts + +# Run with coverage +npm test -- --coverage + +# Run in watch mode +npm test -- --watch +``` + +## Common Issues and Solutions + +### 1. Mock Not Applied + +**Problem**: Mock functions not being called +**Solution**: Ensure mocks are set up before importing modules + +```typescript +beforeEach(async () => { + setupMocks() // Set up first + const module = await import('./module') // Import after +}) +``` + +### 2. State Not Updating + +**Problem**: Mock state changes not reflected +**Solution**: Use simulation functions instead of direct state mutation + +```typescript +// Don't do this +electronAPI.mockState.isInstalling = true + +// Do this instead +electronAPI.simulateInstallationStart() +``` + +### 3. Async Operations Not Completing + +**Problem**: Tests timeout waiting for async operations +**Solution**: Use proper wait functions and increase timeouts + +```typescript +await vi.waitFor(() => { + expect(condition).toBe(true) +}, { timeout: 2000 }) +``` + +## Best Practices + +1. **Reset State**: Always reset mock state between tests +2. **Use Scenarios**: Prefer predefined scenarios over manual state setup +3. **Test Edge Cases**: Include error conditions and edge cases +4. **Verify Events**: Check that the correct events are emitted +5. **Test Cleanup**: Verify that resources are properly cleaned up +6. **Integration Tests**: Test the complete flow, not just individual functions + +## Example Test Files + +- `test/unit/store/installationStore.test.ts` - Store state management +- `test/unit/hooks/useInstallationSetup.test.ts` - Hook behavior +- `test/unit/electron/install-deps.test.ts` - Backend installation logic + +These test files demonstrate all the patterns and scenarios described in this README. \ No newline at end of file diff --git a/test/mocks/electronMocks.ts b/test/mocks/electronMocks.ts new file mode 100644 index 00000000..2a4543f1 --- /dev/null +++ b/test/mocks/electronMocks.ts @@ -0,0 +1,442 @@ +import { vi } from 'vitest' + +export interface MockedElectronAPI { + // Mock environment state that can be controlled in tests + mockState: { + venvExists: boolean + versionFileExists: boolean + currentVersion: string + savedVersion: string + isInstalling: boolean + installedLockExists: boolean + uvicornStarting: boolean + toolInstalled: boolean + allowForceInstall: boolean + // Environment-related state + envFileExists: boolean + envContent: string + eigentDirExists: boolean + userEmail: string + mcpRemoteConfigExists: boolean + hasToken: boolean + } + + // Mock implementation functions + checkAndInstallDepsOnUpdate: ReturnType + getInstallationStatus: ReturnType + exportLog: ReturnType + onInstallDependenciesStart: ReturnType + onInstallDependenciesLog: ReturnType + onInstallDependenciesComplete: ReturnType + removeAllListeners: ReturnType + + // EnvUtil mock functions + getEnvPath: ReturnType + updateEnvBlock: ReturnType + removeEnvKey: ReturnType + getEmailFolderPath: ReturnType + parseEnvBlock: ReturnType + + // Test utilities + simulateInstallationStart: () => void + simulateInstallationLog: (type: 'stdout' | 'stderr', data: string) => void + simulateInstallationComplete: (success: boolean, error?: string) => void + simulateVersionChange: (newVersion: string) => void + simulateVenvRemoval: () => void + simulateUvicornStartup: () => void + simulateEnvCorruption: () => void + simulateUserEmailChange: (email: string) => void + simulateMcpConfigMissing: () => void + reset: () => void +} + +export interface MockedIpcRenderer { + invoke: ReturnType + on: ReturnType + removeAllListeners: ReturnType +} + +/** + * Creates a comprehensive mock for the Electron API + * This mock can simulate all the different installation scenarios + */ +export function createElectronAPIMock(): MockedElectronAPI { + // Listeners for simulation + const installStartListeners: Array<() => void> = [] + const installLogListeners: Array<(data: { type: string; data: string }) => void> = [] + const installCompleteListeners: Array<(data: { success: boolean; code?: number; error?: string }) => void> = [] + + const mockState = { + venvExists: true, + versionFileExists: true, + currentVersion: '1.0.0', + savedVersion: '1.0.0', + isInstalling: false, + installedLockExists: true, + uvicornStarting: false, + toolInstalled: true, + allowForceInstall: false, + // Environment-related state + envFileExists: true, + envContent: 'MOCK_VAR=mock_value\n# === MCP INTEGRATION ENV START ===\nMCP_KEY=test_value\n# === MCP INTEGRATION ENV END ===', + eigentDirExists: true, + userEmail: 'test@example.com', + mcpRemoteConfigExists: true, + hasToken: true, + } + + const electronAPI: MockedElectronAPI = { + mockState, + + // Core API functions + checkAndInstallDepsOnUpdate: vi.fn().mockImplementation(async () => { + const { versionFileExists, currentVersion, savedVersion, allowForceInstall, venvExists, toolInstalled } = mockState + + // Simulate the real implementation logic that checks: + // 1. Version file existence and version match + // 2. Virtual environment existence + // 3. Command tools installation status + const versionChanged = !versionFileExists || savedVersion !== currentVersion + const needsInstallation = allowForceInstall || versionChanged || !venvExists || !toolInstalled + + if (needsInstallation) { + // Log the reason for installation + if (!toolInstalled) { + electronAPI.simulateInstallationLog('stdout', 'Command tools missing, starting installation...') + } else if (!venvExists) { + electronAPI.simulateInstallationLog('stdout', 'Virtual environment missing, starting installation...') + } else if (versionChanged) { + electronAPI.simulateInstallationLog('stdout', 'Version changed, starting installation...') + } + + // Trigger installation + electronAPI.simulateInstallationStart() + + // Simulate installation process with delay + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Resolving dependencies...') + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Installing packages...') + setTimeout(() => { + electronAPI.simulateInstallationComplete(true) + // Update state after successful installation + mockState.venvExists = true + mockState.toolInstalled = true + mockState.installedLockExists = true + }, 100) + }, 100) + }, 50) + + return { success: true, message: 'Dependencies installed successfully after update' } + } else { + return { success: true, message: 'Version not changed, venv exists, and tools installed - skipped installation' } + } + }), + + getInstallationStatus: vi.fn().mockImplementation(async () => { + return { + success: true, + isInstalling: mockState.isInstalling, + hasLockFile: mockState.isInstalling || mockState.installedLockExists, + installedExists: mockState.installedLockExists + } + }), + + exportLog: vi.fn().mockImplementation(async () => { + return { + success: true, + savedPath: '/mock/path/to/log.txt' + } + }), + + // Event listeners + onInstallDependenciesStart: vi.fn().mockImplementation((callback: () => void) => { + installStartListeners.push(callback) + }), + + onInstallDependenciesLog: vi.fn().mockImplementation((callback: (data: { type: string; data: string }) => void) => { + installLogListeners.push(callback) + }), + + onInstallDependenciesComplete: vi.fn().mockImplementation((callback: (data: { success: boolean; code?: number; error?: string }) => void) => { + installCompleteListeners.push(callback) + }), + + removeAllListeners: vi.fn().mockImplementation(() => { + installStartListeners.length = 0 + installLogListeners.length = 0 + installCompleteListeners.length = 0 + }), + + // EnvUtil mock functions + getEnvPath: vi.fn().mockImplementation((email: string) => { + const sanitizedEmail = email.split("@")[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(".", "_") + return `/mock/home/.eigent/.env.${sanitizedEmail}` + }), + + updateEnvBlock: vi.fn().mockImplementation((lines: string[], kv: Record) => { + // Mock implementation that adds/updates environment variables in the MCP block + const startMarker = '# === MCP INTEGRATION ENV START ===' + const endMarker = '# === MCP INTEGRATION ENV END ===' + + let start = lines.findIndex(l => l.trim() === startMarker) + let end = lines.findIndex(l => l.trim() === endMarker) + + if (start === -1 || end === -1) { + // No block exists, create one + lines.push(startMarker) + Object.entries(kv).forEach(([k, v]) => { + lines.push(`${k}=${v}`) + }) + lines.push(endMarker) + return lines + } + + // Update existing block + const newBlock = Object.entries(kv).map(([k, v]) => `${k}=${v}`) + return [ + ...lines.slice(0, start + 1), + ...newBlock, + ...lines.slice(end) + ] + }), + + removeEnvKey: vi.fn().mockImplementation((lines: string[], key: string) => { + // Mock implementation that removes a key from the MCP block + const startMarker = '# === MCP INTEGRATION ENV START ===' + const endMarker = '# === MCP INTEGRATION ENV END ===' + + let start = lines.findIndex(l => l.trim() === startMarker) + let end = lines.findIndex(l => l.trim() === endMarker) + + if (start === -1 || end === -1) return lines + + const block = lines.slice(start + 1, end) + const newBlock = block.filter(line => !line.startsWith(key + '=')) + + return [ + ...lines.slice(0, start + 1), + ...newBlock, + ...lines.slice(end) + ] + }), + + getEmailFolderPath: vi.fn().mockImplementation((email: string) => { + const sanitizedEmail = email.split("@")[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(".", "_") + return { + MCP_REMOTE_CONFIG_DIR: `/mock/home/.eigent/${sanitizedEmail}`, + MCP_CONFIG_DIR: '/mock/home/.eigent', + tempEmail: sanitizedEmail, + hasToken: mockState.hasToken + } + }), + + parseEnvBlock: vi.fn().mockImplementation((content: string) => { + const lines = content.split(/\r?\n/) + const startMarker = '# === MCP INTEGRATION ENV START ===' + const endMarker = '# === MCP INTEGRATION ENV END ===' + + let start = lines.findIndex(l => l.trim() === startMarker) + let end = lines.findIndex(l => l.trim() === endMarker) + + if (start === -1) start = lines.length + if (end === -1) end = lines.length + + return { lines, start, end } + }), + + // Simulation utilities + simulateInstallationStart: () => { + mockState.isInstalling = true + installStartListeners.forEach(listener => listener()) + }, + + simulateInstallationLog: (type: 'stdout' | 'stderr', data: string) => { + installLogListeners.forEach(listener => listener({ type, data })) + }, + + simulateInstallationComplete: (success: boolean, error?: string) => { + mockState.isInstalling = false + if (success) { + mockState.installedLockExists = true + } + installCompleteListeners.forEach(listener => + listener({ success, error, code: success ? 0 : 1 }) + ) + }, + + simulateVersionChange: (newVersion: string) => { + mockState.currentVersion = newVersion + // This simulates a version mismatch scenario + }, + + simulateVenvRemoval: () => { + mockState.venvExists = false + mockState.installedLockExists = false + // Don't remove version file - this simulates venv being deleted but version file still existing + }, + + simulateUvicornStartup: () => { + mockState.uvicornStarting = true + // Simulate uvicorn detecting dependency installation need + setTimeout(() => { + electronAPI.simulateInstallationStart() + electronAPI.simulateInstallationLog('stdout', 'Uvicorn detected missing dependencies') + electronAPI.simulateInstallationLog('stdout', 'Resolving dependencies...') + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Uvicorn running on http://127.0.0.1:8000') + electronAPI.simulateInstallationComplete(true) + mockState.uvicornStarting = false + }, 200) + }, 100) + }, + + reset: () => { + Object.assign(mockState, { + venvExists: true, + versionFileExists: true, + currentVersion: '1.0.0', + savedVersion: '1.0.0', + isInstalling: false, + installedLockExists: true, + uvicornStarting: false, + toolInstalled: true, + allowForceInstall: false, + }) + + // Clear all listeners + installStartListeners.length = 0 + installLogListeners.length = 0 + installCompleteListeners.length = 0 + + // Reset all mocks + electronAPI.checkAndInstallDepsOnUpdate.mockClear() + electronAPI.getInstallationStatus.mockClear() + electronAPI.exportLog.mockClear() + electronAPI.onInstallDependenciesStart.mockClear() + electronAPI.onInstallDependenciesLog.mockClear() + electronAPI.onInstallDependenciesComplete.mockClear() + electronAPI.removeAllListeners.mockClear() + } + } + + return electronAPI +} + +/** + * Creates a mock for the IPC Renderer + */ +export function createIpcRendererMock(): MockedIpcRenderer { + return { + invoke: vi.fn().mockImplementation(async (channel: string, ...args: any[]) => { + if (channel === 'check-tool-installed') { + return { + success: true, + isInstalled: true // This can be controlled via the electronAPI mock + } + } + return { success: false, error: 'Unknown channel' } + }), + + on: vi.fn(), + removeAllListeners: vi.fn(), + } +} + +/** + * Test utility to set up all Electron mocks + */ +export function setupElectronMocks() { + const electronAPI = createElectronAPIMock() + const ipcRenderer = createIpcRendererMock() + + // Set up global mocks + Object.defineProperty(window, 'electronAPI', { + value: electronAPI, + writable: true + }) + + Object.defineProperty(window, 'ipcRenderer', { + value: ipcRenderer, + writable: true + }) + + return { electronAPI, ipcRenderer } +} + +/** + * Predefined test scenarios + */ +export const TestScenarios = { + /** + * Fresh installation - no venv, no version file + */ + freshInstall: (electronAPI: MockedElectronAPI) => { + electronAPI.mockState.venvExists = false + electronAPI.mockState.versionFileExists = false + electronAPI.mockState.installedLockExists = false + electronAPI.mockState.toolInstalled = false + }, + + /** + * Version update scenario - version file exists but version changed + */ + versionUpdate: (electronAPI: MockedElectronAPI) => { + electronAPI.mockState.versionFileExists = true + electronAPI.mockState.savedVersion = '0.9.0' + electronAPI.mockState.currentVersion = '1.0.0' + electronAPI.mockState.installedLockExists = false + }, + + /** + * Venv removed scenario - version file exists but .venv is missing + */ + venvRemoved: (electronAPI: MockedElectronAPI) => { + electronAPI.mockState.venvExists = false + electronAPI.mockState.versionFileExists = true + electronAPI.mockState.installedLockExists = false + }, + + /** + * Installation in progress - when user opens app during installation + */ + installationInProgress: (electronAPI: MockedElectronAPI) => { + electronAPI.mockState.isInstalling = true + electronAPI.mockState.installedLockExists = false + }, + + /** + * Installation error scenario + */ + installationError: (electronAPI: MockedElectronAPI) => { + electronAPI.checkAndInstallDepsOnUpdate.mockImplementation(async () => { + electronAPI.simulateInstallationStart() + setTimeout(() => { + electronAPI.simulateInstallationLog('stderr', 'Error: Failed to resolve dependencies') + electronAPI.simulateInstallationComplete(false, 'Installation failed') + }, 100) + return { success: false, message: 'Installation failed' } + }) + }, + + /** + * Uvicorn startup with dependency installation + */ + uvicornDepsInstall: (electronAPI: MockedElectronAPI) => { + electronAPI.mockState.uvicornStarting = true + electronAPI.mockState.isInstalling = false + // The simulateUvicornStartup method will handle the rest + }, + + /** + * All good - no installation needed + */ + allGood: (electronAPI: MockedElectronAPI) => { + electronAPI.mockState.venvExists = true + electronAPI.mockState.versionFileExists = true + electronAPI.mockState.savedVersion = electronAPI.mockState.currentVersion + electronAPI.mockState.installedLockExists = true + electronAPI.mockState.isInstalling = false + electronAPI.mockState.toolInstalled = true + } +} \ No newline at end of file diff --git a/test/mocks/environmentMocks.ts b/test/mocks/environmentMocks.ts new file mode 100644 index 00000000..3a2d599a --- /dev/null +++ b/test/mocks/environmentMocks.ts @@ -0,0 +1,680 @@ +import { vi } from 'vitest' + +/** + * Environment state management for testing installation flows + * This module provides utilities to simulate different system states + */ + +export interface MockEnvironmentState { + filesystem: { + venvExists: boolean + versionFileExists: boolean + versionFileContent: string + installingLockExists: boolean + installedLockExists: boolean + backendPathExists: boolean + pyprojectExists: boolean + // New fields for process.ts functions + eigentDirExists: boolean + eigentBinDirExists: boolean + eigentCacheDirExists: boolean + eigentVenvsDirExists: boolean + eigentRuntimeDirExists: boolean + resourcesDirExists: boolean + binariesExist: { [name: string]: boolean } + oldVenvsExist: string[] // List of old venv directories that exist + } + processes: { + uvAvailable: boolean + bunAvailable: boolean + uvicornRunning: boolean + uvSyncInProgress: boolean + installationInProgress: boolean + } + app: { + currentVersion: string + userData: string + appPath: string + isPackaged: boolean + resourcesPath: string + } + system: { + platform: 'win32' | 'darwin' | 'linux' + homedir: string + } + network: { + canConnectToMirror: boolean + canConnectToDefault: boolean + } +} + +/** + * Mock implementations for Node.js fs module + */ +export function createFileSystemMock() { + const mockState: MockEnvironmentState = { + filesystem: { + venvExists: true, + versionFileExists: true, + versionFileContent: '1.0.0', + installingLockExists: false, + installedLockExists: true, + backendPathExists: true, + pyprojectExists: true, + eigentDirExists: true, + eigentBinDirExists: true, + eigentCacheDirExists: true, + eigentVenvsDirExists: true, + eigentRuntimeDirExists: true, + resourcesDirExists: true, + binariesExist: { 'uv': true, 'bun': true }, + oldVenvsExist: [] + }, + processes: { + uvAvailable: true, + bunAvailable: true, + uvicornRunning: false, + uvSyncInProgress: false, + installationInProgress: false, + }, + app: { + currentVersion: '1.0.0', + userData: '/mock/user/data', + appPath: '/mock/app/path', + isPackaged: false, + resourcesPath: '/mock/resources/path' + }, + system: { + platform: 'win32', + homedir: '/mock/home' + }, + network: { + canConnectToMirror: true, + canConnectToDefault: true, + } + } + + const fsMock = { + existsSync: vi.fn().mockImplementation((path: string) => { + if (!path || typeof path !== 'string') return false + if (path.includes('version.txt')) return mockState.filesystem.versionFileExists + if (path.includes('uv_installing.lock')) return mockState.filesystem.installingLockExists + if (path.includes('uv_installed.lock')) return mockState.filesystem.installedLockExists + if (path.includes('.venv')) return mockState.filesystem.venvExists + if (path.includes('backend')) return mockState.filesystem.backendPathExists + if (path.includes('pyproject.toml')) return mockState.filesystem.pyprojectExists + if (path.includes('.eigent/bin') || path.includes('.eigent\\bin')) return mockState.filesystem.eigentBinDirExists + if (path.includes('.eigent/cache') || path.includes('.eigent\\cache')) return mockState.filesystem.eigentCacheDirExists + if (path.includes('.eigent/venvs') || path.includes('.eigent\\venvs')) return mockState.filesystem.eigentVenvsDirExists + if (path.includes('.eigent/runtime') || path.includes('.eigent\\runtime')) return mockState.filesystem.eigentRuntimeDirExists + if (path.includes('.eigent') && !path.includes('bin') && !path.includes('cache') && !path.includes('venvs') && !path.includes('runtime')) { + return mockState.filesystem.eigentDirExists + } + if (path.includes('resources')) return mockState.filesystem.resourcesDirExists + // Check for specific binaries + for (const [name, exists] of Object.entries(mockState.filesystem.binariesExist)) { + if (path.includes(name + '.exe') || path.endsWith(name)) { + return exists + } + } + // Check for old venv directories + for (const oldVenv of mockState.filesystem.oldVenvsExist) { + if (path.includes(oldVenv)) return true + } + return true + }), + + readFileSync: vi.fn().mockImplementation((path: string, encoding?: string) => { + if (!path || typeof path !== 'string') return '' + if (path.includes('version.txt')) { + return mockState.filesystem.versionFileContent + } + if (path.includes('pyproject.toml')) { + return ` +[project] +name = "backend" +version = "1.0.0" +dependencies = ["fastapi", "uvicorn"] + ` + } + return '' + }), + + writeFileSync: vi.fn().mockImplementation((path: string, content: string) => { + if (!path || typeof path !== 'string') return + if (path.includes('version.txt')) { + mockState.filesystem.versionFileContent = content + mockState.filesystem.versionFileExists = true + } else if (path.includes('uv_installing.lock')) { + mockState.filesystem.installingLockExists = true + } else if (path.includes('uv_installed.lock')) { + mockState.filesystem.installedLockExists = true + } + }), + + unlinkSync: vi.fn().mockImplementation((path: string) => { + if (!path || typeof path !== 'string') return + if (path.includes('uv_installing.lock')) { + mockState.filesystem.installingLockExists = false + } else if (path.includes('uv_installed.lock')) { + mockState.filesystem.installedLockExists = false + } else if (path.includes('version.txt')) { + mockState.filesystem.versionFileExists = false + } + }), + + mkdirSync: vi.fn().mockImplementation((path: string, options?: any) => { + if (!path || typeof path !== 'string') return + if (path.includes('backend')) { + mockState.filesystem.backendPathExists = true + } else if (path.includes('.eigent/bin') || path.includes('.eigent\\bin')) { + mockState.filesystem.eigentBinDirExists = true + } else if (path.includes('.eigent/cache') || path.includes('.eigent\\cache')) { + mockState.filesystem.eigentCacheDirExists = true + } else if (path.includes('.eigent/venvs') || path.includes('.eigent\\venvs')) { + mockState.filesystem.eigentVenvsDirExists = true + } else if (path.includes('.eigent/runtime') || path.includes('.eigent\\runtime')) { + mockState.filesystem.eigentRuntimeDirExists = true + } else if (path.includes('.eigent')) { + mockState.filesystem.eigentDirExists = true + } + }), + + rmSync: vi.fn().mockImplementation((path: string, options?: any) => { + if (!path || typeof path !== 'string') return + // Handle cleanup of old venvs + for (let i = 0; i < mockState.filesystem.oldVenvsExist.length; i++) { + if (path.includes(mockState.filesystem.oldVenvsExist[i])) { + mockState.filesystem.oldVenvsExist.splice(i, 1) + break + } + } + }), + + readdirSync: vi.fn().mockImplementation((path: string, options?: any) => { + if (!path || typeof path !== 'string') return [] + if (path.includes('.eigent/venvs')) { + // Return old venv directories for cleanup testing + return mockState.filesystem.oldVenvsExist.map(venv => ({ + name: venv, + isDirectory: () => true + })) + } + return [] + }), + + // State control methods + mockState, + + reset: () => { + Object.assign(mockState, { + filesystem: { + venvExists: true, + versionFileExists: true, + versionFileContent: '1.0.0', + installingLockExists: false, + installedLockExists: true, + backendPathExists: true, + pyprojectExists: true, + eigentDirExists: true, + eigentBinDirExists: true, + eigentCacheDirExists: true, + eigentVenvsDirExists: true, + eigentRuntimeDirExists: true, + resourcesDirExists: true, + binariesExist: { 'uv': true, 'bun': true }, + oldVenvsExist: [] + }, + processes: { + uvAvailable: true, + bunAvailable: true, + uvicornRunning: false, + uvSyncInProgress: false, + installationInProgress: false, + }, + app: { + currentVersion: '1.0.0', + userData: '/mock/user/data', + appPath: '/mock/app/path', + isPackaged: false, + resourcesPath: '/mock/resources/path' + }, + system: { + platform: 'win32', + homedir: '/mock/home' + }, + network: { + canConnectToMirror: true, + canConnectToDefault: true, + } + }) + } + } + + return fsMock +} + +/** + * Mock implementations for child_process spawn + */ +export function createProcessMock() { + const processMock = { + spawn: vi.fn(), + mockState: {} as MockEnvironmentState, + + setupSpawnMock: (mockState: MockEnvironmentState) => { + processMock.mockState = mockState + + processMock.spawn.mockImplementation((command: string, args: string[], options: any) => { + // Mock process events + const mockProcess = { + stdout: { + on: vi.fn().mockImplementation((event: string, callback: (data: Buffer) => void) => { + if (event === 'data') { + // Simulate different process outputs based on command + setTimeout(() => { + if (command.includes('uv') && args.includes('sync')) { + mockState.processes.uvSyncInProgress = true + callback(Buffer.from('Resolved 10 packages in 1.2s\n')) + setTimeout(() => { + callback(Buffer.from('Installing packages...\n')) + setTimeout(() => { + callback(Buffer.from('Installation complete\n')) + mockState.processes.uvSyncInProgress = false + }, 100) + }, 50) + } else if (command.includes('uvicorn')) { + mockState.processes.uvicornRunning = true + callback(Buffer.from('Uvicorn running on http://127.0.0.1:8000\n')) + } + }, 10) + } + }) + }, + stderr: { + on: vi.fn().mockImplementation((event: string, callback: (data: Buffer) => void) => { + if (event === 'data') { + // Simulate error scenarios + if (!mockState.processes.uvAvailable && command.includes('uv')) { + setTimeout(() => { + callback(Buffer.from('uv: command not found\n')) + }, 10) + } + } + }) + }, + on: vi.fn().mockImplementation((event: string, callback: (code: number) => void) => { + if (event === 'close') { + setTimeout(() => { + if (command.includes('uv') && args.includes('sync')) { + const exitCode = mockState.processes.uvAvailable && + mockState.network.canConnectToDefault ? 0 : 1 + callback(exitCode) + } else { + callback(0) + } + }, 150) + } + }), + kill: vi.fn() + } + + return mockProcess + }) + }, + + reset: () => { + processMock.spawn.mockReset() + } + } + + return processMock +} + +/** + * Mock for Electron app module + */ +export function createElectronAppMock() { + const appMock = { + getVersion: vi.fn(), + getPath: vi.fn(), + getAppPath: vi.fn(), + isPackaged: false, + mockState: {} as MockEnvironmentState, + + setup: (mockState: MockEnvironmentState) => { + appMock.mockState = mockState + appMock.getVersion.mockReturnValue(mockState.app.currentVersion) + appMock.getAppPath.mockReturnValue(mockState.app.appPath) + appMock.isPackaged = mockState.app.isPackaged + appMock.getPath.mockImplementation((name: string) => { + if (name === 'userData') return mockState.app.userData + return '/mock/path' + }) + + // Mock process.resourcesPath for packaged apps + if (mockState.app.isPackaged) { + Object.defineProperty(process, 'resourcesPath', { + value: mockState.app.resourcesPath, + configurable: true + }) + } + }, + + reset: () => { + appMock.getVersion.mockReset() + appMock.getPath.mockReset() + appMock.getAppPath.mockReset() + } + } + + return appMock +} + +/** + * Mock for OS module + */ +export function createOsMock() { + const osMock = { + homedir: vi.fn().mockReturnValue('/mock/home'), + mockState: {} as MockEnvironmentState, + + setup: (mockState: MockEnvironmentState) => { + osMock.mockState = mockState + osMock.homedir.mockReturnValue(mockState.system.homedir || '/mock/home') + }, + + reset: () => { + osMock.homedir.mockReset() + osMock.homedir.mockReturnValue('/mock/home') + } + } + + return osMock +} + +/** + * Mock for path module + */ +export function createPathMock() { + return { + join: vi.fn((...args) => { + const validArgs = args.filter(arg => arg != null && arg !== undefined && arg !== '') + return validArgs.length > 0 ? validArgs.join(process.platform === 'win32' ? '\\' : '/') : '' + }), + resolve: vi.fn((...args) => { + const validArgs = args.filter(arg => arg != null && arg !== undefined && arg !== '') + return validArgs.length > 0 ? validArgs.join(process.platform === 'win32' ? '\\' : '/') : '' + }), + dirname: vi.fn((path: string) => { + if (!path || typeof path !== 'string') return '' + const parts = path.split(process.platform === 'win32' ? '\\' : '/') + return parts.slice(0, -1).join(process.platform === 'win32' ? '\\' : '/') + }) + } +} + +/** + * Mock for process utilities from electron/main/utils/process.ts + */ +export function createProcessUtilsMock() { + const utilsMock = { + getResourcePath: vi.fn(), + getBackendPath: vi.fn(), + runInstallScript: vi.fn(), + getBinaryName: vi.fn(), + getBinaryPath: vi.fn(), + getCachePath: vi.fn(), + getVenvPath: vi.fn(), + getVenvsBaseDir: vi.fn(), + cleanupOldVenvs: vi.fn(), + isBinaryExists: vi.fn(), + mockState: {} as MockEnvironmentState, + + setup: (mockState: MockEnvironmentState) => { + utilsMock.mockState = mockState + + utilsMock.getResourcePath.mockReturnValue( + `${mockState.app.appPath}/resources` + ) + + utilsMock.getBackendPath.mockReturnValue( + mockState.app.isPackaged + ? `${mockState.app.resourcesPath}/backend` + : `${mockState.app.appPath}/backend` + ) + + utilsMock.runInstallScript.mockImplementation(async (scriptPath: string) => { + // Simulate successful script execution by default + return true + }) + + utilsMock.getBinaryName.mockImplementation(async (name: string) => { + return mockState.system.platform === 'win32' ? `${name}.exe` : name + }) + + utilsMock.getBinaryPath.mockImplementation(async (name?: string) => { + const binDir = `${mockState.system.homedir}/.eigent/bin` + if (!name) return binDir + const binaryName = mockState.system.platform === 'win32' ? `${name}.exe` : name + return `${binDir}/${binaryName}` + }) + + utilsMock.getCachePath.mockImplementation((folder: string) => { + return `${mockState.system.homedir}/.eigent/cache/${folder}` + }) + + utilsMock.getVenvPath.mockImplementation((version: string) => { + return `${mockState.system.homedir}/.eigent/venvs/backend-${version}` + }) + + utilsMock.getVenvsBaseDir.mockReturnValue( + `${mockState.system.homedir}/.eigent/venvs` + ) + + utilsMock.cleanupOldVenvs.mockImplementation(async (currentVersion: string) => { + // Simulate cleanup by removing old venvs from mock state + mockState.filesystem.oldVenvsExist = mockState.filesystem.oldVenvsExist.filter( + venv => venv.includes(`backend-${currentVersion}`) + ) + }) + + utilsMock.isBinaryExists.mockImplementation(async (name: string) => { + return mockState.filesystem.binariesExist[name] || false + }) + }, + + reset: () => { + Object.values(utilsMock).forEach(fn => { + if (typeof fn === 'function' && 'mockReset' in fn) { + fn.mockReset() + } + }) + } + } + + return utilsMock +} + +/** + * Mock for electron-log + */ +export function createLogMock() { + return { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + } +} + +/** + * Complete environment setup for testing + * Note: vi.mock calls should be done at the top level of test files, not here + */ +export function setupMockEnvironment() { + const fsMock = createFileSystemMock() + const processMock = createProcessMock() + const appMock = createElectronAppMock() + const osMock = createOsMock() + const pathMock = createPathMock() + const processUtilsMock = createProcessUtilsMock() + const logMock = createLogMock() + + // Set up the shared state + processMock.setupSpawnMock(fsMock.mockState) + appMock.setup(fsMock.mockState) + osMock.setup(fsMock.mockState) + processUtilsMock.setup(fsMock.mockState) + + return { + fsMock, + processMock, + appMock, + osMock, + pathMock, + processUtilsMock, + logMock, + mockState: fsMock.mockState, + + // Utility functions for test scenarios + scenarios: { + freshInstall: () => { + fsMock.mockState.filesystem.venvExists = false + fsMock.mockState.filesystem.versionFileExists = false + fsMock.mockState.filesystem.installedLockExists = false + fsMock.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + fsMock.mockState.processes.uvAvailable = false + fsMock.mockState.processes.bunAvailable = false + }, + + versionUpdate: (oldVersion: string, newVersion: string) => { + fsMock.mockState.filesystem.versionFileContent = oldVersion + fsMock.mockState.app.currentVersion = newVersion + appMock.getVersion.mockReturnValue(newVersion) + }, + + venvRemoved: () => { + fsMock.mockState.filesystem.venvExists = false + fsMock.mockState.filesystem.installedLockExists = false + }, + + networkIssues: () => { + fsMock.mockState.network.canConnectToDefault = false + fsMock.mockState.network.canConnectToMirror = true + }, + + completeFailure: () => { + fsMock.mockState.network.canConnectToDefault = false + fsMock.mockState.network.canConnectToMirror = false + fsMock.mockState.processes.uvAvailable = false + fsMock.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + + // Note: installCommandTool is defined in the install-deps module, + // not in process utils, so it should be mocked in the test itself + }, + + uvicornStartupInstall: () => { + fsMock.mockState.processes.uvicornRunning = false + fsMock.mockState.filesystem.installedLockExists = false + // Uvicorn will detect missing deps and start installation + }, + + installationInProgress: () => { + fsMock.mockState.filesystem.installingLockExists = true + fsMock.mockState.processes.installationInProgress = true + }, + + // New scenarios for process.ts testing + packagedApp: () => { + fsMock.mockState.app.isPackaged = true + appMock.isPackaged = true + }, + + multipleOldVenvs: (currentVersion: string) => { + fsMock.mockState.filesystem.oldVenvsExist = [ + 'backend-0.9.0', + 'backend-0.9.5', + 'backend-1.0.1-beta', + `backend-${currentVersion}` // This should not be cleaned up + ] + }, + + macOSEnvironment: () => { + fsMock.mockState.system.platform = 'darwin' + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + }, + + linuxEnvironment: () => { + fsMock.mockState.system.platform = 'linux' + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + }, + + missingEigentDirectories: () => { + fsMock.mockState.filesystem.eigentDirExists = false + fsMock.mockState.filesystem.eigentBinDirExists = false + fsMock.mockState.filesystem.eigentCacheDirExists = false + fsMock.mockState.filesystem.eigentVenvsDirExists = false + fsMock.mockState.filesystem.eigentRuntimeDirExists = false + } + }, + + reset: () => { + fsMock.reset() + processMock.reset() + appMock.reset() + osMock.reset() + processUtilsMock.reset() + + // Reset process.platform to original + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }) + } + } +} + +/** + * Factory functions for creating mocks that can be used in vi.mock calls + * These should be called at the top level of test files + */ +export function createMockFactories() { + return { + fs: () => createFileSystemMock(), + childProcess: () => createProcessMock(), + os: () => ({ default: createOsMock() }), + path: () => ({ default: createPathMock() }), + electron: () => ({ + app: createElectronAppMock(), + BrowserWindow: vi.fn() + }), + electronLog: () => ({ default: createLogMock() }), + processUtils: () => createProcessUtilsMock() + } +} + +/** + * Test utility to wait for async state changes + */ +export function waitForStateChange( + stateGetter: () => T, + expectedValue: T, + timeout: number = 1000 +): Promise { + return new Promise((resolve, reject) => { + const startTime = Date.now() + + const check = () => { + if (stateGetter() === expectedValue) { + resolve() + } else if (Date.now() - startTime > timeout) { + reject(new Error(`Timeout waiting for state change. Expected: ${expectedValue}, got: ${stateGetter()}`)) + } else { + setTimeout(check, 10) + } + } + + check() + }) +} \ No newline at end of file diff --git a/test/mocks/testUtils.ts b/test/mocks/testUtils.ts new file mode 100644 index 00000000..d6282d8b --- /dev/null +++ b/test/mocks/testUtils.ts @@ -0,0 +1,282 @@ +import { setupElectronMocks, TestScenarios, type MockedElectronAPI } from './electronMocks' +import { setupMockEnvironment } from './environmentMocks' + +/** + * Complete test setup utility that combines all mocks and provides + * easy-to-use functions for testing installation flows + */ +export function createTestEnvironment() { + const { electronAPI, ipcRenderer } = setupElectronMocks() + const mockEnv = setupMockEnvironment() + + return { + electronAPI, + ipcRenderer, + mockEnv, + + // Quick scenario setups + scenarios: { + /** + * Fresh installation - no .venv, no version file, tools not installed + */ + freshInstall: () => { + TestScenarios.freshInstall(electronAPI) + mockEnv.scenarios.freshInstall() + }, + + /** + * Version update - version file exists but version changed + */ + versionUpdate: (oldVersion: string = '0.9.0', newVersion: string = '1.0.0') => { + TestScenarios.versionUpdate(electronAPI) + mockEnv.scenarios.versionUpdate(oldVersion, newVersion) + }, + + /** + * .venv removed - version file exists but .venv is missing + */ + venvRemoved: () => { + TestScenarios.venvRemoved(electronAPI) + mockEnv.scenarios.venvRemoved() + }, + + /** + * Installation in progress - when user opens app during installation + */ + installationInProgress: () => { + TestScenarios.installationInProgress(electronAPI) + mockEnv.scenarios.installationInProgress() + }, + + /** + * Installation error - installation fails + */ + installationError: () => { + TestScenarios.installationError(electronAPI) + mockEnv.scenarios.completeFailure() + }, + + /** + * Uvicorn startup with dependency installation + */ + uvicornDepsInstall: () => { + TestScenarios.uvicornDepsInstall(electronAPI) + mockEnv.scenarios.uvicornStartupInstall() + }, + + /** + * Network issues - default mirror fails, backup succeeds + */ + networkIssues: () => { + TestScenarios.allGood(electronAPI) + mockEnv.scenarios.networkIssues() + }, + + /** + * All good - no installation needed + */ + allGood: () => { + TestScenarios.allGood(electronAPI) + // Use default mockEnv state (all good) + } + }, + + // Simulation utilities + simulate: { + /** + * Simulate a complete successful installation flow + */ + successfulInstallation: async (delay: number = 100) => { + electronAPI.simulateInstallationStart() + + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Resolving dependencies...') + }, delay) + + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Downloading packages...') + }, delay * 2) + + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Installing packages...') + }, delay * 3) + + setTimeout(() => { + electronAPI.simulateInstallationComplete(true) + }, delay * 4) + }, + + /** + * Simulate a failed installation flow + */ + failedInstallation: async (delay: number = 100, errorMessage: string = 'Installation failed') => { + electronAPI.simulateInstallationStart() + + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Resolving dependencies...') + }, delay) + + setTimeout(() => { + electronAPI.simulateInstallationLog('stderr', `Error: ${errorMessage}`) + }, delay * 2) + + setTimeout(() => { + electronAPI.simulateInstallationComplete(false, errorMessage) + }, delay * 3) + }, + + /** + * Simulate uvicorn startup that detects missing dependencies + */ + uvicornWithDeps: async (delay: number = 100) => { + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Uvicorn detected missing dependencies') + }, delay) + + setTimeout(() => { + electronAPI.simulateInstallationStart() + }, delay * 2) + + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Resolving dependencies...') + }, delay * 3) + + setTimeout(() => { + electronAPI.simulateInstallationLog('stdout', 'Uvicorn running on http://127.0.0.1:8000') + electronAPI.simulateInstallationComplete(true) + }, delay * 4) + } + }, + + // State inspection utilities + inspect: { + /** + * Get current installation state summary + */ + getInstallationState: () => ({ + electronState: electronAPI.mockState, + envState: mockEnv.mockState, + isInstalling: electronAPI.mockState.isInstalling || mockEnv.mockState.processes.installationInProgress, + hasLockFiles: mockEnv.mockState.filesystem.installingLockExists || mockEnv.mockState.filesystem.installedLockExists, + toolsAvailable: mockEnv.mockState.processes.uvAvailable && mockEnv.mockState.processes.bunAvailable, + venvExists: electronAPI.mockState.venvExists && mockEnv.mockState.filesystem.venvExists, + }), + + /** + * Check if environment is in expected state for a scenario + */ + verifyScenario: (scenarioName: string) => { + const state = mockEnv.mockState + const electronState = electronAPI.mockState + + switch (scenarioName) { + case 'freshInstall': + return !state.filesystem.venvExists && + !state.filesystem.versionFileExists && + !electronState.toolInstalled + + case 'versionUpdate': + return state.filesystem.versionFileExists && + state.app.currentVersion !== state.filesystem.versionFileContent + + case 'venvRemoved': + return !state.filesystem.venvExists && + state.filesystem.versionFileExists + + case 'installationInProgress': + return state.filesystem.installingLockExists || + electronState.isInstalling + + default: + return false + } + } + }, + + // Reset everything + reset: () => { + electronAPI.reset() + mockEnv.reset() + } + } +} + +/** + * Helper function to wait for installation state changes + */ +export async function waitForInstallationState( + getState: () => any, + expectedState: string, + timeout: number = 1000 +): Promise { + return new Promise((resolve, reject) => { + const startTime = Date.now() + + const check = () => { + if (getState().state === expectedState) { + resolve() + } else if (Date.now() - startTime > timeout) { + reject(new Error(`Timeout waiting for state ${expectedState}, current: ${getState().state}`)) + } else { + setTimeout(check, 10) + } + } + + check() + }) +} + +/** + * Helper function to wait for multiple logs + */ +export async function waitForLogs( + getLogs: () => any[], + expectedCount: number, + timeout: number = 1000 +): Promise { + return new Promise((resolve, reject) => { + const startTime = Date.now() + + const check = () => { + if (getLogs().length >= expectedCount) { + resolve() + } else if (Date.now() - startTime > timeout) { + reject(new Error(`Timeout waiting for ${expectedCount} logs, got: ${getLogs().length}`)) + } else { + setTimeout(check, 10) + } + } + + check() + }) +} + +/** + * Example usage in tests: + * + * ```typescript + * import { createTestEnvironment, waitForInstallationState } from '../mocks/testUtils' + * + * describe('Installation Flow', () => { + * let testEnv: ReturnType + * + * beforeEach(() => { + * testEnv = createTestEnvironment() + * }) + * + * it('should handle fresh installation', async () => { + * testEnv.scenarios.freshInstall() + * + * // Verify scenario setup + * expect(testEnv.inspect.verifyScenario('freshInstall')).toBe(true) + * + * // Simulate installation + * await testEnv.simulate.successfulInstallation() + * + * // Verify result + * const state = testEnv.inspect.getInstallationState() + * expect(state.isInstalling).toBe(false) + * }) + * }) + * ``` + */ \ No newline at end of file diff --git a/test/setup.ts b/test/setup.ts index 907391e6..2bd019a8 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -7,6 +7,13 @@ global.electronAPI = { // Add mock implementations for electron preload APIs } +// Mock ipcRenderer +global.ipcRenderer = { + invoke: vi.fn(), + on: vi.fn(), + removeAllListeners: vi.fn(), +} + // Mock environment variables process.env.NODE_ENV = 'test' @@ -22,6 +29,13 @@ global.waitFor = async (callback: () => boolean, timeout = 5000) => { throw new Error(`Timeout waiting for condition after ${timeout}ms`) } +// Add type declarations for globals +declare global { + var electronAPI: any + var ipcRenderer: any + var waitFor: (callback: () => boolean, timeout?: number) => Promise +} + // Setup DOM environment Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/test/unit/electron/install-deps.test.ts b/test/unit/electron/install-deps.test.ts new file mode 100644 index 00000000..b1ee8adc --- /dev/null +++ b/test/unit/electron/install-deps.test.ts @@ -0,0 +1,609 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setupMockEnvironment } from '../../mocks/environmentMocks' + +// Set up global mock environment before any imports +const globalMockEnv = setupMockEnvironment() + +// Mock all dependencies at the top level +vi.mock('node:fs', () => ({ + ...globalMockEnv.fsMock, + default: globalMockEnv.fsMock +})) +vi.mock('node:path', () => ({ + ...globalMockEnv.pathMock, + default: globalMockEnv.pathMock +})) +vi.mock('child_process', () => ({ + ...globalMockEnv.processMock, + default: globalMockEnv.processMock +})) +vi.mock('electron-log', () => ({ default: globalMockEnv.logMock })) +vi.mock('electron', () => ({ + app: globalMockEnv.appMock, + BrowserWindow: vi.fn() +})) +vi.mock('../../../electron/main/utils/process', () => globalMockEnv.processUtilsMock) +vi.mock('../../../electron/main/init', () => ({ + getMainWindow: vi.fn().mockReturnValue({ + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(false) + }) +})) +vi.mock('../../../electron/main/utils/safeWebContentsSend', () => ({ + safeMainWindowSend: vi.fn().mockReturnValue(true) +})) + +// Import the module under test after mocking +let installDeps: any + +describe('Install Deps Module', () => { + let mockEnv: ReturnType + + beforeEach(async () => { + // Reset the mock environment state for each test + mockEnv = globalMockEnv + mockEnv.reset() + + // Set up the shared state + mockEnv.processMock.setupSpawnMock(mockEnv.mockState) + mockEnv.appMock.setup(mockEnv.mockState) + mockEnv.osMock.setup(mockEnv.mockState) + mockEnv.processUtilsMock.setup(mockEnv.mockState) + + // Import the module under test fresh for each test + installDeps = await import('../../../electron/main/install-deps') + }) + + afterEach(() => { + vi.clearAllMocks() + mockEnv.reset() + }) + + describe('checkAndInstallDepsOnUpdate', () => { + it('should skip installation when version has not changed and tools are installed', async () => { + // Set up scenario where version is the same and tools exist + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + + const mockWin = { + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(false) + } + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: mockWin, + forceInstall: false + }) + + expect(result.success).toBe(true) + expect(result.message).toContain('Version not changed') + expect(mockEnv.fsMock.writeFileSync).not.toHaveBeenCalledWith( + expect.stringContaining('version.txt'), + expect.any(String) + ) + }) + + it('should install dependencies when version file does not exist', async () => { + // Set up fresh installation scenario + mockEnv.scenarios.freshInstall() + + const mockWin = { + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(false) + } + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: mockWin, + forceInstall: false + }) + console.log(result); + + expect(result.success).toBe(true) + expect(result.message).toContain('Dependencies installed successfully') + expect(mockWin.webContents.send).toHaveBeenCalledWith( + 'update-notification', + expect.objectContaining({ + type: 'version-update', + reason: 'version file not exist' + }) + ) + }) + + it('should install dependencies when version has changed', async () => { + // Set up version update scenario + mockEnv.scenarios.versionUpdate('0.9.0', '1.0.0') + + const mockWin = { + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(false) + } + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: mockWin, + forceInstall: false + }) + + expect(result.success).toBe(true) + expect(result.message).toContain('Dependencies installed successfully') + expect(mockWin.webContents.send).toHaveBeenCalledWith( + 'update-notification', + expect.objectContaining({ + type: 'version-update', + currentVersion: '1.0.0', + previousVersion: '0.9.0', + reason: 'version not match' + }) + ) + }) + + it('should install when command tools are missing', async () => { + // Same version but tools missing + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': true } + + const mockWin = { + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(false) + } + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: mockWin, + forceInstall: false + }) + + expect(result.success).toBe(true) + expect(result.message).toContain('Dependencies installed successfully') + }) + + it('should force install when forceInstall is true', async () => { + // Set up scenario where normally no installation would be needed + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + + const mockWin = { + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(false) + } + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: mockWin, + forceInstall: true + }) + + expect(result.success).toBe(true) + expect(result.message).toContain('Dependencies installed successfully') + }) + + it('should handle installation failure', async () => { + // Set up failure scenario + mockEnv.scenarios.completeFailure() + + const mockWin = { + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(false) + } + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: mockWin, + forceInstall: true + }) + + expect(result.success).toBe(false) + expect(result.message).toContain('Install dependencies failed') + }) + + it('should handle window being destroyed', async () => { + const mockWin = { + webContents: { send: vi.fn() }, + isDestroyed: vi.fn().mockReturnValue(true) + } + + mockEnv.scenarios.versionUpdate('0.9.0', '1.0.0') + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: mockWin, + forceInstall: false + }) + + // Should still complete successfully + expect(result.success).toBe(true) + // Should not try to send notifications to destroyed window + expect(mockWin.webContents.send).not.toHaveBeenCalled() + }) + + it('should handle null window gracefully', async () => { + mockEnv.scenarios.versionUpdate('0.9.0', '1.0.0') + + const result = await installDeps.checkAndInstallDepsOnUpdate({ + win: null, + forceInstall: false + }) + + expect(result.success).toBe(true) + // Should not crash when window is null + }) + }) + + describe('installCommandTool', () => { + it('should install uv and bun when not available', async () => { + // Set up scenario where tools are not available initially + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + + // Simulate successful installation + let uvCallCount = 0 + let bunCallCount = 0 + mockEnv.processUtilsMock.isBinaryExists.mockImplementation(async (name: string) => { + if (name === 'uv') { + uvCallCount++ + return uvCallCount > 1 // False first time, true after "installation" + } + if (name === 'bun') { + bunCallCount++ + return bunCallCount > 1 // False first time, true after "installation" + } + return false + }) + + const result = await installDeps.installCommandTool() + + expect(result.success).toBe(true) + expect(result.message).toContain('Command tools installed successfully') + expect(mockEnv.processUtilsMock.runInstallScript).toHaveBeenCalledTimes(2) // uv and bun + }) + + it('should skip installation when tools are already available', async () => { + // Tools are available by default in mockState + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + + const result = await installDeps.installCommandTool() + + expect(result.success).toBe(true) + expect(result.message).toContain('Command tools installed successfully') + expect(mockEnv.processUtilsMock.runInstallScript).not.toHaveBeenCalled() + }) + + it('should handle uv installation failure', async () => { + // Mock uv installation failure + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': true } + + // uv remains unavailable even after installation attempt + mockEnv.processUtilsMock.isBinaryExists.mockImplementation(async (name: string) => { + if (name === 'uv') return false // Always fails + if (name === 'bun') return true + return false + }) + + const result = await installDeps.installCommandTool() + + expect(result.success).toBe(false) + expect(result.message).toContain('uv installation failed') + }) + + it('should handle bun installation failure', async () => { + // Mock bun installation failure + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': false } + + // bun remains unavailable even after installation attempt + mockEnv.processUtilsMock.isBinaryExists.mockImplementation(async (name: string) => { + if (name === 'uv') return true + if (name === 'bun') return false // Always fails + return false + }) + + const result = await installDeps.installCommandTool() + + expect(result.success).toBe(false) + expect(result.message).toContain('bun installation failed') + }) + }) +}) + +// describe('getInstallationStatus', () => { +// it('should return correct status when installation is in progress', async () => { +// mockEnv.scenarios.installationInProgress() + +// const status = await installDeps.getInstallationStatus() + +// expect(status.isInstalling).toBe(true) +// expect(status.hasLockFile).toBe(true) +// expect(status.installedExists).toBe(false) +// }) + +// it('should return correct status when installation is completed', async () => { +// // Default state has installation completed +// mockEnv.mockState.filesystem.installingLockExists = false +// mockEnv.mockState.filesystem.installedLockExists = true + +// const status = await installDeps.getInstallationStatus() + +// expect(status.isInstalling).toBe(false) +// expect(status.hasLockFile).toBe(true) +// expect(status.installedExists).toBe(true) +// }) + +// it('should return correct status when no installation has occurred', async () => { +// mockEnv.mockState.filesystem.installingLockExists = false +// mockEnv.mockState.filesystem.installedLockExists = false + +// const status = await installDeps.getInstallationStatus() + +// expect(status.isInstalling).toBe(false) +// expect(status.hasLockFile).toBe(false) +// expect(status.installedExists).toBe(false) +// }) + +// it('should handle file system errors gracefully', async () => { +// // Mock fs.existsSync to throw an error +// mockEnv.fsMock.existsSync.mockImplementation(() => { +// throw new Error('File system error') +// }) + +// const status = await installDeps.getInstallationStatus() + +// expect(status.isInstalling).toBe(false) +// expect(status.hasLockFile).toBe(false) +// expect(status.installedExists).toBe(false) +// }) +// }) + +// describe('installDependencies', () => { +// it('should successfully install dependencies with default settings', async () => { +// // Set up successful installation scenario +// mockEnv.mockState.processes.uvAvailable = true +// mockEnv.mockState.network.canConnectToDefault = true + +// const result = await installDeps.installDependencies('1.0.0') + +// expect(result.success).toBe(true) +// expect(result.message).toContain('Dependencies installed successfully') +// expect(mockEnv.fsMock.writeFileSync).toHaveBeenCalledWith( +// expect.stringContaining('uv_installed.lock'), +// '' +// ) +// }) + +// it('should fall back to mirror when default fails', async () => { +// // Set up network issues scenario - first install fails, mirror succeeds +// mockEnv.scenarios.networkIssues() + +// const result = await installDeps.installDependencies('1.0.0') + +// expect(result.success).toBe(true) +// expect(result.message).toContain('Dependencies installed successfully with mirror') +// }) + +// it('should fail when both default and mirror fail', async () => { +// mockEnv.scenarios.completeFailure() + +// const result = await installDeps.installDependencies('1.0.0') + +// expect(result.success).toBe(false) +// expect(result.message).toContain('Both default and mirror install failed') +// }) + +// it('should handle command tool installation failure', async () => { +// // Set up scenario where command tool installation fails +// mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + +// // Mock tools to remain unavailable +// mockEnv.processUtilsMock.isBinaryExists.mockResolvedValue(false) + +// const result = await installDeps.installDependencies('1.0.0') + +// expect(result.success).toBe(false) +// expect(result.message).toContain('Command tool installation failed') +// }) + +// it('should create and clean up lock files correctly', async () => { +// mockEnv.mockState.processes.uvAvailable = true +// mockEnv.mockState.network.canConnectToDefault = true + +// await installDeps.installDependencies('1.0.0') + +// // Verify that installation lock is created and then cleaned up +// expect(mockEnv.fsMock.writeFileSync).toHaveBeenCalledWith( +// expect.stringContaining('uv_installing.lock'), +// '' +// ) +// expect(mockEnv.fsMock.unlinkSync).toHaveBeenCalledWith( +// expect.stringContaining('uv_installing.lock') +// ) +// expect(mockEnv.fsMock.writeFileSync).toHaveBeenCalledWith( +// expect.stringContaining('uv_installed.lock'), +// '' +// ) +// }) + +// it('should clean up old virtual environments after successful installation', async () => { +// mockEnv.scenarios.multipleOldVenvs('1.0.0') +// mockEnv.mockState.processes.uvAvailable = true +// mockEnv.mockState.network.canConnectToDefault = true + +// await installDeps.installDependencies('1.0.0') + +// expect(mockEnv.processUtilsMock.cleanupOldVenvs).toHaveBeenCalledWith('1.0.0') +// }) +// }) + +// describe('detectInstallationLogs', () => { +// beforeEach(() => { +// // Reset the module-level state variables +// vi.resetModules() +// }) + +// it('should detect UV dependency installation patterns', () => { +// const installationPatterns = [ +// 'Resolved 10 packages in 1.2s', +// 'Downloaded package xyz', +// 'Installing numpy==1.21.0', +// 'Built wheel for package', +// 'Prepared virtual environment', +// 'Syncing dependencies', +// 'Creating virtualenv at .venv', +// 'Updating package index', +// 'Audited 15 packages' +// ] + +// installationPatterns.forEach(pattern => { +// // The function has side effects, so we can't easily test return values +// // Instead, we test that it doesn't throw and processes the input +// expect(() => installDeps.detectInstallationLogs(pattern)).not.toThrow() +// }) +// }) + +// it('should handle uvicorn startup messages', () => { +// const uvicornMessages = [ +// 'Uvicorn running on http://127.0.0.1:8000', +// 'Application startup complete', +// 'Server started successfully' +// ] + +// uvicornMessages.forEach(message => { +// expect(() => installDeps.detectInstallationLogs(message)).not.toThrow() +// }) +// }) + +// it('should handle installation failure messages', () => { +// const failureMessages = [ +// '× No solution found when resolving dependencies', +// 'failed to resolve dependencies', +// 'installation failed' +// ] + +// failureMessages.forEach(message => { +// expect(() => installDeps.detectInstallationLogs(message)).not.toThrow() +// }) +// }) +// }) + +// describe('Error Handling and Edge Cases', () => { +// it('should handle file system permission errors gracefully', async () => { +// // Mock filesystem error +// mockEnv.fsMock.writeFileSync.mockImplementation((path: string) => { +// if (path.includes('version.txt')) { +// throw new Error('Permission denied') +// } +// }) + +// const mockWin = { +// webContents: { send: vi.fn() }, +// isDestroyed: vi.fn().mockReturnValue(false) +// } + +// // The function should handle errors gracefully +// const result = await installDeps.checkAndInstallDepsOnUpdate({ +// win: mockWin, +// forceInstall: true +// }) + +// // Should still return a result, even if there are file system errors +// expect(result).toBeDefined() +// expect(typeof result.success).toBe('boolean') +// expect(typeof result.message).toBe('string') +// }) + +// it('should handle timezone-based mirror selection for China', async () => { +// // Mock Intl.DateTimeFormat for China timezone +// const originalDateTimeFormat = global.Intl.DateTimeFormat +// const mockDateTimeFormat = vi.fn().mockImplementation(() => ({ +// resolvedOptions: () => ({ timeZone: 'Asia/Shanghai' }) +// })) as any +// global.Intl.DateTimeFormat = mockDateTimeFormat + +// try { +// // Set up scenario where default fails but mirror succeeds +// mockEnv.scenarios.networkIssues() + +// const result = await installDeps.installDependencies('1.0.0') + +// expect(result.success).toBe(true) +// expect(result.message).toContain('Dependencies installed successfully with mirror') +// } finally { +// // Restore original +// global.Intl.DateTimeFormat = originalDateTimeFormat +// } +// }) + +// it('should handle invalid version strings', async () => { +// const result = await installDeps.installDependencies('') + +// // Should handle empty version string gracefully +// expect(result).toBeDefined() +// expect(typeof result.success).toBe('boolean') +// }) + +// it('should handle missing backend directory', async () => { +// mockEnv.mockState.filesystem.backendPathExists = false + +// const result = await installDeps.installDependencies('1.0.0') + +// // Should create the directory and continue +// expect(mockEnv.fsMock.mkdirSync).toHaveBeenCalledWith( +// expect.stringContaining('backend'), +// { recursive: true } +// ) +// expect(result).toBeDefined() +// }) +// }) + +// describe('Integration Tests', () => { +// it('should handle complete fresh installation workflow', async () => { +// // Set up completely fresh system +// mockEnv.scenarios.freshInstall() + +// const mockWin = { +// webContents: { send: vi.fn() }, +// isDestroyed: vi.fn().mockReturnValue(false) +// } + +// const result = await installDeps.checkAndInstallDepsOnUpdate({ +// win: mockWin, +// forceInstall: false +// }) + +// expect(result.success).toBe(true) +// expect(mockWin.webContents.send).toHaveBeenCalledWith( +// 'update-notification', +// expect.objectContaining({ +// type: 'version-update', +// reason: 'version file not exist' +// }) +// ) +// }) + +// it('should handle version update with missing tools', async () => { +// // Version file exists but tools are missing +// mockEnv.mockState.filesystem.versionFileExists = true +// mockEnv.mockState.filesystem.versionFileContent = '0.9.0' +// mockEnv.mockState.app.currentVersion = '1.0.0' +// mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + +// const mockWin = { +// webContents: { send: vi.fn() }, +// isDestroyed: vi.fn().mockReturnValue(false) +// } + +// const result = await installDeps.checkAndInstallDepsOnUpdate({ +// win: mockWin, +// forceInstall: false +// }) + +// expect(result.success).toBe(true) +// expect(mockWin.webContents.send).toHaveBeenCalledWith( +// 'update-notification', +// expect.objectContaining({ +// type: 'version-update', +// currentVersion: '1.0.0', +// previousVersion: '0.9.0', +// reason: 'version not match' +// }) +// ) +// }) +// }) +// }) \ No newline at end of file diff --git a/test/unit/electron/main/domReadyHandlers.test.ts b/test/unit/electron/main/domReadyHandlers.test.ts new file mode 100644 index 00000000..816bcd01 --- /dev/null +++ b/test/unit/electron/main/domReadyHandlers.test.ts @@ -0,0 +1,539 @@ +/** + * Tests for DOM ready event handlers in createWindow function + * These handlers manage localStorage injection for installation states + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setupMockEnvironment } from '../../../mocks/environmentMocks' + +describe('createWindow - DOM Ready Event Handlers', () => { + let mockEnv: ReturnType + let mockWebContents: any + let mockWindow: any + + beforeEach(() => { + mockEnv = setupMockEnvironment() + + // Mock webContents and window + mockWebContents = { + on: vi.fn(), + once: vi.fn(), + executeJavaScript: vi.fn(), + send: vi.fn(), + loadURL: vi.fn(), + loadFile: vi.fn(), + openDevTools: vi.fn() + } + + mockWindow = { + webContents: mockWebContents, + reload: vi.fn() + } + + // Reset all mocks + vi.clearAllMocks() + }) + + afterEach(() => { + mockEnv.reset() + }) + + describe('Fresh Installation - Carousel State Injection', () => { + it('should inject fresh auth-storage with carousel state', () => { + // Simulate fresh installation scenario + const needsInstallation = true + + // Set up DOM ready handler like createWindow does + if (needsInstallation) { + mockWebContents.on('dom-ready', () => { + const injectionScript = ` + (function() { + try { + const newAuthStorage = { + state: { + token: null, + username: null, + email: null, + user_id: null, + appearance: 'light', + language: 'system', + isFirstLaunch: true, + modelType: 'cloud', + cloud_model_type: 'gpt-4.1', + initState: 'carousel', + share_token: null, + workerListData: {} + }, + version: 0 + }; + localStorage.setItem('auth-storage', JSON.stringify(newAuthStorage)); + console.log('[ELECTRON PRE-INJECT] Created fresh auth-storage with carousel state'); + } catch (e) { + console.error('[ELECTRON PRE-INJECT] Failed to create storage:', e); + } + })(); + ` + mockWebContents.executeJavaScript(injectionScript) + }) + } + + // Trigger DOM ready event + const domReadyCallback = mockWebContents.on.mock.calls.find( + (call: any) => call[0] === 'dom-ready' + )?.[1] + + expect(domReadyCallback).toBeDefined() + + if (domReadyCallback) { + domReadyCallback() + } + + // Verify JavaScript injection was called with carousel state + expect(mockWebContents.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining('initState: \'carousel\'') + ) + expect(mockWebContents.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining('isFirstLaunch: true') + ) + }) + + it('should handle JavaScript injection errors gracefully', () => { + const needsInstallation = true + + // Mock executeJavaScript to reject + mockWebContents.executeJavaScript.mockRejectedValue(new Error('Injection failed')) + + // Set up DOM ready handler with error handling + if (needsInstallation) { + mockWebContents.on('dom-ready', () => { + const injectionScript = `/* injection script */` + mockWebContents.executeJavaScript(injectionScript).catch((err: Error) => { + // In real code, this is logged but doesn't throw + console.error('Failed to inject script:', err) + }) + }) + } + + // Trigger DOM ready event + const domReadyCallback = mockWebContents.on.mock.calls.find( + (call: any) => call[0] === 'dom-ready' + )?.[1] + + if (domReadyCallback) { + expect(() => domReadyCallback()).not.toThrow() + } + }) + + it('should include all required auth-storage properties', () => { + const needsInstallation = true + + if (needsInstallation) { + mockWebContents.on('dom-ready', () => { + const injectionScript = ` + (function() { + try { + const newAuthStorage = { + state: { + token: null, + username: null, + email: null, + user_id: null, + appearance: 'light', + language: 'system', + isFirstLaunch: true, + modelType: 'cloud', + cloud_model_type: 'gpt-4.1', + initState: 'carousel', + share_token: null, + workerListData: {} + }, + version: 0 + }; + localStorage.setItem('auth-storage', JSON.stringify(newAuthStorage)); + } catch (e) { + console.error('Failed to create storage:', e); + } + })(); + ` + mockWebContents.executeJavaScript(injectionScript) + }) + } + + const domReadyCallback = mockWebContents.on.mock.calls.find( + (call: any) => call[0] === 'dom-ready' + )?.[1] + + if (domReadyCallback) { + domReadyCallback() + } + + const injectedScript = mockWebContents.executeJavaScript.mock.calls[0]?.[0] + + // Verify all required properties are included + expect(injectedScript).toContain('token: null') + expect(injectedScript).toContain('username: null') + expect(injectedScript).toContain('email: null') + expect(injectedScript).toContain('user_id: null') + expect(injectedScript).toContain('appearance: \'light\'') + expect(injectedScript).toContain('language: \'system\'') + expect(injectedScript).toContain('isFirstLaunch: true') + expect(injectedScript).toContain('modelType: \'cloud\'') + expect(injectedScript).toContain('cloud_model_type: \'gpt-4.1\'') + expect(injectedScript).toContain('initState: \'carousel\'') + expect(injectedScript).toContain('share_token: null') + expect(injectedScript).toContain('workerListData: {}') + expect(injectedScript).toContain('version: 0') + }) + }) + + describe('Completed Installation - Done State Management', () => { + it('should check and update initState to done when installation is complete', () => { + const needsInstallation = false + + if (!needsInstallation) { + mockWebContents.once('dom-ready', () => { + const checkScript = ` + (function() { + try { + const authStorage = localStorage.getItem('auth-storage'); + if (authStorage) { + const parsed = JSON.parse(authStorage); + if (parsed.state && parsed.state.initState !== 'done') { + console.log('[ELECTRON] Updating initState from', parsed.state.initState, 'to done'); + parsed.state.initState = 'done'; + localStorage.setItem('auth-storage', JSON.stringify(parsed)); + console.log('[ELECTRON] initState updated to done, reloading page...'); + return true; + } + } + return false; + } catch (e) { + console.error('[ELECTRON] Failed to update initState:', e); + return false; + } + })(); + ` + mockWebContents.executeJavaScript(checkScript) + }) + } + + // Trigger DOM ready event + const domReadyCallback = mockWebContents.once.mock.calls.find( + (call: any) => call[0] === 'dom-ready' + )?.[1] + + expect(domReadyCallback).toBeDefined() + + if (domReadyCallback) { + domReadyCallback() + } + + // Verify the check script was executed + expect(mockWebContents.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining('initState !== \'done\'') + ) + expect(mockWebContents.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining('initState = \'done\'') + ) + }) + + it('should trigger window reload when initState needs updating', async () => { + const needsInstallation = false + + // Mock executeJavaScript to return true (indicating reload needed) + mockWebContents.executeJavaScript.mockResolvedValue(true) + + if (!needsInstallation) { + mockWebContents.once('dom-ready', () => { + mockWebContents.executeJavaScript(`/* check script */`).then((needsReload: boolean) => { + if (needsReload) { + mockWindow.reload() + } + }) + }) + } + + // Trigger DOM ready event + const domReadyCallback = mockWebContents.once.mock.calls.find( + (call: any) => call[0] === 'dom-ready' + )?.[1] + + if (domReadyCallback) { + domReadyCallback() + } + + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(mockWindow.reload).toHaveBeenCalled() + }) + + it('should not reload when initState is already done', async () => { + const needsInstallation = false + + // Mock executeJavaScript to return false (no reload needed) + mockWebContents.executeJavaScript.mockResolvedValue(false) + + if (!needsInstallation) { + mockWebContents.once('dom-ready', () => { + mockWebContents.executeJavaScript(`/* check script */`).then((needsReload: boolean) => { + if (needsReload) { + mockWindow.reload() + } + }) + }) + } + + const domReadyCallback = mockWebContents.once.mock.calls.find( + (call: any) => call[0] === 'dom-ready' + )?.[1] + + if (domReadyCallback) { + domReadyCallback() + } + + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(mockWindow.reload).not.toHaveBeenCalled() + }) + + it('should handle localStorage parsing errors gracefully', async () => { + const needsInstallation = false + + // Mock executeJavaScript to simulate parsing error (return false) + mockWebContents.executeJavaScript.mockResolvedValue(false) + + if (!needsInstallation) { + mockWebContents.once('dom-ready', () => { + const checkScript = ` + (function() { + try { + const authStorage = localStorage.getItem('auth-storage'); + if (authStorage) { + const parsed = JSON.parse(authStorage); // This could throw + // ... rest of logic + } + return false; + } catch (e) { + console.error('[ELECTRON] Failed to update initState:', e); + return false; // Error case returns false + } + })(); + ` + mockWebContents.executeJavaScript(checkScript) + }) + } + + const domReadyCallback = mockWebContents.once.mock.calls.find( + (call: any) => call[0] === 'dom-ready' + )?.[1] + + if (domReadyCallback) { + expect(() => domReadyCallback()).not.toThrow() + } + + await new Promise(resolve => setTimeout(resolve, 10)) + + // Should not reload on error + expect(mockWindow.reload).not.toHaveBeenCalled() + }) + }) + + describe('Event Handler Setup Differences', () => { + it('should use "on" event for fresh installation (can trigger multiple times)', () => { + const needsInstallation = true + + // Simulate the logic from createWindow + if (needsInstallation) { + mockWebContents.on('dom-ready', () => { + // Fresh installation handler + }) + } + + // Verify 'on' was used instead of 'once' + expect(mockWebContents.on).toHaveBeenCalledWith('dom-ready', expect.any(Function)) + expect(mockWebContents.once).not.toHaveBeenCalled() + }) + + it('should use "once" event for completed installation (single trigger)', () => { + const needsInstallation = false + + // Simulate the logic from createWindow + if (!needsInstallation) { + mockWebContents.once('dom-ready', () => { + // Completed installation handler + }) + } + + // Verify 'once' was used instead of 'on' + expect(mockWebContents.once).toHaveBeenCalledWith('dom-ready', expect.any(Function)) + expect(mockWebContents.on).not.toHaveBeenCalled() + }) + }) + + describe('JavaScript Execution Content Validation', () => { + it('should create properly structured auth-storage JSON for fresh installation', () => { + const needsInstallation = true + + if (needsInstallation) { + mockWebContents.on('dom-ready', () => { + const script = ` + (function() { + try { + const newAuthStorage = { + state: { + token: null, + username: null, + email: null, + user_id: null, + appearance: 'light', + language: 'system', + isFirstLaunch: true, + modelType: 'cloud', + cloud_model_type: 'gpt-4.1', + initState: 'carousel', + share_token: null, + workerListData: {} + }, + version: 0 + }; + localStorage.setItem('auth-storage', JSON.stringify(newAuthStorage)); + console.log('[ELECTRON PRE-INJECT] Created fresh auth-storage with carousel state'); + } catch (e) { + console.error('[ELECTRON PRE-INJECT] Failed to create storage:', e); + } + })(); + ` + mockWebContents.executeJavaScript(script) + }) + } + + const domReadyCallback = mockWebContents.on.mock.calls[0]?.[1] + if (domReadyCallback) { + domReadyCallback() + } + + const executedScript = mockWebContents.executeJavaScript.mock.calls[0]?.[0] + + // Verify the script is wrapped in IIFE + expect(executedScript).toMatch(/^\s*\(\s*function\s*\(\s*\)\s*\{/) + expect(executedScript).toMatch(/\}\s*\)\s*\(\s*\)\s*;?\s*$/) + + // Verify it has try-catch error handling + expect(executedScript).toContain('try {') + expect(executedScript).toContain('} catch (e) {') + + // Verify it sets localStorage + expect(executedScript).toContain('localStorage.setItem(\'auth-storage\'') + expect(executedScript).toContain('JSON.stringify(newAuthStorage)') + }) + + it('should check localStorage properly for completed installation', () => { + const needsInstallation = false + + if (!needsInstallation) { + mockWebContents.once('dom-ready', () => { + const script = ` + (function() { + try { + const authStorage = localStorage.getItem('auth-storage'); + if (authStorage) { + const parsed = JSON.parse(authStorage); + if (parsed.state && parsed.state.initState !== 'done') { + parsed.state.initState = 'done'; + localStorage.setItem('auth-storage', JSON.stringify(parsed)); + return true; + } + } + return false; + } catch (e) { + console.error('[ELECTRON] Failed to update initState:', e); + return false; + } + })(); + ` + mockWebContents.executeJavaScript(script) + }) + } + + const domReadyCallback = mockWebContents.once.mock.calls[0]?.[1] + if (domReadyCallback) { + domReadyCallback() + } + + const executedScript = mockWebContents.executeJavaScript.mock.calls[0]?.[0] + + // Verify it gets localStorage + expect(executedScript).toContain('localStorage.getItem(\'auth-storage\')') + + // Verify it parses JSON + expect(executedScript).toContain('JSON.parse(authStorage)') + + // Verify it checks initState + expect(executedScript).toContain('initState !== \'done\'') + + // Verify it updates initState + expect(executedScript).toContain('initState = \'done\'') + + // Verify it returns boolean + expect(executedScript).toContain('return true') + expect(executedScript).toContain('return false') + }) + }) + + describe('Console Logging in Injected Scripts', () => { + it('should include proper console logging for fresh installation', () => { + const needsInstallation = true + + if (needsInstallation) { + mockWebContents.on('dom-ready', () => { + const script = ` + console.log('[ELECTRON PRE-INJECT] Created fresh auth-storage with carousel state'); + console.error('[ELECTRON PRE-INJECT] Failed to create storage:', e); + ` + mockWebContents.executeJavaScript(script) + }) + } + + const domReadyCallback = mockWebContents.on.mock.calls[0]?.[1] + if (domReadyCallback) { + domReadyCallback() + } + + const executedScript = mockWebContents.executeJavaScript.mock.calls[0]?.[0] + + // Verify console logging is included + expect(executedScript).toContain('[ELECTRON PRE-INJECT]') + expect(executedScript).toContain('console.log') + expect(executedScript).toContain('console.error') + }) + + it('should include proper console logging for completed installation', () => { + const needsInstallation = false + + if (!needsInstallation) { + mockWebContents.once('dom-ready', () => { + const script = ` + console.log('[ELECTRON] Updating initState from', parsed.state.initState, 'to done'); + console.log('[ELECTRON] initState updated to done, reloading page...'); + console.error('[ELECTRON] Failed to update initState:', e); + ` + mockWebContents.executeJavaScript(script) + }) + } + + const domReadyCallback = mockWebContents.once.mock.calls[0]?.[1] + if (domReadyCallback) { + domReadyCallback() + } + + const executedScript = mockWebContents.executeJavaScript.mock.calls[0]?.[0] + + // Verify console logging is included + expect(executedScript).toContain('[ELECTRON]') + expect(executedScript).toContain('console.log') + expect(executedScript).toContain('console.error') + }) + }) +}) \ No newline at end of file diff --git a/test/unit/electron/main/installationStateLogic.test.ts b/test/unit/electron/main/installationStateLogic.test.ts new file mode 100644 index 00000000..eabf6f15 --- /dev/null +++ b/test/unit/electron/main/installationStateLogic.test.ts @@ -0,0 +1,357 @@ +/** + * Focused tests for the complex installation state detection logic in createWindow + * This tests the decision matrix for when installation is needed vs when it's not + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setupMockEnvironment } from '../../../mocks/environmentMocks' + +describe('createWindow - Installation State Detection Logic', () => { + let mockEnv: ReturnType + let installationStateChecker: (mockState: any) => Promise + + beforeEach(() => { + mockEnv = setupMockEnvironment() + + // Extract the installation decision logic for focused testing + installationStateChecker = async (mockState) => { + const currentVersion = mockState.app.currentVersion + const versionExists = mockState.filesystem.versionFileExists + const savedVersion = versionExists ? mockState.filesystem.versionFileContent : '' + const uvExists = mockState.filesystem.binariesExist['uv'] || false + const bunExists = mockState.filesystem.binariesExist['bun'] || false + const installationCompleted = mockState.filesystem.installedLockExists + + return !versionExists || + savedVersion !== currentVersion || + !uvExists || + !bunExists || + !installationCompleted + } + }) + + afterEach(() => { + mockEnv.reset() + }) + + describe('Version File Scenarios', () => { + it('should require installation when version file does not exist', async () => { + mockEnv.mockState.filesystem.versionFileExists = false + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should require installation when saved version differs from current version', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '0.9.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should not require installation when versions match', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(false) + }) + + it('should handle version file with whitespace correctly', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = ' 1.0.0 \n' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + + // In the real code, version content is trimmed + const trimmedVersion = mockEnv.mockState.filesystem.versionFileContent.trim() + const needsInstallation = trimmedVersion !== mockEnv.mockState.app.currentVersion || + !mockEnv.mockState.filesystem.binariesExist['uv'] || + !mockEnv.mockState.filesystem.binariesExist['bun'] || + !mockEnv.mockState.filesystem.installedLockExists + + expect(needsInstallation).toBe(false) + }) + }) + + describe('Binary Existence Scenarios', () => { + it('should require installation when uv binary is missing', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should require installation when bun binary is missing', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': false } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should require installation when both binaries are missing', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + }) + + describe('Installation Lock File Scenarios', () => { + it('should require installation when lock file is missing', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = false + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should not require installation when lock file exists', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(false) + }) + }) + + describe('Combined Failure Scenarios', () => { + it('should require installation when multiple conditions fail', async () => { + mockEnv.mockState.filesystem.versionFileExists = false + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + mockEnv.mockState.filesystem.installedLockExists = false + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should require installation when version mismatch AND missing binaries', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '0.9.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should require installation when only lock file is present', async () => { + mockEnv.mockState.filesystem.versionFileExists = false + mockEnv.mockState.filesystem.binariesExist = { 'uv': false, 'bun': false } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + }) + + describe('Edge Cases and Boundaries', () => { + it('should handle empty version strings', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + + it('should handle version with special characters', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0-beta.1' + mockEnv.mockState.app.currentVersion = '1.0.0-beta.1' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(false) + }) + + it('should handle null or undefined binary states', async () => { + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = {} + mockEnv.mockState.filesystem.installedLockExists = true + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(true) + }) + }) + + describe('Platform-Specific Binary Detection', () => { + it('should check for .exe extension on Windows', async () => { + mockEnv.scenarios.missingEigentDirectories() + mockEnv.mockState.system.platform = 'win32' + + // Test that binary detection considers .exe files on Windows + expect(mockEnv.processUtilsMock.getBinaryName('uv')).resolves.toBe('uv.exe') + }) + + it('should not add .exe extension on macOS', async () => { + mockEnv.scenarios.macOSEnvironment() + + expect(mockEnv.processUtilsMock.getBinaryName('uv')).resolves.toBe('uv') + }) + + it('should not add .exe extension on Linux', async () => { + mockEnv.scenarios.linuxEnvironment() + + expect(mockEnv.processUtilsMock.getBinaryName('uv')).resolves.toBe('uv') + }) + }) + + describe('Decision Matrix Verification', () => { + // This test verifies the complete decision matrix + const testCases = [ + { + name: 'All conditions met - no installation needed', + setup: { + versionFileExists: true, + versionFileContent: '1.0.0', + currentVersion: '1.0.0', + uvExists: true, + bunExists: true, + installedLockExists: true + }, + expectedNeedsInstallation: false + }, + { + name: 'Missing version file', + setup: { + versionFileExists: false, + versionFileContent: '', + currentVersion: '1.0.0', + uvExists: true, + bunExists: true, + installedLockExists: true + }, + expectedNeedsInstallation: true + }, + { + name: 'Version mismatch', + setup: { + versionFileExists: true, + versionFileContent: '0.9.0', + currentVersion: '1.0.0', + uvExists: true, + bunExists: true, + installedLockExists: true + }, + expectedNeedsInstallation: true + }, + { + name: 'Missing uv binary', + setup: { + versionFileExists: true, + versionFileContent: '1.0.0', + currentVersion: '1.0.0', + uvExists: false, + bunExists: true, + installedLockExists: true + }, + expectedNeedsInstallation: true + }, + { + name: 'Missing bun binary', + setup: { + versionFileExists: true, + versionFileContent: '1.0.0', + currentVersion: '1.0.0', + uvExists: true, + bunExists: false, + installedLockExists: true + }, + expectedNeedsInstallation: true + }, + { + name: 'Missing installation lock', + setup: { + versionFileExists: true, + versionFileContent: '1.0.0', + currentVersion: '1.0.0', + uvExists: true, + bunExists: true, + installedLockExists: false + }, + expectedNeedsInstallation: true + } + ] + + testCases.forEach(({ name, setup, expectedNeedsInstallation }) => { + it(`should correctly handle: ${name}`, async () => { + // Set up the mock state according to the test case + mockEnv.mockState.filesystem.versionFileExists = setup.versionFileExists + mockEnv.mockState.filesystem.versionFileContent = setup.versionFileContent + mockEnv.mockState.app.currentVersion = setup.currentVersion + mockEnv.mockState.filesystem.binariesExist = { + 'uv': setup.uvExists, + 'bun': setup.bunExists + } + mockEnv.mockState.filesystem.installedLockExists = setup.installedLockExists + + const needsInstallation = await installationStateChecker(mockEnv.mockState) + expect(needsInstallation).toBe(expectedNeedsInstallation) + }) + }) + }) + + describe('Logging Verification', () => { + it('should log installation check results', async () => { + // This test ensures that the installation decision logic provides proper logging + const mockState = mockEnv.mockState + + const logData = { + needsInstallation: await installationStateChecker(mockState), + versionExists: mockState.filesystem.versionFileExists, + versionMatch: mockState.filesystem.versionFileContent === mockState.app.currentVersion, + uvExists: mockState.filesystem.binariesExist['uv'] || false, + bunExists: mockState.filesystem.binariesExist['bun'] || false, + installationCompleted: mockState.filesystem.installedLockExists + } + + // Verify that all required data for logging is available + expect(logData).toHaveProperty('needsInstallation') + expect(logData).toHaveProperty('versionExists') + expect(logData).toHaveProperty('versionMatch') + expect(logData).toHaveProperty('uvExists') + expect(logData).toHaveProperty('bunExists') + expect(logData).toHaveProperty('installationCompleted') + + // Verify the logic is consistent + const expectedNeedsInstallation = !logData.versionExists || + !logData.versionMatch || + !logData.uvExists || + !logData.bunExists || + !logData.installationCompleted + + expect(logData.needsInstallation).toBe(expectedNeedsInstallation) + }) + }) +}) \ No newline at end of file diff --git a/test/unit/electron/main/processUtilsDemo.test.ts b/test/unit/electron/main/processUtilsDemo.test.ts new file mode 100644 index 00000000..f560d86f --- /dev/null +++ b/test/unit/electron/main/processUtilsDemo.test.ts @@ -0,0 +1,199 @@ +/** + * Simple demonstration test for the new process utilities mocking + * This shows how to test the functions from process.ts with our enhanced mocks + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + setupMockEnvironment, + createFileSystemMock, + createProcessMock, + createElectronAppMock, + createOsMock, + createPathMock, + createLogMock, + createProcessUtilsMock +} from '../../../mocks/environmentMocks' + +// Set up vi.mock calls at the top level to avoid hoisting issues +vi.mock('fs', () => createFileSystemMock()) +vi.mock('child_process', () => createProcessMock()) +vi.mock('os', () => ({ default: createOsMock() })) +vi.mock('path', () => ({ default: createPathMock() })) +vi.mock('electron', () => ({ + app: createElectronAppMock(), + BrowserWindow: vi.fn() +})) +vi.mock('electron-log', () => ({ default: createLogMock() })) +vi.mock('../../../../electron/main/utils/process', () => createProcessUtilsMock()) + +describe('Process Utils Mocking Demo', () => { + let mockEnv: ReturnType + + beforeEach(() => { + mockEnv = setupMockEnvironment() + }) + + afterEach(() => { + mockEnv.reset() + }) + + describe('Binary Path Functions', () => { + it('should return correct binary paths based on platform', async () => { + // Test Windows binary naming + mockEnv.mockState.system.platform = 'win32' + + const uvBinaryName = await mockEnv.processUtilsMock.getBinaryName('uv') + expect(uvBinaryName).toBe('uv.exe') + + const uvBinaryPath = await mockEnv.processUtilsMock.getBinaryPath('uv') + expect(uvBinaryPath).toContain('.eigent/bin') + expect(uvBinaryPath).toContain('uv.exe') + }) + + it('should return correct binary paths for macOS', async () => { + mockEnv.scenarios.macOSEnvironment() + + const uvBinaryName = await mockEnv.processUtilsMock.getBinaryName('uv') + expect(uvBinaryName).toBe('uv') + + const uvBinaryPath = await mockEnv.processUtilsMock.getBinaryPath('uv') + expect(uvBinaryPath).toContain('.eigent/bin') + expect(uvBinaryPath).toContain('/uv') + expect(uvBinaryPath).not.toContain('.exe') + }) + }) + + describe('Directory Path Functions', () => { + it('should return correct backend path for development mode', () => { + mockEnv.mockState.app.isPackaged = false + + const backendPath = mockEnv.processUtilsMock.getBackendPath() + expect(backendPath).toContain('/backend') + expect(backendPath).not.toContain('resources') + }) + + it('should return correct backend path for packaged app', () => { + mockEnv.scenarios.packagedApp() + + const backendPath = mockEnv.processUtilsMock.getBackendPath() + expect(backendPath).toContain('backend') + // In packaged mode, it should use resources path + expect(mockEnv.mockState.app.isPackaged).toBe(true) + }) + + it('should return correct cache paths', () => { + const cachePath = mockEnv.processUtilsMock.getCachePath('models') + expect(cachePath).toContain('.eigent/cache/models') + }) + + it('should return correct venv paths', () => { + const venvPath = mockEnv.processUtilsMock.getVenvPath('1.0.0') + expect(venvPath).toContain('.eigent/venvs/backend-1.0.0') + }) + }) + + describe('Binary Existence Checking', () => { + it('should correctly check binary existence', async () => { + // Set up scenario where uv exists but bun doesn't + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': false } + + const uvExists = await mockEnv.processUtilsMock.isBinaryExists('uv') + const bunExists = await mockEnv.processUtilsMock.isBinaryExists('bun') + + expect(uvExists).toBe(true) + expect(bunExists).toBe(false) + }) + }) + + describe('Old Venv Cleanup', () => { + it('should cleanup old venvs correctly', async () => { + // Set up scenario with multiple old venvs + mockEnv.scenarios.multipleOldVenvs('1.0.0') + + const initialOldVenvs = [...mockEnv.mockState.filesystem.oldVenvsExist] + expect(initialOldVenvs).toContain('backend-0.9.0') + expect(initialOldVenvs).toContain('backend-0.9.5') + expect(initialOldVenvs).toContain('backend-1.0.1-beta') + + // Run cleanup + await mockEnv.processUtilsMock.cleanupOldVenvs('1.0.0') + + // Should keep current version but remove others + const remainingVenvs = mockEnv.mockState.filesystem.oldVenvsExist + console.log(remainingVenvs); + + + expect(remainingVenvs).toContain('backend-1.0.0') + expect(remainingVenvs).not.toContain('backend-0.9.0') + expect(remainingVenvs).not.toContain('backend-0.9.5') + expect(remainingVenvs).not.toContain('backend-1.0.1-beta') + }) + }) + + describe('Installation Decision Matrix', () => { + it('should correctly determine when installation is needed', () => { + // Test the decision logic that createWindow uses + const checkInstallationNeeded = (mockState: any) => { + const currentVersion = mockState.app.currentVersion + const versionExists = mockState.filesystem.versionFileExists + const savedVersion = versionExists ? mockState.filesystem.versionFileContent : '' + const uvExists = mockState.filesystem.binariesExist['uv'] || false + const bunExists = mockState.filesystem.binariesExist['bun'] || false + const installationCompleted = mockState.filesystem.installedLockExists + + return !versionExists || + savedVersion !== currentVersion || + !uvExists || + !bunExists || + !installationCompleted + } + + // Test fresh install scenario + mockEnv.scenarios.freshInstall() + expect(checkInstallationNeeded(mockEnv.mockState)).toBe(true) + + // Test version update scenario + mockEnv.scenarios.versionUpdate('0.9.0', '1.0.0') + expect(checkInstallationNeeded(mockEnv.mockState)).toBe(true) + + // Test all good scenario + mockEnv.mockState.filesystem.versionFileExists = true + mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + mockEnv.mockState.app.currentVersion = '1.0.0' + mockEnv.mockState.filesystem.binariesExist = { 'uv': true, 'bun': true } + mockEnv.mockState.filesystem.installedLockExists = true + expect(checkInstallationNeeded(mockEnv.mockState)).toBe(false) + }) + }) + + describe('File System Operations', () => { + it('should handle directory creation correctly', () => { + // Test .eigent directory creation + mockEnv.scenarios.missingEigentDirectories() + + expect(mockEnv.mockState.filesystem.eigentDirExists).toBe(false) + expect(mockEnv.mockState.filesystem.eigentBinDirExists).toBe(false) + + // Simulate directory creation + mockEnv.fsMock.mkdirSync('/mock/home/.eigent', { recursive: true }) + mockEnv.fsMock.mkdirSync('/mock/home/.eigent/bin', { recursive: true }) + + expect(mockEnv.mockState.filesystem.eigentDirExists).toBe(true) + expect(mockEnv.mockState.filesystem.eigentBinDirExists).toBe(true) + }) + + it('should handle file operations correctly', () => { + // Test version file operations + expect(mockEnv.mockState.filesystem.versionFileExists).toBe(true) + + // Write new version + mockEnv.fsMock.writeFileSync('/path/to/version.txt', '2.0.0') + expect(mockEnv.mockState.filesystem.versionFileContent).toBe('2.0.0') + + // Delete version file + mockEnv.fsMock.unlinkSync('/path/to/version.txt') + expect(mockEnv.mockState.filesystem.versionFileExists).toBe(false) + }) + }) +}) \ No newline at end of file diff --git a/test/unit/electron/main/windowLifecycle.test.ts b/test/unit/electron/main/windowLifecycle.test.ts new file mode 100644 index 00000000..8f19f2b8 --- /dev/null +++ b/test/unit/electron/main/windowLifecycle.test.ts @@ -0,0 +1,407 @@ +/** + * Tests for window event setup and lifecycle management in createWindow function + * Covers dev tools shortcuts, external link handling, before close handling, + * auto-update integration, webview manager, and file reader initialization + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setupMockEnvironment } from '../../../mocks/environmentMocks' + +describe('createWindow - Window Event Setup and Lifecycle', () => { + let mockEnv: ReturnType + let mockWebContents: any + let mockWindow: any + let mockFileReader: any + let mockWebViewManager: any + let mockUpdate: any + let mockMenu: any + + beforeEach(() => { + mockEnv = setupMockEnvironment() + + // Mock webContents + mockWebContents = { + on: vi.fn(), + once: vi.fn(), + executeJavaScript: vi.fn(), + send: vi.fn(), + loadURL: vi.fn(), + loadFile: vi.fn(), + openDevTools: vi.fn(), + toggleDevTools: vi.fn() + } + + // Mock window + mockWindow = { + webContents: mockWebContents, + reload: vi.fn() + } + + // Mock FileReader class + mockFileReader = vi.fn() + + // Mock WebViewManager class + mockWebViewManager = vi.fn().mockImplementation(() => ({ + createWebview: vi.fn() + })) + + // Mock update function + mockUpdate = vi.fn() + + // Mock Menu + mockMenu = { + setApplicationMenu: vi.fn() + } + + // Reset all mocks + vi.clearAllMocks() + }) + + afterEach(() => { + mockEnv.reset() + }) + + describe('FileReader and WebViewManager Initialization', () => { + it.skip('should create 8 webviews with correct IDs', () => { + const webViewManager = new mockWebViewManager(mockWindow) + const instance = mockWebViewManager.mock.instances[0] + + // Simulate the loop that creates webviews + for (let i = 1; i <= 8; i++) { + instance.createWebview(i === 1 ? undefined : i.toString()) + } + + expect(instance.createWebview).toHaveBeenCalledTimes(8) + expect(instance.createWebview).toHaveBeenNthCalledWith(1, undefined) + expect(instance.createWebview).toHaveBeenNthCalledWith(2, '2') + expect(instance.createWebview).toHaveBeenNthCalledWith(3, '3') + expect(instance.createWebview).toHaveBeenNthCalledWith(4, '4') + expect(instance.createWebview).toHaveBeenNthCalledWith(5, '5') + expect(instance.createWebview).toHaveBeenNthCalledWith(6, '6') + expect(instance.createWebview).toHaveBeenNthCalledWith(7, '7') + expect(instance.createWebview).toHaveBeenNthCalledWith(8, '8') + }) + }) + + describe('Window Event Listeners Setup', () => { + it('should disable application menu', () => { + // Simulate setupWindowEventListeners + mockMenu.setApplicationMenu(null) + + expect(mockMenu.setApplicationMenu).toHaveBeenCalledWith(null) + }) + + it('should set up application menu only once', () => { + // Simulate multiple calls to setupWindowEventListeners + mockMenu.setApplicationMenu(null) + mockMenu.setApplicationMenu(null) + + expect(mockMenu.setApplicationMenu).toHaveBeenCalledTimes(2) + expect(mockMenu.setApplicationMenu).toHaveBeenCalledWith(null) + }) + }) + + describe('DevTools Shortcuts Setup', () => { + it('should set up before-input-event listener for dev tools shortcuts', () => { + // Simulate setupDevToolsShortcuts + mockWebContents.on('before-input-event', expect.any(Function)) + + expect(mockWebContents.on).toHaveBeenCalledWith('before-input-event', expect.any(Function)) + }) + + it('should handle F12 key to toggle dev tools', () => { + let beforeInputCallback: any + + mockWebContents.on.mockImplementation((event: string, callback: any) => { + if (event === 'before-input-event') { + beforeInputCallback = callback + } + }) + + // Simulate setupDevToolsShortcuts + mockWebContents.on('before-input-event', (event: any, input: any) => { + if (input.key === 'F12' && input.type === 'keyDown') { + mockWebContents.toggleDevTools() + } + }) + + // Trigger F12 key + if (beforeInputCallback) { + const mockEvent = { preventDefault: vi.fn() } + const mockInput = { key: 'F12', type: 'keyDown' } + beforeInputCallback(mockEvent, mockInput) + } + + expect(mockWebContents.toggleDevTools).toHaveBeenCalled() + }) + + it('should handle Ctrl+Shift+I to toggle dev tools on Windows/Linux', () => { + let beforeInputCallback: any + + mockWebContents.on.mockImplementation((event: string, callback: any) => { + if (event === 'before-input-event') { + beforeInputCallback = callback + } + }) + + // Simulate setupDevToolsShortcuts + mockWebContents.on('before-input-event', (event: any, input: any) => { + if (input.control && input.shift && input.key.toLowerCase() === 'i' && input.type === 'keyDown') { + mockWebContents.toggleDevTools() + } + }) + + // Trigger Ctrl+Shift+I + if (beforeInputCallback) { + const mockEvent = { preventDefault: vi.fn() } + const mockInput = { + control: true, + shift: true, + key: 'I', + type: 'keyDown' + } + beforeInputCallback(mockEvent, mockInput) + } + + expect(mockWebContents.toggleDevTools).toHaveBeenCalled() + }) + + it('should handle Cmd+Shift+I to toggle dev tools on Mac', () => { + let beforeInputCallback: any + + mockWebContents.on.mockImplementation((event: string, callback: any) => { + if (event === 'before-input-event') { + beforeInputCallback = callback + } + }) + + // Simulate setupDevToolsShortcuts + mockWebContents.on('before-input-event', (event: any, input: any) => { + if (input.meta && input.shift && input.key.toLowerCase() === 'i' && input.type === 'keyDown') { + mockWebContents.toggleDevTools() + } + }) + + // Trigger Cmd+Shift+I + if (beforeInputCallback) { + const mockEvent = { preventDefault: vi.fn() } + const mockInput = { + meta: true, + shift: true, + key: 'I', + type: 'keyDown' + } + beforeInputCallback(mockEvent, mockInput) + } + + expect(mockWebContents.toggleDevTools).toHaveBeenCalled() + }) + + it('should not trigger dev tools on key up events', () => { + let beforeInputCallback: any + + mockWebContents.on.mockImplementation((event: string, callback: any) => { + if (event === 'before-input-event') { + beforeInputCallback = callback + } + }) + + // Simulate setupDevToolsShortcuts + mockWebContents.on('before-input-event', (event: any, input: any) => { + if (input.key === 'F12' && input.type === 'keyDown') { + mockWebContents.toggleDevTools() + } + }) + + // Trigger F12 key up (should not toggle) + if (beforeInputCallback) { + const mockEvent = { preventDefault: vi.fn() } + const mockInput = { key: 'F12', type: 'keyUp' } + beforeInputCallback(mockEvent, mockInput) + } + + expect(mockWebContents.toggleDevTools).not.toHaveBeenCalled() + }) + + it('should not trigger dev tools on wrong key combinations', () => { + let beforeInputCallback: any + + mockWebContents.on.mockImplementation((event: string, callback: any) => { + if (event === 'before-input-event') { + beforeInputCallback = callback + } + }) + + // Simulate setupDevToolsShortcuts + mockWebContents.on('before-input-event', (event: any, input: any) => { + if (input.control && input.shift && input.key.toLowerCase() === 'i' && input.type === 'keyDown') { + mockWebContents.toggleDevTools() + } + }) + + // Trigger wrong combination (Ctrl+I without Shift) + if (beforeInputCallback) { + const mockEvent = { preventDefault: vi.fn() } + const mockInput = { + control: true, + shift: false, + key: 'I', + type: 'keyDown' + } + beforeInputCallback(mockEvent, mockInput) + } + + expect(mockWebContents.toggleDevTools).not.toHaveBeenCalled() + }) + }) + + describe('Auto-Update Integration', () => { + it('should call update function with window reference', () => { + // Simulate auto-update setup + mockUpdate(mockWindow) + + expect(mockUpdate).toHaveBeenCalledWith(mockWindow) + }) + + it('should call update function only once', () => { + // Simulate auto-update setup + mockUpdate(mockWindow) + + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) + }) + + describe('Event Handler Organization', () => { + it('should set up event handlers in correct order', () => { + const eventSetupOrder: string[] = [] + + // Mock all the setup functions to track order + const setupWindowEventListeners = () => { + eventSetupOrder.push('windowEventListeners') + mockMenu.setApplicationMenu(null) + } + + const setupDevToolsShortcuts = () => { + eventSetupOrder.push('devToolsShortcuts') + mockWebContents.on('before-input-event', vi.fn()) + } + + const setupExternalLinkHandling = () => { + eventSetupOrder.push('externalLinkHandling') + } + + const handleBeforeClose = () => { + eventSetupOrder.push('beforeClose') + } + + // Simulate the order in createWindow + setupWindowEventListeners() + setupDevToolsShortcuts() + setupExternalLinkHandling() + handleBeforeClose() + + expect(eventSetupOrder).toEqual([ + 'windowEventListeners', + 'devToolsShortcuts', + 'externalLinkHandling', + 'beforeClose' + ]) + }) + }) + + describe('Window State Management', () => { + it('should handle window ready state correctly', async () => { + let didFinishLoadCallback: (() => void) | undefined + + // Mock the did-finish-load event listener + mockWebContents.once.mockImplementation((event: string, callback: () => void) => { + if (event === 'did-finish-load') { + didFinishLoadCallback = callback + } + }) + + // Simulate waiting for window ready + const windowReadyPromise = new Promise(resolve => { + mockWebContents.once('did-finish-load', () => { + resolve() + }) + }) + + // Trigger the event + if (didFinishLoadCallback) { + didFinishLoadCallback() + } + + // Should resolve without throwing + await expect(windowReadyPromise).resolves.toBeUndefined() + }) + + it('should log appropriate messages during window setup', () => { + // In a real test, you would verify that appropriate log messages are called + // This ensures the window setup process is properly logged + const mockLog = { + info: vi.fn(), + error: vi.fn() + } + + // Simulate logging calls that would happen during window setup + mockLog.info('Window content loaded, starting dependency check immediately...') + mockLog.info('.eigent directory structure ensured') + + expect(mockLog.info).toHaveBeenCalledWith( + 'Window content loaded, starting dependency check immediately...' + ) + expect(mockLog.info).toHaveBeenCalledWith( + '.eigent directory structure ensured' + ) + }) + }) + + describe('Integration Points', () => { + it('should properly coordinate between file reader and webview manager', () => { + const fileReader = new mockFileReader(mockWindow) + const webViewManager = new mockWebViewManager(mockWindow) + + // Both should be initialized with the same window + expect(mockFileReader).toHaveBeenCalledWith(mockWindow) + expect(mockWebViewManager).toHaveBeenCalledWith(mockWindow) + }) + + it('should handle window initialization errors gracefully', () => { + // Mock FileReader to throw during initialization + mockFileReader.mockImplementation(() => { + throw new Error('FileReader initialization failed') + }) + + // Should handle gracefully in real implementation + expect(() => { + try { + new mockFileReader(mockWindow) + } catch (error) { + // Log error but don't stop execution + console.error('FileReader initialization error:', error) + } + }).not.toThrow() + }) + }) + + describe('Memory Management', () => { + it('should properly clean up event listeners when window is destroyed', () => { + // In a real scenario, you would test that event listeners are removed + // when the window is closed to prevent memory leaks + + const mockRemoveListener = vi.fn() + mockWebContents.removeListener = mockRemoveListener + + // Simulate cleanup + const cleanup = () => { + mockWebContents.removeListener('before-input-event', vi.fn()) + mockWebContents.removeListener('dom-ready', vi.fn()) + } + + cleanup() + + expect(mockRemoveListener).toHaveBeenCalledTimes(2) + }) + }) +}) \ No newline at end of file diff --git a/test/unit/examples/installationFlow.test.ts b/test/unit/examples/installationFlow.test.ts new file mode 100644 index 00000000..de3f9717 --- /dev/null +++ b/test/unit/examples/installationFlow.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import React from 'react' +import { createTestEnvironment, waitForInstallationState } from '../../mocks/testUtils' +import { useInstallationStore } from '../../../src/store/installationStore' + +/** + * Example test file demonstrating how to use the test environment + * This shows all the main scenarios you wanted to test + */ +describe('Installation Flow Examples', () => { + let testEnv: ReturnType + + beforeEach(() => { + testEnv = createTestEnvironment() + useInstallationStore.getState().reset() + }) + + afterEach(() => { + testEnv.reset() + }) + + describe('Main Test Scenarios', () => { + it('should handle when .venv is removed', async () => { + // Set up scenario + testEnv.scenarios.venvRemoved() + + // Verify scenario is set up correctly + expect(testEnv.inspect.verifyScenario('venvRemoved')).toBe(true) + expect(testEnv.inspect.getInstallationState().venvExists).toBe(false) + + // Trigger installation + const result = await testEnv.electronAPI.checkAndInstallDepsOnUpdate() + + // Should trigger installation since .venv is missing + expect(result.success).toBe(true) + console.log(result); + + expect(result.message).toContain('Dependencies installed successfully') + }) + + it('should handle when version file is different', async () => { + // Set up version update scenario + testEnv.scenarios.versionUpdate('0.9.0', '1.0.0') + + // Verify scenario + expect(testEnv.inspect.verifyScenario('versionUpdate')).toBe(true) + + // Trigger installation + const result = await testEnv.electronAPI.checkAndInstallDepsOnUpdate() + + // Should install due to version mismatch + expect(result.success).toBe(true) + expect(result.message).toContain('Dependencies installed successfully after update') + }) + + it('should handle when uvicorn starts installing deps after page is loaded', async () => { + // Set up uvicorn dependency installation scenario + testEnv.scenarios.uvicornDepsInstall() + + const { result } = renderHook(() => useInstallationStore()) + + // Set up event listeners manually for this test + const startInstallation = result.current.startInstallation + const addLog = result.current.addLog + const setSuccess = result.current.setSuccess + const setError = result.current.setError + + // Set up the electron API event handlers to connect to the store + testEnv.electronAPI.onInstallDependenciesStart(() => { + act(() => { + startInstallation() + }) + }) + + testEnv.electronAPI.onInstallDependenciesLog((data: { type: string; data: string }) => { + act(() => { + addLog({ + type: data.type as 'stdout' | 'stderr', + data: data.data, + timestamp: new Date(), + }) + }) + }) + + testEnv.electronAPI.onInstallDependenciesComplete((data: { success: boolean; error?: string }) => { + act(() => { + if (data.success) { + setSuccess() + } else { + setError(data.error || 'Installation failed') + } + }) + }) + + // Simulate uvicorn startup that triggers dependency detection + await act(async () => { + // Use the electron mock's simulation methods instead of calling detectInstallationLogs directly + testEnv.electronAPI.simulateInstallationStart() + + // Wait a bit for state to update + await new Promise(resolve => setTimeout(resolve, 50)) + }) + + // Should start installation + expect(result.current.state).toBe('installing') + console.log("State after startInstalling() ", result.current); + + + // Simulate UV sync/run command being executed + await act(async () => { + testEnv.electronAPI.simulateInstallationLog('stdout', 'Resolved 45 packages in 2.1s') + testEnv.electronAPI.simulateInstallationLog('stdout', 'Downloaded 12 packages in 1.3s') + testEnv.electronAPI.simulateInstallationLog('stdout', 'Installing packages...') + + // Wait a bit for state to update + await new Promise(resolve => setTimeout(resolve, 50)) + }) + + // Should still be installing with logs + expect(result.current.state).toBe('installing') + expect(result.current.logs.length).toBeGreaterThan(0) + + // Simulate uvicorn completing successfully + await act(async () => { + testEnv.electronAPI.simulateInstallationLog('stdout', 'Uvicorn running on http://127.0.0.1:8000') + testEnv.electronAPI.simulateInstallationComplete(true) + await new Promise(resolve => setTimeout(resolve, 50)) + }) + + // Should complete successfully + expect(result.current.state).toBe('completed') + }) + }) + + describe('All Installation UI States', () => { + it('should test idle state', () => { + const { result } = renderHook(() => useInstallationStore()) + + expect(result.current.state).toBe('idle') + expect(result.current.progress).toBe(20) + expect(result.current.logs).toEqual([]) + expect(result.current.error).toBeUndefined() + expect(result.current.isVisible).toBe(false) + }) + + it('should test installing state', async () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + }) + + expect(result.current.state).toBe('installing') + expect(result.current.isVisible).toBe(true) + expect(result.current.progress).toBe(20) + }) + + it('should test error state', async () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + }) + + act(() => { + result.current.setError('Installation failed') + }) + + expect(result.current.state).toBe('error') + expect(result.current.error).toBe('Installation failed') + expect(result.current.logs).toHaveLength(1) + expect(result.current.logs[0].type).toBe('stderr') + }) + + it('should test completed state', async () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + }) + + act(() => { + result.current.setSuccess() + }) + + expect(result.current.state).toBe('completed') + expect(result.current.progress).toBe(100) + }) + + it('should test retry after error', async () => { + const { result } = renderHook(() => useInstallationStore()) + + // Start and fail installation + act(() => { + result.current.startInstallation() + result.current.setError('Installation failed') + }) + + expect(result.current.state).toBe('error') + + // Retry installation + act(() => { + result.current.retryInstallation() + }) + + expect(result.current.state).toBe('installing') + expect(result.current.error).toBeUndefined() + expect(result.current.logs).toEqual([]) + }) + }) + + describe('Complete Installation Flows', () => { + it('should handle fresh installation flow', async () => { + testEnv.scenarios.freshInstall() + + const { result } = renderHook(() => useInstallationStore()) + + // Start installation + await act(async () => { + await result.current.performInstallation() + }) + + // Should complete successfully + await waitForInstallationState(() => result.current, 'completed', 1000) + expect(result.current.state).toBe('completed') + }) + + it('should handle installation with simulation', async () => { + const { result } = renderHook(() => useInstallationStore()) + + // Start installation manually + act(() => { + result.current.startInstallation() + }) + + testEnv.electronAPI.onInstallDependenciesStart(() => { + act(() => { + result.current.startInstallation() + }) + }) + + testEnv.electronAPI.onInstallDependenciesLog((data: { type: string; data: string }) => { + act(() => { + result.current.addLog({ + type: data.type as 'stdout' | 'stderr', + data: data.data, + timestamp: new Date(), + }) + }) + }) + + testEnv.electronAPI.onInstallDependenciesComplete((data: { success: boolean; error?: string }) => { + act(() => { + if (data.success) { + result.current.setSuccess() + } else { + result.current.setError(data.error || 'Installation failed') + } + }) + }) + + console.log("State before success installation, ", result.current) + // Simulate successful installation flow + await testEnv.simulate.successfulInstallation(50) + + // Wait for completion + await waitForInstallationState(() => result.current, 'completed', 1000) + console.log("State after success installation, ", result.current) + + expect(result.current.state).toBe('completed') + expect(result.current.logs.length).toBeGreaterThan(0) + }) + + it('should handle installation failure with retry', async () => { + const { result } = renderHook(() => useInstallationStore()) + + // Start installation + act(() => { + result.current.startInstallation() + }) + + testEnv.electronAPI.onInstallDependenciesStart(() => { + act(() => { + result.current.startInstallation() + }) + }) + + testEnv.electronAPI.onInstallDependenciesLog((data: { type: string; data: string }) => { + act(() => { + result.current.addLog({ + type: data.type as 'stdout' | 'stderr', + data: data.data, + timestamp: new Date(), + }) + }) + }) + + testEnv.electronAPI.onInstallDependenciesComplete((data: { success: boolean; error?: string }) => { + act(() => { + if (data.success) { + result.current.setSuccess() + } else { + result.current.setError(data.error || 'Installation failed') + } + }) + }) + + // Simulate failed installation + await testEnv.simulate.failedInstallation(50, 'Network error') + + // Wait for error state + await waitForInstallationState(() => result.current, 'error', 1000) + console.log("State after event listened", result.current); + + expect(result.current.state).toBe('error') + expect(result.current.error).toBe('Network error') + + // Fix the environment and retry + testEnv.scenarios.allGood() + + act(() => { + result.current.retryInstallation() + }) + + // Simulate successful retry + await testEnv.simulate.successfulInstallation(50) + + // Should complete successfully + await waitForInstallationState(() => result.current, 'completed', 1000) + expect(result.current.state).toBe('completed') + }) + }) + + describe('State Inspection', () => { + it('should provide useful state inspection', () => { + testEnv.scenarios.freshInstall() + + const state = testEnv.inspect.getInstallationState() + + expect(state.venvExists).toBe(false) + expect(state.toolsAvailable).toBe(false) + expect(state.isInstalling).toBe(false) + expect(state.hasLockFiles).toBe(false) + }) + + it('should verify scenario setup', () => { + testEnv.scenarios.versionUpdate() + expect(testEnv.inspect.verifyScenario('versionUpdate')).toBe(true) + + testEnv.scenarios.freshInstall() + expect(testEnv.inspect.verifyScenario('freshInstall')).toBe(true) + expect(testEnv.inspect.verifyScenario('versionUpdate')).toBe(false) + }) + }) + + describe('Environment Changes During Tests', () => { + it('should allow changing environment state during test', async () => { + // Start with fresh install + testEnv.scenarios.freshInstall() + expect(testEnv.inspect.getInstallationState().venvExists).toBe(false) + + // Simulate .venv being created + testEnv.electronAPI.mockState.venvExists = true + testEnv.mockEnv.mockState.filesystem.venvExists = true + + expect(testEnv.inspect.getInstallationState().venvExists).toBe(true) + + // Simulate version file being created + testEnv.electronAPI.simulateVersionChange('1.0.0') + testEnv.mockEnv.mockState.filesystem.versionFileExists = true + testEnv.mockEnv.mockState.filesystem.versionFileContent = '1.0.0' + + // Now environment should be in 'all good' state + const state = testEnv.inspect.getInstallationState() + expect(state.venvExists).toBe(true) + }) + }) +}) + +// You can run this test file with: +// npm test test/unit/examples/installationFlow.test.ts \ No newline at end of file diff --git a/test/unit/hooks/useInstallationSetup.test.ts b/test/unit/hooks/useInstallationSetup.test.ts new file mode 100644 index 00000000..b2412202 --- /dev/null +++ b/test/unit/hooks/useInstallationSetup.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useInstallationSetup } from '../../../src/hooks/useInstallationSetup' +import { useInstallationStore } from '../../../src/store/installationStore' +import { useAuthStore } from '../../../src/store/authStore' +import { setupElectronMocks, TestScenarios, type MockedElectronAPI } from '../../mocks/electronMocks' + +// Mock the stores +vi.mock('../../../src/store/installationStore') +vi.mock('../../../src/store/authStore') + +describe('useInstallationSetup Hook', () => { + let electronAPI: MockedElectronAPI + let mockInstallationStore: any + let mockAuthStore: any + + beforeEach(() => { + // Set up electron mocks + const mocks = setupElectronMocks() + electronAPI = mocks.electronAPI + + // Mock installation store + mockInstallationStore = { + startInstallation: vi.fn(), + addLog: vi.fn(), + setSuccess: vi.fn(), + setError: vi.fn(), + } + + // Mock auth store + mockAuthStore = { + initState: 'done', + setInitState: vi.fn(), + } + + // Set up mock implementations + vi.mocked(useInstallationStore).mockImplementation((selector: any) => { + if (typeof selector === 'function') { + return selector(mockInstallationStore) + } + return mockInstallationStore + }) + + vi.mocked(useAuthStore).mockReturnValue(mockAuthStore) + + // Mock console.log to avoid noise in tests + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.clearAllMocks() + electronAPI.reset() + }) + + describe('Initial Setup', () => { + it('should check tool installation status on mount', async () => { + // Mock IPC response for tool check + electronAPI.mockState.toolInstalled = true + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('check-tool-installed') + }) + }) + + it('should check backend installation status on mount', async () => { + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(electronAPI.getInstallationStatus).toHaveBeenCalled() + }) + }) + + it('should start installation if backend installation is in progress', async () => { + electronAPI.mockState.isInstalling = true + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(mockInstallationStore.startInstallation).toHaveBeenCalled() + }) + }) + + it('should set initState to carousel if tool is not installed', async () => { + // Mock tool not installed + window.ipcRenderer.invoke = vi.fn().mockResolvedValue({ + success: true, + isInstalled: false + }) + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(mockAuthStore.setInitState).toHaveBeenCalledWith('carousel') + }) + }) + }) + + describe('Electron IPC Event Handling', () => { + it('should register all required event listeners', () => { + renderHook(() => useInstallationSetup()) + + expect(electronAPI.onInstallDependenciesStart).toHaveBeenCalled() + expect(electronAPI.onInstallDependenciesLog).toHaveBeenCalled() + expect(electronAPI.onInstallDependenciesComplete).toHaveBeenCalled() + }) + + it('should handle install-dependencies-start event', () => { + renderHook(() => useInstallationSetup()) + + // Get the registered callback + const startCallback = electronAPI.onInstallDependenciesStart.mock.calls[0][0] + + act(() => { + startCallback() + }) + + expect(mockInstallationStore.startInstallation).toHaveBeenCalled() + }) + + it('should handle install-dependencies-log event', () => { + renderHook(() => useInstallationSetup()) + + // Get the registered callback + const logCallback = electronAPI.onInstallDependenciesLog.mock.calls[0][0] + const logData = { type: 'stdout', data: 'Installing packages...' } + + act(() => { + logCallback(logData) + }) + + expect(mockInstallationStore.addLog).toHaveBeenCalledWith({ + type: 'stdout', + data: 'Installing packages...', + timestamp: expect.any(Date), + }) + }) + + it('should handle install-dependencies-complete event with success', () => { + renderHook(() => useInstallationSetup()) + + // Get the registered callback + const completeCallback = electronAPI.onInstallDependenciesComplete.mock.calls[0][0] + const completeData = { success: true } + + act(() => { + completeCallback(completeData) + }) + + expect(mockInstallationStore.setSuccess).toHaveBeenCalled() + expect(mockAuthStore.setInitState).toHaveBeenCalledWith('done') + }) + + it('should handle install-dependencies-complete event with failure', () => { + renderHook(() => useInstallationSetup()) + + // Get the registered callback + const completeCallback = electronAPI.onInstallDependenciesComplete.mock.calls[0][0] + const completeData = { success: false, error: 'Installation failed' } + + act(() => { + completeCallback(completeData) + }) + + expect(mockInstallationStore.setError).toHaveBeenCalledWith('Installation failed') + expect(mockAuthStore.setInitState).not.toHaveBeenCalledWith('done') + }) + + it('should handle complete event without error message', () => { + renderHook(() => useInstallationSetup()) + + const completeCallback = electronAPI.onInstallDependenciesComplete.mock.calls[0][0] + const completeData = { success: false } + + act(() => { + completeCallback(completeData) + }) + + expect(mockInstallationStore.setError).toHaveBeenCalledWith('Installation failed') + }) + }) + + describe('Event Listener Cleanup', () => { + it('should remove all event listeners on unmount', () => { + const { unmount } = renderHook(() => useInstallationSetup()) + + unmount() + + expect(electronAPI.removeAllListeners).toHaveBeenCalledWith('install-dependencies-start') + expect(electronAPI.removeAllListeners).toHaveBeenCalledWith('install-dependencies-log') + expect(electronAPI.removeAllListeners).toHaveBeenCalledWith('install-dependencies-complete') + }) + }) + + describe('Test Scenarios Integration', () => { + it('should handle fresh installation scenario', async () => { + TestScenarios.freshInstall(electronAPI) + + // Mock tool not installed + window.ipcRenderer.invoke = vi.fn().mockResolvedValue({ + success: true, + isInstalled: false + }) + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(mockAuthStore.setInitState).toHaveBeenCalledWith('carousel') + }) + }) + + it('should handle version update scenario', async () => { + TestScenarios.versionUpdate(electronAPI) + + renderHook(() => useInstallationSetup()) + + // Simulate version update detection and installation start + electronAPI.simulateInstallationStart() + + await vi.waitFor(() => { + expect(mockInstallationStore.startInstallation).toHaveBeenCalled() + }) + }) + + it('should handle venv removed scenario', async () => { + TestScenarios.venvRemoved(electronAPI) + electronAPI.mockState.isInstalling = true + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(mockInstallationStore.startInstallation).toHaveBeenCalled() + }) + }) + + it('should handle installation in progress scenario', async () => { + TestScenarios.installationInProgress(electronAPI) + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(mockInstallationStore.startInstallation).toHaveBeenCalled() + }) + }) + + it('should handle uvicorn startup with dependency installation', async () => { + TestScenarios.uvicornDepsInstall(electronAPI) + + renderHook(() => useInstallationSetup()) + + // Simulate uvicorn detecting and installing dependencies + act(() => { + electronAPI.simulateUvicornStartup() + }) + + await vi.waitFor(() => { + expect(mockInstallationStore.startInstallation).toHaveBeenCalled() + }) + + // Should receive logs and completion + await vi.waitFor(() => { + expect(mockInstallationStore.addLog).toHaveBeenCalled() + expect(mockInstallationStore.setSuccess).toHaveBeenCalled() + }) + }) + }) + + describe('Error Handling', () => { + it('should handle tool installation check failure', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + window.ipcRenderer.invoke = vi.fn().mockRejectedValue(new Error('IPC failed')) + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[useInstallationSetup] Tool installation check failed:', + expect.any(Error) + ) + }) + + consoleErrorSpy.mockRestore() + }) + + it('should handle installation status check failure', async () => { + electronAPI.getInstallationStatus.mockRejectedValue(new Error('Status check failed')) + + renderHook(() => useInstallationSetup()) + + // Should not crash, should handle the error gracefully + await vi.waitFor(() => { + expect(electronAPI.getInstallationStatus).toHaveBeenCalled() + }) + }) + }) + + describe('Multiple Hook Instances', () => { + it('should handle multiple hook instances without conflicts', () => { + const { result: hook1 } = renderHook(() => useInstallationSetup()) + const { result: hook2 } = renderHook(() => useInstallationSetup()) + + // Both hooks should register listeners + expect(electronAPI.onInstallDependenciesStart).toHaveBeenCalledTimes(2) + expect(electronAPI.onInstallDependenciesLog).toHaveBeenCalledTimes(2) + expect(electronAPI.onInstallDependenciesComplete).toHaveBeenCalledTimes(2) + }) + }) + + describe('State Dependencies', () => { + it('should react to initState changes', async () => { + mockAuthStore.initState = 'carousel' + + const { rerender } = renderHook(() => useInstallationSetup()) + + // Change initState to 'done' + mockAuthStore.initState = 'done' + rerender() + + // Should check tool installation again + await vi.waitFor(() => { + expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('check-tool-installed') + }) + }) + + it('should not set carousel state if initState is not done', async () => { + mockAuthStore.initState = 'loading' + + window.ipcRenderer.invoke = vi.fn().mockResolvedValue({ + success: true, + isInstalled: false + }) + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('check-tool-installed') + }) + + // Should not call setInitState because initState is not 'done' + expect(mockAuthStore.setInitState).not.toHaveBeenCalledWith('carousel') + }) + }) + + describe('Console Logging', () => { + it('should log installation status check', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + renderHook(() => useInstallationSetup()) + + await vi.waitFor(() => { + expect(consoleLogSpy).toHaveBeenCalledWith( + '[useInstallationSetup] Installation status check:', + expect.any(Object) + ) + }) + + consoleLogSpy.mockRestore() + }) + + it('should log when installation listeners are registered', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + renderHook(() => useInstallationSetup()) + + expect(consoleLogSpy).toHaveBeenCalledWith( + '[useInstallationSetup] Installation listeners registered' + ) + + consoleLogSpy.mockRestore() + }) + + it('should log install complete events', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + renderHook(() => useInstallationSetup()) + + const completeCallback = electronAPI.onInstallDependenciesComplete.mock.calls[0][0] + + act(() => { + completeCallback({ success: true }) + }) + + expect(consoleLogSpy).toHaveBeenCalledWith( + '[useInstallationSetup] Install complete event received:', + { success: true } + ) + + consoleLogSpy.mockRestore() + }) + }) +}) \ No newline at end of file diff --git a/test/unit/store/installationStore.test.ts b/test/unit/store/installationStore.test.ts new file mode 100644 index 00000000..5db11d72 --- /dev/null +++ b/test/unit/store/installationStore.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useInstallationStore, type InstallationState } from '../../../src/store/installationStore' +import { setupElectronMocks, TestScenarios, type MockedElectronAPI } from '../../mocks/electronMocks' + +// Mock the authStore import since it's imported dynamically +vi.mock('../../../src/store/authStore', () => ({ + useAuthStore: { + getState: () => ({ + setInitState: vi.fn() + }) + } +})) + +describe('Installation Store', () => { + let electronAPI: MockedElectronAPI + let mockSetInitState: ReturnType + + beforeEach(async () => { + // Set up electron mocks + const mocks = setupElectronMocks() + electronAPI = mocks.electronAPI + + // Mock the authStore + const { useAuthStore } = await import('../../../src/store/authStore') + mockSetInitState = vi.fn() + useAuthStore.getState = vi.fn().mockReturnValue({ + setInitState: mockSetInitState + }) + + // Reset the store to initial state + useInstallationStore.getState().reset() + }) + + afterEach(() => { + vi.clearAllMocks() + electronAPI.reset() + }) + + describe('Initial State', () => { + it('should have correct initial state', () => { + const { result } = renderHook(() => useInstallationStore()) + + expect(result.current.state).toBe('idle') + expect(result.current.progress).toBe(20) + expect(result.current.logs).toEqual([]) + expect(result.current.error).toBeUndefined() + expect(result.current.isVisible).toBe(false) + }) + }) + + describe('State Transitions', () => { + it('should transition from idle to installing when startInstallation is called', () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + }) + + expect(result.current.state).toBe('installing') + expect(result.current.progress).toBe(20) + expect(result.current.logs).toEqual([]) + expect(result.current.error).toBeUndefined() + expect(result.current.isVisible).toBe(true) + }) + + it('should transition to completed when setSuccess is called', () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + }) + + act(() => { + result.current.setSuccess() + }) + + expect(result.current.state).toBe('completed') + expect(result.current.progress).toBe(100) + }) + + it('should transition to error when setError is called', () => { + const { result } = renderHook(() => useInstallationStore()) + const errorMessage = 'Installation failed' + + act(() => { + result.current.startInstallation() + }) + + act(() => { + result.current.setError(errorMessage) + }) + + expect(result.current.state).toBe('error') + expect(result.current.error).toBe(errorMessage) + expect(result.current.logs).toHaveLength(1) + expect(result.current.logs[0].type).toBe('stderr') + expect(result.current.logs[0].data).toBe(errorMessage) + }) + + it('should reset to installing state when retryInstallation is called', () => { + const { result } = renderHook(() => useInstallationStore()) + + // First, set error state + act(() => { + result.current.startInstallation() + }) + + act(() => { + result.current.setError('Some error') + }) + + expect(result.current.state).toBe('error') + + // Then retry + act(() => { + result.current.retryInstallation() + }) + + expect(result.current.state).toBe('installing') + expect(result.current.logs).toEqual([]) + expect(result.current.error).toBeUndefined() + expect(result.current.isVisible).toBe(true) + }) + }) + + describe('Log Management', () => { + it('should add logs and update progress', () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + }) + + const initialProgress = result.current.progress + + act(() => { + result.current.addLog({ + type: 'stdout', + data: 'Installing package...', + timestamp: new Date() + }) + }) + + expect(result.current.logs).toHaveLength(1) + expect(result.current.logs[0].type).toBe('stdout') + expect(result.current.logs[0].data).toBe('Installing package...') + expect(result.current.progress).toBe(initialProgress + 5) + }) + + it('should not exceed 90% progress when adding logs', () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + }) + + // Add many logs to test progress cap + act(() => { + for (let i = 0; i < 20; i++) { + result.current.addLog({ + type: 'stdout', + data: `Log entry ${i}`, + timestamp: new Date() + }) + } + }) + + expect(result.current.progress).toBe(90) + expect(result.current.logs).toHaveLength(20) + }) + }) + + describe('Installation Flow Integration', () => { + it('should handle successful installation flow', async () => { + TestScenarios.versionUpdate(electronAPI) + + const { result } = renderHook(() => useInstallationStore()) + + // Start installation + await act(async () => { + await result.current.performInstallation() + }) + + // Wait for the mocked installation to complete + await vi.waitFor(() => { + expect(result.current.state).toBe('completed') + }, { timeout: 1000 }) + + expect(electronAPI.checkAndInstallDepsOnUpdate).toHaveBeenCalled() + expect(mockSetInitState).toHaveBeenCalledWith('done') + }) + + it('should handle installation failure', async () => { + TestScenarios.installationError(electronAPI) + + const { result } = renderHook(() => useInstallationStore()) + + await act(async () => { + await result.current.performInstallation() + }) + + // Wait for the mocked installation to fail + await vi.waitFor(() => { + expect(result.current.state).toBe('error') + }, { timeout: 1000 }) + + expect(result.current.error).toBe('Installation failed') + }) + + it('should handle fresh installation scenario', async () => { + TestScenarios.freshInstall(electronAPI) + + const { result } = renderHook(() => useInstallationStore()) + + await act(async () => { + await result.current.performInstallation() + }) + + await vi.waitFor(() => { + expect(result.current.state).toBe('completed') + }, { timeout: 1000 }) + + expect(electronAPI.checkAndInstallDepsOnUpdate).toHaveBeenCalled() + }) + }) + + describe('Log Export', () => { + it('should export logs successfully', async () => { + const { result } = renderHook(() => useInstallationStore()) + + // Mock window.location.href + const originalLocation = window.location + Object.defineProperty(window, 'location', { + value: { href: '' }, + writable: true + }) + + // Mock alert + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}) + + await act(async () => { + await result.current.exportLog() + }) + + expect(electronAPI.exportLog).toHaveBeenCalled() + expect(alertSpy).toHaveBeenCalledWith('Log saved: /mock/path/to/log.txt') + expect(window.location.href).toBe('https://github.com/eigent-ai/eigent/issues/new/choose') + + // Restore + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true + }) + alertSpy.mockRestore() + }) + + it('should handle export failure', async () => { + electronAPI.exportLog.mockResolvedValue({ + success: false, + error: 'Export failed' + }) + + const { result } = renderHook(() => useInstallationStore()) + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}) + + await act(async () => { + await result.current.exportLog() + }) + + expect(alertSpy).toHaveBeenCalledWith('Export cancelled: Export failed') + alertSpy.mockRestore() + }) + }) + + describe('Computed Selectors', () => { + it('useLatestLog should return the most recent log', () => { + const { result: storeResult } = renderHook(() => useInstallationStore()) + const { result: latestLogResult } = renderHook(() => useInstallationStore((state: any) => + state.logs[state.logs.length - 1] + )) + + expect(latestLogResult.current).toBeUndefined() + + act(() => { + storeResult.current.startInstallation() + storeResult.current.addLog({ + type: 'stdout', + data: 'First log', + timestamp: new Date() + }) + storeResult.current.addLog({ + type: 'stderr', + data: 'Latest log', + timestamp: new Date() + }) + }) + + expect(latestLogResult.current.data).toBe('Latest log') + expect(latestLogResult.current.type).toBe('stderr') + }) + + it('useInstallationStatus should return correct status', () => { + const { result: storeResult } = renderHook(() => useInstallationStore()) + const { result: statusResult } = renderHook(() => { + const state = useInstallationStore((state: any) => state.state) + const isVisible = useInstallationStore((state: any) => state.isVisible) + + return { + isInstalling: state === 'installing', + installationState: state, + shouldShowInstallScreen: isVisible && state !== 'completed', + isInstallationComplete: state === 'completed', + canRetry: state === 'error', + } + }) + + // Initial state + expect(statusResult.current.isInstalling).toBe(false) + expect(statusResult.current.installationState).toBe('idle') + expect(statusResult.current.shouldShowInstallScreen).toBe(false) + expect(statusResult.current.isInstallationComplete).toBe(false) + expect(statusResult.current.canRetry).toBe(false) + + // Installing state + act(() => { + storeResult.current.startInstallation() + }) + + expect(statusResult.current.isInstalling).toBe(true) + expect(statusResult.current.shouldShowInstallScreen).toBe(true) + expect(statusResult.current.canRetry).toBe(false) + + // Error state + act(() => { + storeResult.current.setError('Some error') + }) + + expect(statusResult.current.isInstalling).toBe(false) + expect(statusResult.current.canRetry).toBe(true) + + // Completed state + act(() => { + storeResult.current.setSuccess() + }) + + expect(statusResult.current.isInstallationComplete).toBe(true) + expect(statusResult.current.shouldShowInstallScreen).toBe(false) + }) + }) + + describe('Edge Cases', () => { + it('should handle multiple rapid state changes', () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.startInstallation() + result.current.setError('Error 1') + result.current.retryInstallation() + result.current.setSuccess() + }) + + expect(result.current.state).toBe('completed') + expect(result.current.progress).toBe(100) + }) + + it('should handle visibility changes correctly', () => { + const { result } = renderHook(() => useInstallationStore()) + + expect(result.current.isVisible).toBe(false) + + act(() => { + result.current.setVisible(true) + }) + + expect(result.current.isVisible).toBe(true) + + act(() => { + result.current.completeSetup() + }) + + expect(result.current.state).toBe('completed') + expect(result.current.isVisible).toBe(false) + }) + + it('should handle manual progress updates', () => { + const { result } = renderHook(() => useInstallationStore()) + + act(() => { + result.current.updateProgress(75) + }) + + expect(result.current.progress).toBe(75) + }) + }) + + describe('Installation State Sequence', () => { + it('should follow correct state sequence for normal installation', async () => { + const { result } = renderHook(() => useInstallationStore()) + const states: InstallationState[] = [] + + // Subscribe to state changes + useInstallationStore.subscribe((state: any) => { + states.push(state.state) + }) + + await act(async () => { + await result.current.performInstallation() + }) + + await vi.waitFor(() => { + expect(result.current.state).toBe('completed') + }, { timeout: 1000 }) + + // Should have progressed through: idle -> installing -> completed + expect(states).toContain('installing') + expect(states).toContain('completed') + }) + }) +}) \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index afc219c9..5592cc87 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,7 +23,6 @@ export default defineConfig({ 'test/', 'dist/', 'dist-electron/', - 'electron/', 'build/', '**/*.d.ts', '**/*.config.*', From 6637dde2b7f5b7f725830e8bed281ac7d0d1c549 Mon Sep 17 00:00:00 2001 From: Wendong-Fan Date: Mon, 6 Oct 2025 13:09:11 +0800 Subject: [PATCH 2/3] update test --- test/mocks/electronMocks.ts | 29 +++++++++++++++++++++++++++-- test/mocks/environmentMocks.ts | 9 ++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/test/mocks/electronMocks.ts b/test/mocks/electronMocks.ts index 2a4543f1..c2e28a91 100644 --- a/test/mocks/electronMocks.ts +++ b/test/mocks/electronMocks.ts @@ -291,6 +291,19 @@ export function createElectronAPIMock(): MockedElectronAPI { }, 100) }, + simulateEnvCorruption: () => { + mockState.envFileExists = true + mockState.envContent = 'INVALID_ENV_CONTENT\n# === MCP INTEGRATION ENV START ===\nBROKEN' + }, + + simulateUserEmailChange: (email: string) => { + mockState.userEmail = email + }, + + simulateMcpConfigMissing: () => { + mockState.mcpRemoteConfigExists = false + }, + reset: () => { Object.assign(mockState, { venvExists: true, @@ -302,13 +315,20 @@ export function createElectronAPIMock(): MockedElectronAPI { uvicornStarting: false, toolInstalled: true, allowForceInstall: false, + // Reset environment-related state + envFileExists: true, + envContent: 'MOCK_VAR=mock_value\n# === MCP INTEGRATION ENV START ===\nMCP_KEY=test_value\n# === MCP INTEGRATION ENV END ===', + eigentDirExists: true, + userEmail: 'test@example.com', + mcpRemoteConfigExists: true, + hasToken: true, }) - + // Clear all listeners installStartListeners.length = 0 installLogListeners.length = 0 installCompleteListeners.length = 0 - + // Reset all mocks electronAPI.checkAndInstallDepsOnUpdate.mockClear() electronAPI.getInstallationStatus.mockClear() @@ -317,6 +337,11 @@ export function createElectronAPIMock(): MockedElectronAPI { electronAPI.onInstallDependenciesLog.mockClear() electronAPI.onInstallDependenciesComplete.mockClear() electronAPI.removeAllListeners.mockClear() + electronAPI.getEnvPath.mockClear() + electronAPI.updateEnvBlock.mockClear() + electronAPI.removeEnvKey.mockClear() + electronAPI.getEmailFolderPath.mockClear() + electronAPI.parseEnvBlock.mockClear() } } diff --git a/test/mocks/environmentMocks.ts b/test/mocks/environmentMocks.ts index 3a2d599a..92229db0 100644 --- a/test/mocks/environmentMocks.ts +++ b/test/mocks/environmentMocks.ts @@ -445,7 +445,14 @@ export function createProcessUtilsMock() { ) utilsMock.runInstallScript.mockImplementation(async (scriptPath: string) => { - // Simulate successful script execution by default + // Simulate successful script execution and update binary state + if (scriptPath.includes('install-uv')) { + mockState.filesystem.binariesExist['uv'] = true + mockState.processes.uvAvailable = true + } else if (scriptPath.includes('install-bun')) { + mockState.filesystem.binariesExist['bun'] = true + mockState.processes.bunAvailable = true + } return true }) From 14c968ad45eda733adf9f77f80a16ae108e198ce Mon Sep 17 00:00:00 2001 From: Wendong-Fan Date: Mon, 6 Oct 2025 13:27:24 +0800 Subject: [PATCH 3/3] enhance: update test --- src/components/ChatBox/index.tsx | 22 +++++++------- src/hooks/useInstallationSetup.ts | 20 +++++++------ test/setup.ts | 30 ++++++++++++++++++++ test/unit/components/ChatBox.test.tsx | 10 +++++++ test/unit/hooks/useInstallationSetup.test.ts | 10 +++++++ 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 2ce48b09..45d29ee6 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -50,16 +50,18 @@ export default function ChatBox(): JSX.Element { }) .catch((err) => console.error("Failed to fetch settings:", err)); - proxyFetchGet("/api/configs").then((configsRes) => { - const configs = Array.isArray(configsRes) ? configsRes : []; - const _hasApiKey = configs.find( - (item) => item.config_name === "GOOGLE_API_KEY" - ); - const _hasApiId = configs.find( - (item) => item.config_name === "SEARCH_ENGINE_ID" - ); - if (_hasApiKey && _hasApiId) setHasSearchKey(true); - }); + proxyFetchGet("/api/configs") + .then((configsRes) => { + const configs = Array.isArray(configsRes) ? configsRes : []; + const _hasApiKey = configs.find( + (item) => item.config_name === "GOOGLE_API_KEY" + ); + const _hasApiId = configs.find( + (item) => item.config_name === "SEARCH_ENGINE_ID" + ); + if (_hasApiKey && _hasApiId) setHasSearchKey(true); + }) + .catch((err) => console.error("Failed to fetch configs:", err)); }, []); // Refresh privacy status when dialog closes diff --git a/src/hooks/useInstallationSetup.ts b/src/hooks/useInstallationSetup.ts index d6b5240e..16e19275 100644 --- a/src/hooks/useInstallationSetup.ts +++ b/src/hooks/useInstallationSetup.ts @@ -41,16 +41,20 @@ export const useInstallationSetup = () => { }; const checkBackendStatus = async() => { - // Also check if installation is currently in progress - const installationStatus = await window.electronAPI.getInstallationStatus(); - console.log('[useInstallationSetup] Installation status check:', installationStatus); - - if (installationStatus.success && installationStatus.isInstalling) { - console.log('[useInstallationSetup] Installation in progress, starting frontend state'); - startInstallation(); + try { + // Also check if installation is currently in progress + const installationStatus = await window.electronAPI.getInstallationStatus(); + console.log('[useInstallationSetup] Installation status check:', installationStatus); + + if (installationStatus.success && installationStatus.isInstalling) { + console.log('[useInstallationSetup] Installation in progress, starting frontend state'); + startInstallation(); + } + } catch (err) { + console.error('[useInstallationSetup] Failed to check installation status:', err); } } - + checkToolInstalled(); checkBackendStatus(); }, [initState, setInitState, startInstallation]); diff --git a/test/setup.ts b/test/setup.ts index 2bd019a8..54d7c529 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,6 +2,36 @@ import { vi } from 'vitest' import '@testing-library/jest-dom' +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + // Map translation keys to English text + const translations: Record = { + 'chat.welcome-to-eigent': 'Welcome to Eigent', + 'chat.how-can-i-help-you': 'How can I help you today?', + 'chat.palm-springs-tennis-trip-planner': 'Palm Springs Tennis Trip Planner', + 'chat.bank-transfer-csv-analysis-and-visualization': 'Bank Transfer CSV Analysis and Visualization', + 'chat.find-duplicate-files-in-downloads-folder': 'Find Duplicate Files in Downloads Folder', + 'setting.search-mcp': 'Search MCPs', + 'chat.by-messaging-eigent': 'By messaging Eigent, you agree to our', + 'chat.terms-of-use': 'Terms of Use', + 'chat.and': 'and', + 'chat.privacy-policy': 'Privacy Policy', + 'chat.palm-springs-tennis-trip-planner-message': 'Plan a tennis trip to Palm Springs', + 'chat.bank-transfer-csv-analysis-and-visualization-message': 'Analyze and visualize bank transfer CSV', + 'chat.find-duplicate-files-in-downloads-folder-message': 'Find duplicate files in Downloads folder', + 'chat.no-reply-received-task-continue': 'No reply received, task will continue', + } + return translations[key] || key + }, + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), +})) + // Mock Electron APIs if needed global.electronAPI = { // Add mock implementations for electron preload APIs diff --git a/test/unit/components/ChatBox.test.tsx b/test/unit/components/ChatBox.test.tsx index ca13746b..c19bc5f0 100644 --- a/test/unit/components/ChatBox.test.tsx +++ b/test/unit/components/ChatBox.test.tsx @@ -737,11 +737,21 @@ describe('ChatBox Component', () => { }) it('should handle privacy fetch errors', async () => { + // Mock console.error to suppress expected error logs + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + // Mock the fetch to reject properly for testing error handling mockProxyFetchGet.mockRejectedValue(new Error('Privacy fetch failed')) // Rendering should not throw even with fetch error expect(() => renderChatBox()).not.toThrow() + + // Wait for the promise to settle + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + consoleErrorSpy.mockRestore() }) }) }) diff --git a/test/unit/hooks/useInstallationSetup.test.ts b/test/unit/hooks/useInstallationSetup.test.ts index b2412202..afc03e04 100644 --- a/test/unit/hooks/useInstallationSetup.test.ts +++ b/test/unit/hooks/useInstallationSetup.test.ts @@ -286,6 +286,9 @@ describe('useInstallationSetup Hook', () => { }) it('should handle installation status check failure', async () => { + // Mock console.error to suppress expected error logs + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + electronAPI.getInstallationStatus.mockRejectedValue(new Error('Status check failed')) renderHook(() => useInstallationSetup()) @@ -294,6 +297,13 @@ describe('useInstallationSetup Hook', () => { await vi.waitFor(() => { expect(electronAPI.getInstallationStatus).toHaveBeenCalled() }) + + // Wait for error to be logged + await vi.waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + consoleErrorSpy.mockRestore() }) })