eigent/test/unit/store/chatStore.test.ts
Dream 2e44ae0f0a
refactor: move privacy consent from ChatBox to Login/SignUp (#1239)
Co-authored-by: a7m-1st <Ahmed.jimi.awelkeir500@gmail.com>
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
2026-03-07 18:55:48 +08:00

666 lines
21 KiB
TypeScript

// ========= 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: 'carousel' 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: 'carousel' 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();
});
});
});