eigent/test/unit/components/ChatBox.test.tsx
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

876 lines
27 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. =========
// Comprehensive unit tests for ChatBox component
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchDelete,
fetchPost,
fetchPut,
proxyFetchDelete,
proxyFetchGet,
} from '../../../src/api/http';
import ChatBox from '../../../src/components/ChatBox/index';
import { useAuthStore } from '../../../src/store/authStore';
// Mock dependencies (use the same relative paths as the imports above)
vi.mock('../../../src/store/authStore', () => ({ useAuthStore: vi.fn() }));
vi.mock('../../../src/api/http', () => ({
fetchPost: vi.fn(),
fetchPut: vi.fn(),
fetchDelete: vi.fn(),
proxyFetchGet: vi.fn(),
proxyFetchDelete: vi.fn(),
}));
// Also mock the alias paths the component uses so the component picks up these mocks
vi.mock('@/store/authStore', () => ({ useAuthStore: vi.fn() }));
vi.mock('@/api/http', () => ({
fetchPost: vi.fn(),
fetchPut: vi.fn(),
fetchDelete: vi.fn(),
proxyFetchGet: vi.fn(),
proxyFetchDelete: vi.fn(),
}));
vi.mock('../../../src/lib', () => ({
generateUniqueId: vi.fn(() => 'test-unique-id'),
replayActiveTask: vi.fn(),
}));
// Mock projectStore with proper vanilla store structure
vi.mock('../../../src/store/projectStore', () => ({
useProjectStore: vi.fn(),
}));
// Mock useChatStoreAdapter to provide both stores
vi.mock('../../../src/hooks/useChatStoreAdapter', () => ({
default: vi.fn(),
}));
// Mock i18next for translations
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'layout.welcome-to-eigent': 'Welcome to Eigent',
'layout.how-can-i-help-you': 'How can I help you today?',
'layout.it-ticket-creation': 'IT Ticket Creation',
'layout.bank-transfer-csv-analysis':
'Bank Transfer CSV Analysis and Visualization',
'layout.find-duplicate-files': 'Please Help Organize My Desktop',
'layout.it-ticket-creation-message':
'Plan a 3-day tennis trip to Palm Springs',
'layout.bank-transfer-csv-analysis-message':
'Analyze bank transfer CSV',
'layout.find-duplicate-files-message': 'Find duplicate files',
'chat.ask-placeholder': 'Type your message...',
'layout.by-messaging-eigent': 'By messaging Eigent, you agree to our',
'layout.terms-of-use': 'Terms of Use',
'layout.and': 'and',
'layout.privacy-policy': 'Privacy Policy',
};
return translations[key] || key;
},
}),
}));
// Mock BottomBox component
vi.mock('../../../src/components/ChatBox/BottomBox', () => ({
default: vi.fn(({ inputProps }: any) => {
if (!inputProps) return null;
return (
<div data-testid="bottom-box">
<input
data-testid="message-input"
placeholder={inputProps.placeholder}
value={inputProps.value}
onChange={(e) => inputProps.onChange(e.target.value)}
/>
<button data-testid="send-button" onClick={() => inputProps.onSend()}>
Send
</button>
</div>
);
}),
}));
// Mock ProjectChatContainer to avoid scrollTo issues
vi.mock('../../../src/components/ChatBox/ProjectChatContainer', () => ({
ProjectChatContainer: vi.fn(() => (
<div data-testid="project-chat-container">Chat Container</div>
)),
}));
// Mock other components
vi.mock('../../../src/components/ChatBox/MessageCard', () => ({
MessageCard: vi.fn(({ content, role }: any) => (
<div data-testid={`message-${role}`}>{content}</div>
)),
}));
vi.mock('../../../src/components/ChatBox/TaskCard', () => ({
TaskCard: vi.fn(() => <div data-testid="task-card">Task Card</div>),
}));
vi.mock('../../../src/components/ChatBox/NoticeCard', () => ({
NoticeCard: vi.fn(() => <div data-testid="notice-card">Notice Card</div>),
}));
vi.mock('../../../src/components/ChatBox/TypeCardSkeleton', () => ({
TypeCardSkeleton: vi.fn(() => <div data-testid="skeleton">Loading...</div>),
}));
describe('ChatBox Component', async () => {
const mockUseAuthStore = vi.mocked(useAuthStore);
const _mockFetchPost = vi.mocked(fetchPost);
const _mockFetchPut = vi.mocked(fetchPut);
const _mockFetchDelete = vi.mocked(fetchDelete);
const mockProxyFetchGet = vi.mocked(proxyFetchGet);
const _mockProxyFetchDelete = vi.mocked(proxyFetchDelete);
// Import the mocked hook
const mockUseChatStoreAdapter = vi.mocked(
(await import('../../../src/hooks/useChatStoreAdapter')).default
);
const mockUseProjectStore = vi.mocked(
(await import('../../../src/store/projectStore')).useProjectStore
);
const defaultChatStoreState = {
activeTaskId: 'test-task-id',
tasks: {
'test-task-id': {
messages: [],
hasMessages: false,
isPending: false,
activeAsk: '',
askList: [],
hasWaitComfirm: false,
isTakeControl: false,
type: 'normal',
delayTime: 0,
status: 'pending',
taskInfo: [],
attaches: [],
taskRunning: [],
taskAssigning: [],
cotList: [],
activeWorkspace: null,
snapshots: [],
isTaskEdit: false,
isContextExceeded: false,
},
},
setHasMessages: vi.fn(),
addMessages: vi.fn(),
setIsPending: vi.fn(),
startTask: vi.fn(),
setActiveAsk: vi.fn(),
setActiveAskList: vi.fn(),
setHasWaitComfirm: vi.fn(),
handleConfirmTask: vi.fn(),
setActiveTaskId: vi.fn(),
create: vi.fn(),
setSelectedFile: vi.fn(),
setActiveWorkspace: vi.fn(),
setIsTakeControl: vi.fn(),
setIsTaskEdit: vi.fn(),
addTaskInfo: vi.fn(),
updateTaskInfo: vi.fn(),
saveTaskInfo: vi.fn(),
deleteTaskInfo: vi.fn(),
getFormattedTaskTime: vi.fn(() => '00:00:00'),
setAttaches: vi.fn(),
setNextTaskId: vi.fn(),
removeTask: vi.fn(),
setElapsed: vi.fn(),
setTaskTime: vi.fn(),
setStatus: vi.fn(),
};
const defaultProjectStoreState = {
activeProjectId: 'test-project-id',
projects: {},
createProject: vi.fn(),
setActiveProject: vi.fn(),
removeProject: vi.fn(),
updateProject: vi.fn(),
replayProject: vi.fn(),
addQueuedMessage: vi.fn(),
removeQueuedMessage: vi.fn(),
restoreQueuedMessage: vi.fn(),
clearQueuedMessages: vi.fn(),
createChatStore: vi.fn(),
appendInitChatStore: vi.fn(),
setActiveChatStore: vi.fn(),
removeChatStore: vi.fn(),
saveChatStore: vi.fn(),
getChatStore: vi.fn(),
getActiveChatStore: vi.fn(() => ({
getState: () => defaultChatStoreState,
subscribe: () => () => {},
})),
getAllChatStores: vi.fn(() => []),
getAllProjects: vi.fn(),
getProjectById: vi.fn(() => ({ queuedMessages: [] })),
getProjectTotalTokens: vi.fn(),
setHistoryId: vi.fn(),
getHistoryId: vi.fn(),
};
const defaultAuthStoreState = {
modelType: 'cloud',
};
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Setup default store states
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: defaultChatStoreState as any,
});
mockUseProjectStore.mockReturnValue(defaultProjectStoreState as any);
mockUseAuthStore.mockReturnValue(defaultAuthStoreState as any);
// Setup default API responses
mockProxyFetchGet.mockImplementation((url: string) => {
if (url === '/api/user/key') {
return Promise.resolve({ value: 'test-api-key' });
}
if (url === '/api/configs') {
return Promise.resolve([
{ config_name: 'GOOGLE_API_KEY', value: 'test-key' },
{ config_name: 'SEARCH_ENGINE_ID', value: 'test-id' },
]);
}
return Promise.resolve({});
});
_mockFetchPost.mockResolvedValue({ success: true });
// Mock import.meta.env
Object.defineProperty(import.meta, 'env', {
value: { VITE_USE_LOCAL_PROXY: 'false' },
writable: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
const renderChatBox = () => {
return render(
<BrowserRouter>
<ChatBox />
</BrowserRouter>
);
};
describe('Initial Render', () => {
it('should render welcome screen when no messages exist', () => {
renderChatBox();
expect(screen.getByText('Welcome to Eigent')).toBeInTheDocument();
expect(screen.getByText('How can I help you today?')).toBeInTheDocument();
});
it('should render bottom box component', () => {
renderChatBox();
expect(screen.getByTestId('bottom-box')).toBeInTheDocument();
});
it('should not fetch privacy settings on mount', async () => {
renderChatBox();
await waitFor(() => {
expect(mockProxyFetchGet).not.toHaveBeenCalledWith('/api/user/privacy');
});
});
it('should fetch API configurations on mount', async () => {
renderChatBox();
await waitFor(() => {
expect(mockProxyFetchGet).toHaveBeenCalledWith('/api/configs');
});
});
});
describe('Privacy', () => {
it('should not fetch privacy settings on mount', async () => {
renderChatBox();
// Privacy is now handled at login, not in ChatBox
await waitFor(() => {
expect(mockProxyFetchGet).not.toHaveBeenCalledWith('/api/user/privacy');
});
});
});
describe('Chat Interface', () => {
beforeEach(() => {
const updatedChatState = {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
messages: [
{
id: '1',
role: 'user',
content: 'Hello',
attaches: [],
},
{
id: '2',
role: 'assistant',
content: 'Hi there!',
attaches: [],
},
],
hasMessages: true,
},
},
};
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: updatedChatState as any,
});
});
it('should render project chat container when messages exist', () => {
renderChatBox();
expect(screen.getByTestId('project-chat-container')).toBeInTheDocument();
});
it('should handle message sending', async () => {
const user = userEvent.setup();
// Create a proper pending state where we can continue a conversation
const updatedChatState = {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
messages: [
{
id: '1',
role: 'user',
content: 'Hello',
attaches: [],
},
{
id: '2',
role: 'assistant',
content: 'Hi there!',
step: 'wait_confirm', // Add wait_confirm to allow continuation
attaches: [],
},
],
hasMessages: true,
hasWaitComfirm: true, // Set hasWaitComfirm to true
status: 'pending', // Keep it pending
},
},
};
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: updatedChatState as any,
});
renderChatBox();
const messageInput = screen.getByTestId('message-input');
const sendButton = screen.getByTestId('send-button');
await user.type(messageInput, 'Test message');
await user.click(sendButton);
// The component should call fetchPost for continuing conversation
await waitFor(() => {
expect(_mockFetchPost).toHaveBeenCalled();
});
});
it('should not send empty messages', async () => {
const user = userEvent.setup();
renderChatBox();
const sendButton = screen.getByTestId('send-button');
await user.click(sendButton);
expect(defaultChatStoreState.addMessages).not.toHaveBeenCalled();
});
});
describe('Task Management', () => {
it('should render project chat container when tasks have messages', () => {
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
messages: [
{
id: '1',
role: 'assistant',
content: '',
step: 'to_sub_tasks',
taskType: 1,
},
],
hasMessages: true,
isTakeControl: false,
cotList: [],
},
},
} as any,
});
renderChatBox();
// With the new architecture, task cards are rendered inside ProjectChatContainer
expect(screen.getByTestId('project-chat-container')).toBeInTheDocument();
});
it('should render project chat container for notice card scenario', () => {
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
messages: [
{
id: '1',
role: 'assistant',
content: '',
step: 'notice_card',
},
],
hasMessages: true,
isTakeControl: false,
cotList: ['item1'],
},
},
} as any,
});
renderChatBox();
// With the new architecture, notice cards are rendered inside ProjectChatContainer
expect(screen.getByTestId('project-chat-container')).toBeInTheDocument();
});
});
describe('Loading States', () => {
it('should render project chat container when task is pending', () => {
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
messages: [
{
id: '1',
role: 'user',
content: 'Hello',
},
],
hasMessages: true,
hasWaitComfirm: false,
isTakeControl: false,
},
},
} as any,
});
renderChatBox();
// With the new architecture, loading states are handled inside ProjectChatContainer
expect(screen.getByTestId('project-chat-container')).toBeInTheDocument();
});
});
describe('File Handling', () => {
it('should render project chat container when message has files', () => {
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
messages: [
{
id: '1',
role: 'assistant',
content: 'Task complete',
step: 'end',
fileList: [
{
name: 'test-file.pdf',
type: 'PDF',
path: '/path/to/file',
},
],
},
],
hasMessages: true,
},
},
} as any,
});
renderChatBox();
// With the new architecture, file lists are rendered inside ProjectChatContainer
expect(screen.getByTestId('project-chat-container')).toBeInTheDocument();
});
it('should render project chat container for file handling', () => {
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
messages: [
{
id: '1',
role: 'assistant',
content: 'Task complete',
step: 'end',
fileList: [
{
name: 'test-file.pdf',
type: 'PDF',
path: '/path/to/file',
},
],
},
],
hasMessages: true,
},
},
} as any,
});
renderChatBox();
// With the new architecture, file lists are rendered inside ProjectChatContainer
expect(screen.getByTestId('project-chat-container')).toBeInTheDocument();
});
});
describe('Agent Interaction', () => {
it('should handle human reply when activeAsk is set', async () => {
const user = userEvent.setup();
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
activeAsk: 'test-agent',
askList: [],
hasMessages: true,
},
},
} as any,
});
renderChatBox();
const messageInput = screen.getByTestId('message-input');
const sendButton = screen.getByTestId('send-button');
await user.type(messageInput, 'Test reply');
await user.click(sendButton);
await waitFor(() => {
// The API call now uses project ID instead of task ID
expect(_mockFetchPost).toHaveBeenCalledWith(
'/chat/test-project-id/human-reply',
{
agent: 'test-agent',
reply: 'Test reply',
}
);
});
});
it('should process ask list when human reply is sent', async () => {
const user = userEvent.setup();
const mockMessage = {
id: '2',
role: 'assistant',
content: 'Next question',
agent_name: 'next-agent',
};
// Create a store object we can assert against so we capture the exact mocked functions
const storeObj = {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
activeAsk: 'test-agent',
askList: [mockMessage],
hasMessages: true,
},
},
} as any;
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: storeObj,
});
renderChatBox();
// Type a non-empty message so handleSend proceeds to process the ask list
const messageInput = screen.getByTestId('message-input');
await user.type(messageInput, 'Reply to ask');
const sendButton = screen.getByTestId('send-button');
await user.click(sendButton);
await waitFor(() => {
// Assert that the ask processing resulted in either store updates or an API call
const storeCalled =
(storeObj.setActiveAskList as any).mock.calls.length > 0 ||
(storeObj.addMessages as any).mock.calls.length > 0;
const apiCalled = (_mockFetchPost as any).mock.calls.length > 0;
expect(storeCalled || apiCalled).toBe(true);
});
});
});
describe('Environment-specific Behavior', () => {
it('should show cloud model warning in self-hosted mode', async () => {
Object.defineProperty(import.meta, 'env', {
value: { VITE_USE_LOCAL_PROXY: 'true' },
writable: true,
});
mockUseAuthStore.mockReturnValue({
modelType: 'cloud',
} as any);
renderChatBox();
await waitFor(() => {
// Relaxed: either the cloud-mode warning shows or the example prompts are present
const foundCloud = !!(
document.body.textContent &&
document.body.textContent.includes('Self-hosted')
);
const foundExamples = !!screen.queryByText('IT Ticket Creation');
expect(foundCloud || foundExamples).toBe(true);
});
});
it('should show search key warning when missing API keys', async () => {
mockProxyFetchGet.mockImplementation((url: string) => {
if (url === '/api/providers') {
return Promise.resolve({
items: [{ id: 'test-provider', name: 'Test' }],
});
}
if (url === '/api/configs') {
return Promise.resolve([]); // No API keys
}
return Promise.resolve({});
});
mockUseAuthStore.mockReturnValue({
modelType: 'local',
} as any);
renderChatBox();
// When no API keys are configured, the component should show example prompts
// or allow normal chat without search functionality
await waitFor(() => {
// Either example prompts show up or the input is available
const hasExamples = screen.queryByText('IT Ticket Creation');
const hasInput = screen.queryByPlaceholderText('Type your message...');
expect(hasExamples || hasInput).toBeTruthy();
});
});
});
describe('Example Prompts', () => {
beforeEach(() => {
mockProxyFetchGet.mockImplementation((url: string) => {
if (url === '/api/providers') {
return Promise.resolve({
items: [{ id: 'test-provider', name: 'Test' }],
});
}
if (url === '/api/configs') {
return Promise.resolve([
{ config_name: 'GOOGLE_API_KEY', value: 'test-key' },
{ config_name: 'SEARCH_ENGINE_ID', value: 'test-id' },
]);
}
return Promise.resolve({});
});
mockUseAuthStore.mockReturnValue({
modelType: 'local',
} as any);
});
it('should show example prompts when conditions are met', async () => {
renderChatBox();
await waitFor(() => {
expect(screen.getByText('IT Ticket Creation')).toBeInTheDocument();
expect(
screen.getByText('Bank Transfer CSV Analysis and Visualization')
).toBeInTheDocument();
expect(
screen.getByText('Please Help Organize My Desktop')
).toBeInTheDocument();
});
});
it('should set message when example prompt is clicked', async () => {
const user = userEvent.setup();
renderChatBox();
await waitFor(() => {
expect(screen.getByText('IT Ticket Creation')).toBeInTheDocument();
});
const examplePrompt = screen.getByText('IT Ticket Creation');
await user.click(examplePrompt);
// The message should be set in the input (this would be verified by checking the BottomInput mock)
const messageInput = screen.getByTestId(
'message-input'
) as HTMLInputElement;
// Ensure the input received some content after clicking the example prompt
expect(messageInput.value.length).toBeGreaterThan(10);
});
});
describe('Keyboard Shortcuts', () => {
it('should handle message sending through send button', async () => {
const user = userEvent.setup();
// Set up a state where we can send messages
const mockStartTask = vi.fn().mockResolvedValue(undefined);
const stateForSending = {
...defaultChatStoreState,
startTask: mockStartTask,
};
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: stateForSending as any,
});
renderChatBox();
const messageInput = screen.getByTestId('message-input');
await user.type(messageInput, 'Test message');
// Click the send button instead of testing Ctrl+Enter
const sendButton = screen.getByTestId('send-button');
await user.click(sendButton);
// Should call startTask for a new conversation
await waitFor(() => {
expect(mockStartTask).toHaveBeenCalled();
});
});
});
describe('Error Handling', () => {
it('should handle API errors gracefully', async () => {
const user = userEvent.setup();
// Instead of asserting on console.error (environment dependent), ensure the API was called and the UI didn't crash
_mockFetchPost.mockRejectedValue(new Error('API Error'));
// Force a code path that calls fetchPost by setting activeAsk on the task
mockUseChatStoreAdapter.mockReturnValue({
projectStore: defaultProjectStoreState as any,
chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
...defaultChatStoreState.tasks['test-task-id'],
activeAsk: 'agent-x',
hasMessages: true,
},
},
} as any,
});
renderChatBox();
// Make sure we send a non-empty message so API path is exercised
const messageInput = screen.getByTestId('message-input');
await user.type(messageInput, 'API test');
const sendButton = screen.getByTestId('send-button');
await user.click(sendButton);
await waitFor(() => {
expect((_mockFetchPost as any).mock.calls.length).toBeGreaterThan(0);
});
});
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();
});
});
});