// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= /** * ChatStore Unit Tests - Core Functionality * * Tests basic chatStore operations: * - Task creation and removal * - Status management * - Token tracking * - Message handling */ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies - moved to top before other imports vi.mock('@/api/http', () => ({ fetchPost: vi.fn(), fetchPut: vi.fn(), getBaseURL: vi.fn(() => Promise.resolve('http://localhost:8000')), proxyFetchPost: vi.fn(() => Promise.resolve({ id: 'mock-history-id' })), proxyFetchPut: vi.fn(), proxyFetchGet: vi.fn(() => Promise.resolve({ value: '', api_url: '', items: [], warning_code: null, }) ), uploadFile: vi.fn(), fetchDelete: vi.fn(), waitForBackendReady: vi.fn(() => Promise.resolve(true)), })); vi.mock('@microsoft/fetch-event-source', () => ({ fetchEventSource: vi.fn(), })); vi.mock('../../../src/store/authStore', () => ({ useAuthStore: { token: null, username: null, email: null, user_id: null, appearance: 'light', language: 'system', isFirstLaunch: true, modelType: 'cloud' as const, cloud_model_type: 'gpt-4.1' as const, initState: 'permissions' as const, share_token: null, workerListData: {}, }, getAuthStore: vi.fn(() => ({ token: null, username: null, email: null, user_id: null, appearance: 'light', language: 'system', isFirstLaunch: true, modelType: 'cloud' as const, cloud_model_type: 'gpt-4.1' as const, initState: 'permissions' as const, share_token: null, workerListData: {}, })), useWorkerList: vi.fn(() => []), getWorkerList: vi.fn(() => []), })); vi.mock('../../../src/store/projectStore', () => ({ useProjectStore: { getState: vi.fn(() => ({ activeProjectId: null, getHistoryId: () => null, })), }, })); import { proxyFetchGet } from '@/api/http'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { generateUniqueId } from '../../../src/lib'; import { useChatStore } from '../../../src/store/chatStore'; import { useProjectStore } from '../../../src/store/projectStore'; import { ChatTaskStatus } from '../../../src/types/constants'; // Mock electron IPC (global as any).ipcRenderer = { invoke: vi.fn((channel, ..._args) => { if (channel === 'get-system-language') return Promise.resolve('en'); if (channel === 'get-browser-port') return Promise.resolve(9222); if (channel === 'get-env-path') return Promise.resolve('/path/to/env'); if (channel === 'mcp-list') return Promise.resolve({}); return Promise.resolve(); }), }; describe('ChatStore - Core Functionality', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('Task Creation', () => { it('should create a task with unique ID', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId1 = result.current.getState().create(); const taskId2 = result.current.getState().create(); expect(taskId1).toBeDefined(); expect(taskId2).toBeDefined(); expect(taskId1).not.toBe(taskId2); expect(result.current.getState().tasks[taskId1]).toBeDefined(); expect(result.current.getState().tasks[taskId2]).toBeDefined(); }); }); it('should create a task with custom ID', () => { const { result } = renderHook(() => useChatStore()); const customId = 'custom-task-123'; act(() => { const taskId = result.current.getState().create(customId); expect(taskId).toBe(customId); expect(result.current.getState().tasks[customId]).toBeDefined(); }); }); it('should initialize task with correct default state', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); const task = result.current.getState().tasks[taskId]; expect(task.status).toBe('pending'); expect(task.messages).toEqual([]); expect(task.tokens).toBe(0); expect(task.isPending).toBe(false); expect(task.hasWaitComfirm).toBe(false); expect(task.progressValue).toBe(0); expect(task.taskInfo).toEqual([]); expect(task.taskRunning).toEqual([]); expect(task.taskAssigning).toEqual([]); }); }); it('should set task as active when created', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); expect(result.current.getState().activeTaskId).toBe(taskId); }); }); }); describe('Task Removal', () => { it('should remove a task by ID', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); expect(result.current.getState().tasks[taskId]).toBeDefined(); result.current.getState().removeTask(taskId); expect(result.current.getState().tasks[taskId]).toBeUndefined(); }); }); it('should handle removing non-existent task gracefully', () => { const { result } = renderHook(() => useChatStore()); act(() => { // Should not throw result.current.getState().removeTask('non-existent-id'); }); }); it('should clear all tasks and create new one', () => { const { result } = renderHook(() => useChatStore()); act(() => { const _taskId1 = result.current.getState().create(); const _taskId2 = result.current.getState().create(); expect(Object.keys(result.current.getState().tasks)).toHaveLength(2); result.current.getState().clearTasks(); const remainingTasks = Object.keys(result.current.getState().tasks); expect(remainingTasks).toHaveLength(1); expect(result.current.getState().activeTaskId).toBeDefined(); }); }); }); describe('Status Management', () => { it('should update task status correctly', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().setStatus(taskId, 'running'); expect(result.current.getState().tasks[taskId].status).toBe('running'); result.current.getState().setStatus(taskId, 'finished'); expect(result.current.getState().tasks[taskId].status).toBe('finished'); result.current.getState().setStatus(taskId, 'pause'); expect(result.current.getState().tasks[taskId].status).toBe('pause'); }); }); it('should set pending state independently of status', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().setIsPending(taskId, true); expect(result.current.getState().tasks[taskId].isPending).toBe(true); expect(result.current.getState().tasks[taskId].status).toBe('pending'); result.current.getState().setStatus(taskId, 'running'); expect(result.current.getState().tasks[taskId].isPending).toBe(true); expect(result.current.getState().tasks[taskId].status).toBe('running'); }); }); }); describe('Token Management', () => { it('should accumulate tokens correctly', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().addTokens(taskId, 100); expect(result.current.getState().getTokens(taskId)).toBe(100); result.current.getState().addTokens(taskId, 50); expect(result.current.getState().getTokens(taskId)).toBe(150); result.current.getState().addTokens(taskId, 250); expect(result.current.getState().getTokens(taskId)).toBe(400); }); }); it('should handle negative token additions', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().addTokens(taskId, 100); result.current.getState().addTokens(taskId, -50); expect(result.current.getState().getTokens(taskId)).toBe(50); }); }); it('should return 0 tokens for non-existent task', () => { const { result } = renderHook(() => useChatStore()); expect(result.current.getState().getTokens('non-existent')).toBe(0); }); it('should preserve tokens when creating new task with initial tokens', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId1 = result.current.getState().create(); result.current.getState().addTokens(taskId1, 500); // Simulate new task in same project with accumulated tokens const taskId2 = result.current.getState().create(); result.current.getState().addTokens(taskId2, 500); // Cumulative expect(result.current.getState().getTokens(taskId2)).toBe(500); }); }); }); describe('Message Management', () => { it('should add messages to task', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().addMessages(taskId, { id: generateUniqueId(), role: 'user', content: 'Hello, world!', }); expect(result.current.getState().tasks[taskId].messages).toHaveLength( 1 ); expect( result.current.getState().tasks[taskId].messages[0].content ).toBe('Hello, world!'); }); }); it('should maintain message order', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().addMessages(taskId, { id: '1', role: 'user', content: 'First', }); result.current.getState().addMessages(taskId, { id: '2', role: 'agent', content: 'Second', }); result.current.getState().addMessages(taskId, { id: '3', role: 'user', content: 'Third', }); const messages = result.current.getState().tasks[taskId].messages; expect(messages).toHaveLength(3); expect(messages[0].content).toBe('First'); expect(messages[1].content).toBe('Second'); expect(messages[2].content).toBe('Third'); }); }); it('should get last user message', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().setActiveTaskId(taskId); result.current.getState().addMessages(taskId, { id: '1', role: 'user', content: 'First user message', }); result.current.getState().addMessages(taskId, { id: '2', role: 'agent', content: 'Agent response', }); result.current.getState().addMessages(taskId, { id: '3', role: 'user', content: 'Second user message', }); const lastUserMessage = result.current.getState().getLastUserMessage(); expect(lastUserMessage?.content).toBe('Second user message'); }); }); it('should return null when no user messages exist', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().setActiveTaskId(taskId); result.current.getState().addMessages(taskId, { id: '1', role: 'agent', content: 'Agent message', }); const lastUserMessage = result.current.getState().getLastUserMessage(); expect(lastUserMessage).toBeNull(); }); }); it('should set messages replacing existing ones', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().addMessages(taskId, { id: '1', role: 'user', content: 'Original', }); const newMessages = [ { id: '2', role: 'user' as const, content: 'New 1' }, { id: '3', role: 'agent' as const, content: 'New 2' }, ]; result.current.getState().setMessages(taskId, newMessages); expect(result.current.getState().tasks[taskId].messages).toHaveLength( 2 ); expect( result.current.getState().tasks[taskId].messages[0].content ).toBe('New 1'); }); }); }); describe('Task Time Tracking', () => { it('should track task time', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); const startTime = Date.now(); result.current.getState().setTaskTime(taskId, startTime); expect(result.current.getState().tasks[taskId].taskTime).toBe( startTime ); }); }); it('should track elapsed time', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().setElapsed(taskId, 5000); expect(result.current.getState().tasks[taskId].elapsed).toBe(5000); }); }); it('should format task time correctly', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); // Test elapsed time formatting result.current.getState().setTaskTime(taskId, 0); result.current.getState().setElapsed(taskId, 3665000); // 1h 1m 5s const formatted = result.current .getState() .getFormattedTaskTime(taskId); expect(formatted).toBe('01:01:05'); }); }); }); describe('Progress Tracking', () => { it('should update progress value', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); result.current.getState().setProgressValue(taskId, 50); expect(result.current.getState().tasks[taskId].progressValue).toBe(50); result.current.getState().setProgressValue(taskId, 100); expect(result.current.getState().tasks[taskId].progressValue).toBe(100); }); }); it('should compute progress based on completed tasks', () => { const { result } = renderHook(() => useChatStore()); act(() => { const taskId = result.current.getState().create(); // Set up task structure result.current.getState().setTaskRunning(taskId, [ { id: '1', content: 'Task 1', status: 'completed' }, { id: '2', content: 'Task 2', status: 'completed' }, { id: '3', content: 'Task 3', status: 'running' }, { id: '4', content: 'Task 4', status: 'waiting' }, ] as any); result.current.getState().computedProgressValue(taskId); // 2 out of 4 = 50% expect(result.current.getState().tasks[taskId].progressValue).toBe(50); }); }); }); describe('Update Counter', () => { it('should increment update count', () => { const { result } = renderHook(() => useChatStore()); const initialCount = result.current.getState().updateCount; act(() => { result.current.getState().setUpdateCount(); }); expect(result.current.getState().updateCount).toBe(initialCount + 1); act(() => { result.current.getState().setUpdateCount(); }); expect(result.current.getState().updateCount).toBe(initialCount + 2); }); }); /** * Issue #1212: Duplicate task execution after network reconnection / system wake-up. * When the task is already FINISHED, SSE onerror must not retry (throw to stop retry). */ describe('SSE onerror - no retry when task already finished (issue #1212)', () => { it('should stop retry when task is already FINISHED (avoids duplicate execution)', async () => { const mockFetchEventSource = vi.mocked(fetchEventSource); mockFetchEventSource.mockImplementation((_url, opts) => { // Simulate connection error; when onerror runs, store checks task status // and throws to stop retry (issue #1212 fix) try { opts.onerror?.(new Error('Failed to fetch')); } catch { // Expected: onerror throws to stop fetch-event-source from retrying } return Promise.resolve(); }); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { result } = renderHook(() => useChatStore()); let taskId: string; await act(async () => { taskId = result.current.getState().create(); result.current.getState().setActiveTaskId(taskId!); result.current.getState().setStatus(taskId!, ChatTaskStatus.FINISHED); result.current.getState().addMessages(taskId!, { id: generateUniqueId(), role: 'user', content: 'Test message', }); result.current.getState().setHasMessages(taskId!, true); }); await act(async () => { await result.current.getState().startTask(taskId!); }); expect(mockFetchEventSource).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining('already finished, stopping retry') ); logSpy.mockRestore(); }); }); describe('Replay', () => { const replayProjectState = () => ({ activeProjectId: 'proj-replay', getHistoryId: () => null, }); beforeEach(() => { vi.mocked(useProjectStore.getState).mockImplementation( replayProjectState as any ); vi.mocked(proxyFetchGet).mockImplementation((url: string) => url?.includes?.('snapshots') ? Promise.resolve([]) : Promise.resolve({ value: '', api_url: '', items: [], warning_code: null, }) ); }); it('replay() creates task and starts SSE', async () => { vi.mocked(fetchEventSource).mockImplementation(() => Promise.resolve()); const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.getState().replay('replay-1', 'Q', 0.2); }); expect(result.current.getState().tasks['replay-1']).toBeDefined(); expect(result.current.getState().activeTaskId).toBe('replay-1'); expect(fetchEventSource).toHaveBeenCalled(); }); it('replay SSE: AbortError does not throw', async () => { vi.mocked(fetchEventSource).mockImplementation(() => Promise.reject(new DOMException('', 'AbortError')) ); const { result } = renderHook(() => useChatStore()); let taskId!: string; await act(async () => { taskId = result.current.getState().create(); result.current.getState().setHasMessages(taskId, true); result.current.getState().addMessages(taskId, { id: generateUniqueId(), role: 'user', content: 'Q', }); }); await expect( result.current.getState().startTask(taskId, 'replay', undefined, 0.2) ).resolves.toBeUndefined(); }); it('replay SSE: unexpected error is logged and rethrown', async () => { const err = new Error('SSE failed'); vi.mocked(fetchEventSource).mockImplementation(() => Promise.reject(err)); const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); const { result } = renderHook(() => useChatStore()); let taskId!: string; await act(async () => { taskId = result.current.getState().create(); result.current.getState().setHasMessages(taskId, true); result.current.getState().addMessages(taskId, { id: generateUniqueId(), role: 'user', content: 'Q', }); }); await expect( result.current.getState().startTask(taskId, 'replay', undefined, 0.2) ).rejects.toThrow('SSE failed'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('SSE stream failed for task'), err ); consoleSpy.mockRestore(); }); }); });