mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-04-28 03:30:06 +00:00
Co-authored-by: a7m-1st <Ahmed.jimi.awelkeir500@gmail.com> Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
666 lines
21 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|