mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-29 02:41:44 +00:00
1588 lines
47 KiB
TypeScript
1588 lines
47 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,
|
|
fetchGet,
|
|
fetchPost,
|
|
fetchPut,
|
|
proxyFetchDelete,
|
|
proxyFetchGet,
|
|
} from '../../../src/api/http';
|
|
import ChatBox from '../../../src/components/ChatBox/index';
|
|
import { useAuthStore } from '../../../src/store/authStore';
|
|
|
|
const eventNativeHarness = vi.hoisted(() => ({
|
|
enabled: false,
|
|
snapshot: null as any,
|
|
controlOptions: null as any,
|
|
}));
|
|
|
|
// Mock dependencies (use the same relative paths as the imports above)
|
|
vi.mock('../../../src/store/authStore', () => ({
|
|
useAuthStore: vi.fn(),
|
|
getAuthStore: vi.fn(() => ({ language: 'en-US', setLanguage: vi.fn() })),
|
|
}));
|
|
vi.mock('../../../src/api/http', () => ({
|
|
fetchGet: vi.fn(),
|
|
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(),
|
|
getAuthStore: vi.fn(() => ({ language: 'en-US', setLanguage: vi.fn() })),
|
|
}));
|
|
vi.mock('@/api/http', () => ({
|
|
fetchGet: vi.fn(),
|
|
fetchPost: vi.fn(),
|
|
fetchPut: vi.fn(),
|
|
fetchDelete: vi.fn(),
|
|
proxyFetchGet: vi.fn(),
|
|
proxyFetchDelete: vi.fn(),
|
|
}));
|
|
vi.mock('@/store/chatEventProjectionBridge', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('@/store/chatEventProjectionBridge')>();
|
|
return {
|
|
...actual,
|
|
isChatEventTimelineEnabled: () => eventNativeHarness.enabled,
|
|
};
|
|
});
|
|
vi.mock('@/hooks/useProjectEventRuntime', () => ({
|
|
useProjectEventRuntime: () => ({
|
|
hydration: {
|
|
status: 'ready',
|
|
errorCode: null,
|
|
eventsTruncated: false,
|
|
retry: vi.fn(),
|
|
},
|
|
projectId: 'test-project-id',
|
|
snapshot: eventNativeHarness.snapshot,
|
|
}),
|
|
}));
|
|
vi.mock('../../../src/components/ChatBox/EventNativeProjectTimeline', () => ({
|
|
EventNativeProjectTimeline: ({ floatingControl }: any) => (
|
|
<div data-testid="event-native-timeline">{floatingControl}</div>
|
|
),
|
|
}));
|
|
vi.mock(
|
|
'../../../src/components/ChatBox/BottomBox/useEventNativeHumanControl',
|
|
() => ({
|
|
useEventNativeHumanControl: (options: any) => {
|
|
eventNativeHarness.controlOptions = options;
|
|
return {
|
|
interaction: null,
|
|
variant: null,
|
|
pendingCount: 0,
|
|
phase: 'idle',
|
|
submitError: null,
|
|
};
|
|
},
|
|
})
|
|
);
|
|
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', () => {
|
|
const useProjectStore = vi.fn();
|
|
(useProjectStore as any).getState = vi.fn(() => ({
|
|
getAllChatStores: () => [],
|
|
}));
|
|
return { useProjectStore };
|
|
});
|
|
|
|
vi.mock('@/store/projectStore', () => {
|
|
const useProjectStore = vi.fn();
|
|
(useProjectStore as any).getState = vi.fn(() => ({
|
|
getAllChatStores: () => [],
|
|
}));
|
|
return { useProjectStore };
|
|
});
|
|
|
|
// Mock useChatStoreAdapter to provide both stores
|
|
vi.mock('../../../src/hooks/useChatStoreAdapter', () => ({
|
|
default: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/hooks/useModelConfigCheck', () => ({
|
|
useModelConfigCheck: () => ({
|
|
hasModel: true,
|
|
isConfigLoaded: true,
|
|
cloudUsageLimitReached: false,
|
|
}),
|
|
}));
|
|
|
|
// Mock i18next for translations
|
|
vi.mock('react-i18next', () => ({
|
|
initReactI18next: {
|
|
type: '3rdParty',
|
|
init: () => {},
|
|
},
|
|
useTranslation: () => ({
|
|
t: (key: string) => {
|
|
const translations: Record<string, string> = {
|
|
'chat.ask-placeholder': 'Type your message...',
|
|
'chat.attachment-only-message': 'Please use the attached file(s).',
|
|
'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;
|
|
const hasContent =
|
|
(inputProps.value || '').trim().length > 0 ||
|
|
inputProps.files?.length > 0;
|
|
const primaryAction = hasContent
|
|
? 'send'
|
|
: inputProps.taskControlState === 'running'
|
|
? 'pause'
|
|
: inputProps.taskControlState === 'paused'
|
|
? 'resume'
|
|
: 'idle';
|
|
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"
|
|
data-composer-primary-action={primaryAction}
|
|
disabled={primaryAction === 'idle'}
|
|
onClick={() => {
|
|
if (primaryAction === 'send') inputProps.onSend();
|
|
if (primaryAction === 'pause') inputProps.onPauseTask();
|
|
if (primaryAction === 'resume') inputProps.onResumeTask();
|
|
}}
|
|
>
|
|
{primaryAction}
|
|
</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 mockFetchGet = vi.mocked(fetchGet);
|
|
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(),
|
|
removeMessage: 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(),
|
|
setNextExecutionId: vi.fn(),
|
|
setTaskSessionMode: vi.fn(),
|
|
setTaskSource: vi.fn(),
|
|
setExecutionId: vi.fn(),
|
|
removeTask: vi.fn(),
|
|
stopTask: vi.fn(),
|
|
setElapsed: vi.fn(),
|
|
setTaskTime: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
setDurableRunStatus: vi.fn(),
|
|
markHumanInteractionResolved: vi.fn(),
|
|
};
|
|
|
|
const preparedRunState = {
|
|
setNextTaskId: vi.fn(),
|
|
setTaskSessionMode: vi.fn(),
|
|
setTaskSource: vi.fn(),
|
|
setExecutionId: vi.fn(),
|
|
setIsPending: vi.fn(),
|
|
setHasMessages: vi.fn(),
|
|
addMessages: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
};
|
|
|
|
const preparedRunChatStore = {
|
|
getState: vi.fn(() => preparedRunState),
|
|
};
|
|
|
|
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(),
|
|
setQueuedMessageProcessing: vi.fn(),
|
|
createChatStore: vi.fn(),
|
|
appendInitChatStore: vi.fn((_projectId: string, _taskId: string) => ({
|
|
chatStore: preparedRunChatStore,
|
|
})),
|
|
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',
|
|
};
|
|
|
|
const runningEventNativeSnapshot = (runId = 'test-task-id') => ({
|
|
view: {
|
|
projectId: 'test-project-id',
|
|
mode: 'live',
|
|
seenEventIds: {},
|
|
currentCursor: 1,
|
|
eventsTruncated: false,
|
|
lastSyncedAt: null,
|
|
needsResync: false,
|
|
resyncReason: null,
|
|
resyncTargetCursor: null,
|
|
runs: {
|
|
[runId]: {
|
|
runId,
|
|
status: 'running',
|
|
lastSequence: 1,
|
|
runVersion: 1,
|
|
updatedAt: '2026-08-20T00:00:00Z',
|
|
origin: 'local',
|
|
resumeBlockedReason: null,
|
|
},
|
|
},
|
|
artifactsByRun: {},
|
|
legacySteps: [],
|
|
unknownEvents: [],
|
|
},
|
|
chat: {
|
|
projectId: 'test-project-id',
|
|
nodes: [
|
|
{
|
|
id: `${runId}:started`,
|
|
eventId: `${runId}:started`,
|
|
projectId: 'test-project-id',
|
|
runId,
|
|
createdAt: '2026-08-20T00:00:00Z',
|
|
runSequence: 1,
|
|
cloudCursor: 1,
|
|
eventType: 'run.attempt_started',
|
|
legacyStep: null,
|
|
kind: 'run_status',
|
|
status: 'running',
|
|
},
|
|
],
|
|
nodeById: {},
|
|
seenEventIds: {},
|
|
},
|
|
control: {
|
|
projectId: 'test-project-id',
|
|
orderedInteractionIds: [],
|
|
interactionById: {},
|
|
seenEventIds: {},
|
|
},
|
|
revision: 1,
|
|
hasHydratedSnapshot: true,
|
|
overflowed: false,
|
|
lastEffects: [],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Reset all mocks
|
|
vi.clearAllMocks();
|
|
window.sessionStorage.clear();
|
|
eventNativeHarness.enabled = false;
|
|
eventNativeHarness.snapshot = null;
|
|
eventNativeHarness.controlOptions = null;
|
|
defaultProjectStoreState.activeProjectId = 'test-project-id';
|
|
defaultProjectStoreState.getActiveChatStore.mockImplementation(() => ({
|
|
getState: () => defaultChatStoreState,
|
|
subscribe: () => () => {},
|
|
}));
|
|
defaultProjectStoreState.getProjectById.mockImplementation(() => ({
|
|
queuedMessages: [],
|
|
}));
|
|
defaultProjectStoreState.removeQueuedMessage.mockImplementation(
|
|
() => undefined
|
|
);
|
|
defaultProjectStoreState.getAllChatStores.mockReturnValue([]);
|
|
|
|
// 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
|
|
mockFetchGet.mockResolvedValue({ runs: [] });
|
|
mockProxyFetchGet.mockImplementation((url: string) => {
|
|
if (url === '/api/user/key' || url === '/api/v1/user/key') {
|
|
return Promise.resolve({ value: 'test-api-key' });
|
|
}
|
|
if (url === '/api/v1/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 });
|
|
_mockFetchPut.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 bottom box when no messages exist', () => {
|
|
renderChatBox();
|
|
|
|
expect(screen.getByTestId('bottom-box')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render message input in bottom box', () => {
|
|
renderChatBox();
|
|
|
|
expect(screen.getByTestId('message-input')).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/v1/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.each([
|
|
['running', 'pause'],
|
|
['pause', 'resume'],
|
|
] as const)(
|
|
'routes the empty composer %s state through the %s task control',
|
|
async (status, action) => {
|
|
const user = userEvent.setup();
|
|
const controlledTask = {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
status,
|
|
hasMessages: true,
|
|
messages: [{ id: '1', role: 'user', content: 'Start', attaches: [] }],
|
|
elapsed: 100,
|
|
taskTime: 1_000,
|
|
};
|
|
const controlledStore = {
|
|
...defaultChatStoreState,
|
|
tasks: { 'test-task-id': controlledTask },
|
|
setElapsed: vi.fn(),
|
|
setTaskTime: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
};
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: controlledStore as any,
|
|
});
|
|
|
|
renderChatBox();
|
|
|
|
const actionButton = screen.getByTestId('send-button');
|
|
expect(actionButton).toHaveAttribute(
|
|
'data-composer-primary-action',
|
|
action
|
|
);
|
|
await user.click(actionButton);
|
|
|
|
await waitFor(() => {
|
|
expect(_mockFetchPut).toHaveBeenCalledWith(
|
|
'/task/test-project-id/take-control',
|
|
{ action }
|
|
);
|
|
});
|
|
}
|
|
);
|
|
|
|
it('sends an attachment-only draft with a usable instruction', async () => {
|
|
const user = userEvent.setup();
|
|
const attachment = {
|
|
fileName: 'brief.pdf',
|
|
filePath: '/tmp/brief.pdf',
|
|
};
|
|
const attachmentStore = {
|
|
...defaultChatStoreState,
|
|
tasks: {
|
|
'test-task-id': {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
attaches: [attachment],
|
|
},
|
|
},
|
|
};
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: attachmentStore as any,
|
|
});
|
|
|
|
renderChatBox();
|
|
|
|
const actionButton = screen.getByTestId('send-button');
|
|
expect(actionButton).toHaveAttribute(
|
|
'data-composer-primary-action',
|
|
'send'
|
|
);
|
|
await user.click(actionButton);
|
|
|
|
await waitFor(() => {
|
|
expect(attachmentStore.startTask).toHaveBeenCalledWith(
|
|
'test-task-id',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
'Please use the attached file(s).',
|
|
[attachment],
|
|
undefined,
|
|
'test-project-id',
|
|
'single-agent'
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
// A follow-up is prepared as a new durable Run before admission.
|
|
await waitFor(() => {
|
|
expect(defaultProjectStoreState.appendInitChatStore).toHaveBeenCalled();
|
|
expect(_mockFetchPost).toHaveBeenCalledWith(
|
|
'/chat/test-project-id',
|
|
expect.objectContaining({
|
|
question: 'Test message',
|
|
})
|
|
);
|
|
});
|
|
|
|
const nextTaskId =
|
|
defaultProjectStoreState.appendInitChatStore.mock.calls[0][1];
|
|
expect(preparedRunState.setNextTaskId).toHaveBeenCalledWith(nextTaskId);
|
|
expect(preparedRunState.setTaskSessionMode).toHaveBeenCalledWith(
|
|
nextTaskId,
|
|
'single-agent'
|
|
);
|
|
expect(preparedRunState.setIsPending).toHaveBeenCalledWith(
|
|
nextTaskId,
|
|
true
|
|
);
|
|
expect(preparedRunState.setHasMessages).toHaveBeenCalledWith(
|
|
nextTaskId,
|
|
true
|
|
);
|
|
expect(_mockFetchPost).toHaveBeenCalledWith(
|
|
'/chat/test-project-id',
|
|
expect.objectContaining({ task_id: nextTaskId })
|
|
);
|
|
});
|
|
|
|
it('keeps the composer available and queues a second task while running', async () => {
|
|
const user = userEvent.setup();
|
|
const runningChatState = {
|
|
...defaultChatStoreState,
|
|
tasks: {
|
|
'test-task-id': {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
messages: [
|
|
{
|
|
id: 'running-query',
|
|
role: 'user',
|
|
content: 'First task',
|
|
attaches: [],
|
|
},
|
|
],
|
|
hasMessages: true,
|
|
status: 'running',
|
|
},
|
|
},
|
|
};
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: runningChatState as any,
|
|
});
|
|
_mockFetchPost.mockResolvedValueOnce({
|
|
request_id: 'test-unique-id',
|
|
project_id: 'test-project-id',
|
|
content: 'Second task',
|
|
attachment_paths: [],
|
|
delivery_mode: 'wait',
|
|
status: 'pending',
|
|
source: 'local',
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
});
|
|
|
|
renderChatBox();
|
|
await user.type(screen.getByTestId('message-input'), 'Second task');
|
|
await user.click(screen.getByTestId('send-button'));
|
|
|
|
await waitFor(() => {
|
|
expect(_mockFetchPost).toHaveBeenCalledWith(
|
|
'/projects/test-project-id/follow-ups',
|
|
{
|
|
request_id: 'test-unique-id',
|
|
content: 'Second task',
|
|
attachment_paths: [],
|
|
delivery_mode: 'wait',
|
|
source: 'local',
|
|
source_command_id: undefined,
|
|
}
|
|
);
|
|
expect(
|
|
defaultProjectStoreState.restoreQueuedMessage
|
|
).toHaveBeenCalledWith(
|
|
'test-project-id',
|
|
expect.objectContaining({
|
|
task_id: 'test-unique-id',
|
|
content: 'Second task',
|
|
source: 'local',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
it('admits queued follow-ups one at a time without writing into the completed Run', async () => {
|
|
const queuedMessages = [
|
|
{
|
|
task_id: 'queued-run-1',
|
|
run_id: 'queued-run-1',
|
|
content: 'First queued follow-up',
|
|
timestamp: 1,
|
|
attaches: [],
|
|
},
|
|
{
|
|
task_id: 'queued-run-2',
|
|
run_id: 'queued-run-2',
|
|
content: 'Second queued follow-up',
|
|
timestamp: 2,
|
|
attaches: [],
|
|
},
|
|
];
|
|
defaultProjectStoreState.getProjectById.mockImplementation(() => ({
|
|
queuedMessages,
|
|
}));
|
|
defaultProjectStoreState.removeQueuedMessage.mockImplementation(
|
|
(_projectId: string, taskId: string) => {
|
|
const index = queuedMessages.findIndex(
|
|
(item) => item.task_id === taskId
|
|
);
|
|
return index >= 0 ? queuedMessages.splice(index, 1)[0] : undefined;
|
|
}
|
|
);
|
|
|
|
const completedTask = {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
messages: [
|
|
{
|
|
id: 'completed-query',
|
|
role: 'user',
|
|
content: 'Completed task',
|
|
attaches: [],
|
|
},
|
|
],
|
|
hasMessages: true,
|
|
hasWaitComfirm: true,
|
|
status: 'finished',
|
|
};
|
|
const completedStore = {
|
|
...defaultChatStoreState,
|
|
tasks: { 'test-task-id': completedTask },
|
|
};
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: completedStore as any,
|
|
});
|
|
defaultProjectStoreState.getAllChatStores.mockReturnValue([]);
|
|
eventNativeHarness.enabled = true;
|
|
eventNativeHarness.snapshot = runningEventNativeSnapshot('test-task-id');
|
|
eventNativeHarness.snapshot.view.runs['test-task-id'].status =
|
|
'completed';
|
|
mockFetchGet.mockImplementation((url: string) => {
|
|
if (url === '/chat/test-project-id/status') {
|
|
return Promise.resolve({
|
|
has_lock: true,
|
|
status: 'done',
|
|
run_id: 'test-task-id',
|
|
});
|
|
}
|
|
return Promise.resolve({ items: [] });
|
|
});
|
|
|
|
const view = renderChatBox();
|
|
|
|
await waitFor(() => {
|
|
expect(_mockFetchPost).toHaveBeenCalledWith('/chat/test-project-id', {
|
|
question: 'First queued follow-up',
|
|
task_id: 'queued-run-1',
|
|
attaches: [],
|
|
target: undefined,
|
|
});
|
|
});
|
|
expect(
|
|
_mockFetchPost.mock.calls.filter(
|
|
([url]) => url === '/chat/test-project-id'
|
|
)
|
|
).toHaveLength(1);
|
|
expect(completedStore.addMessages).not.toHaveBeenCalled();
|
|
expect(defaultProjectStoreState.removeQueuedMessage).toHaveBeenCalledWith(
|
|
'test-project-id',
|
|
'queued-run-1'
|
|
);
|
|
|
|
eventNativeHarness.snapshot = runningEventNativeSnapshot('queued-run-1');
|
|
eventNativeHarness.snapshot.revision = 2;
|
|
view.rerender(
|
|
<BrowserRouter>
|
|
<ChatBox />
|
|
</BrowserRouter>
|
|
);
|
|
await Promise.resolve();
|
|
expect(
|
|
_mockFetchPost.mock.calls.filter(
|
|
([url]) => url === '/chat/test-project-id'
|
|
)
|
|
).toHaveLength(1);
|
|
|
|
eventNativeHarness.snapshot = runningEventNativeSnapshot('queued-run-1');
|
|
eventNativeHarness.snapshot.revision = 3;
|
|
eventNativeHarness.snapshot.view.runs['queued-run-1'].status =
|
|
'completed';
|
|
view.rerender(
|
|
<BrowserRouter>
|
|
<ChatBox />
|
|
</BrowserRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(_mockFetchPost).toHaveBeenCalledWith('/chat/test-project-id', {
|
|
question: 'Second queued follow-up',
|
|
task_id: 'queued-run-2',
|
|
attaches: [],
|
|
target: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
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('Event-native floating Stop', () => {
|
|
const setRunningEventNativeStore = () => {
|
|
const runningTask = {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
status: 'running',
|
|
hasMessages: true,
|
|
messages: [{ id: '1', role: 'user', content: 'Start', attaches: [] }],
|
|
};
|
|
const runningStore = {
|
|
...defaultChatStoreState,
|
|
tasks: { 'test-task-id': runningTask },
|
|
stopTask: vi.fn(),
|
|
setIsPending: vi.fn(),
|
|
};
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: runningStore as any,
|
|
});
|
|
eventNativeHarness.enabled = true;
|
|
eventNativeHarness.snapshot = runningEventNativeSnapshot();
|
|
return runningStore;
|
|
};
|
|
|
|
it('keeps the plan overlay host mounted for the durable timeline', () => {
|
|
setRunningEventNativeStore();
|
|
|
|
renderChatBox();
|
|
|
|
expect(
|
|
document.getElementById('plan-task-overlay-root')
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('fails closed when the rendered Run loses control ownership before click', async () => {
|
|
const user = userEvent.setup();
|
|
setRunningEventNativeStore();
|
|
renderChatBox();
|
|
const stopButton = await screen.findByRole('button', {
|
|
name: 'Stop Task',
|
|
});
|
|
|
|
const snapshot = eventNativeHarness.snapshot;
|
|
snapshot.view.runs['typed-run'] = {
|
|
...snapshot.view.runs['test-task-id'],
|
|
runId: 'typed-run',
|
|
};
|
|
snapshot.control.orderedInteractionIds.push('typed-request');
|
|
snapshot.control.interactionById['typed-request'] = {
|
|
interactionId: 'typed-request',
|
|
runId: 'typed-run',
|
|
status: 'requested',
|
|
requestSource: 'canonical',
|
|
requestEventType: 'interaction.requested',
|
|
};
|
|
|
|
await user.click(stopButton);
|
|
|
|
expect(_mockFetchPost).not.toHaveBeenCalledWith(
|
|
expect.stringMatching(/^\/runs\//),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('reuses the request id after failure and never closes the legacy SSE', async () => {
|
|
const user = userEvent.setup();
|
|
const runningStore = setRunningEventNativeStore();
|
|
const consoleError = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => {});
|
|
_mockFetchPost.mockRejectedValue(new Error('offline'));
|
|
renderChatBox();
|
|
const stopButton = await screen.findByRole('button', {
|
|
name: 'Stop Task',
|
|
});
|
|
|
|
await user.click(stopButton);
|
|
await waitFor(() => expect(_mockFetchPost).toHaveBeenCalledTimes(1));
|
|
await waitFor(() => expect(stopButton).not.toBeDisabled());
|
|
await user.click(stopButton);
|
|
await waitFor(() => expect(_mockFetchPost).toHaveBeenCalledTimes(2));
|
|
|
|
const [firstUrl, firstBody] = _mockFetchPost.mock.calls[0];
|
|
const [secondUrl, secondBody] = _mockFetchPost.mock.calls[1];
|
|
expect(firstUrl).toBe('/runs/test-task-id/cancel');
|
|
expect(secondUrl).toBe(firstUrl);
|
|
expect(secondBody.request_id).toBe(firstBody.request_id);
|
|
expect(runningStore.stopTask).not.toHaveBeenCalled();
|
|
expect(runningStore.setIsPending).not.toHaveBeenCalled();
|
|
consoleError.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Event-native human-control compatibility bridge', () => {
|
|
it('reconciles the initiating Project after the user switches Projects', () => {
|
|
eventNativeHarness.enabled = true;
|
|
eventNativeHarness.snapshot = runningEventNativeSnapshot();
|
|
const projectAState = {
|
|
...defaultChatStoreState,
|
|
activeTaskId: 'test-task-id',
|
|
tasks: {
|
|
'test-task-id': {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
messages: [],
|
|
askList: [],
|
|
},
|
|
},
|
|
setIsPending: vi.fn(),
|
|
setDurableRunStatus: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
markHumanInteractionResolved: vi.fn(),
|
|
setActiveAskList: vi.fn(),
|
|
setActiveAsk: vi.fn(),
|
|
addMessages: vi.fn(),
|
|
};
|
|
const projectBState = {
|
|
...projectAState,
|
|
setIsPending: vi.fn(),
|
|
setDurableRunStatus: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
markHumanInteractionResolved: vi.fn(),
|
|
setActiveAskList: vi.fn(),
|
|
setActiveAsk: vi.fn(),
|
|
addMessages: vi.fn(),
|
|
};
|
|
const projectAStore = { getState: () => projectAState };
|
|
const projectBStore = { getState: () => projectBState };
|
|
defaultProjectStoreState.getActiveChatStore.mockImplementation(
|
|
(projectId?: string) =>
|
|
projectId === 'test-project-id' ? projectAStore : projectBStore
|
|
);
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: projectAState as any,
|
|
});
|
|
|
|
renderChatBox();
|
|
const initiatingCallbacks = eventNativeHarness.controlOptions;
|
|
defaultProjectStoreState.activeProjectId = 'project-b';
|
|
const interaction = {
|
|
interactionId: 'interaction-1',
|
|
runId: 'test-task-id',
|
|
};
|
|
|
|
initiatingCallbacks.onSubmissionStart(interaction);
|
|
initiatingCallbacks.onSubmissionFailure(interaction);
|
|
initiatingCallbacks.onDurableResolution(interaction);
|
|
|
|
expect(
|
|
defaultProjectStoreState.getActiveChatStore
|
|
).toHaveBeenLastCalledWith('test-project-id');
|
|
expect(projectAState.setIsPending).toHaveBeenCalledWith(
|
|
'test-task-id',
|
|
true
|
|
);
|
|
expect(projectAState.setIsPending).toHaveBeenCalledWith(
|
|
'test-task-id',
|
|
false
|
|
);
|
|
expect(projectAState.markHumanInteractionResolved).toHaveBeenCalledWith(
|
|
'test-task-id',
|
|
'interaction-1'
|
|
);
|
|
expect(projectBState.setIsPending).not.toHaveBeenCalled();
|
|
expect(projectBState.markHumanInteractionResolved).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 clear stale human reply state when backend no longer has the task lock', async () => {
|
|
const user = userEvent.setup();
|
|
_mockFetchPost.mockResolvedValueOnce({
|
|
code: 1,
|
|
text: 'This task is no longer waiting for a human reply.',
|
|
});
|
|
|
|
const storeObj = {
|
|
...defaultChatStoreState,
|
|
tasks: {
|
|
'test-task-id': {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
activeAsk: 'test-agent',
|
|
askList: [],
|
|
hasMessages: true,
|
|
},
|
|
},
|
|
} as any;
|
|
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: storeObj,
|
|
});
|
|
|
|
renderChatBox();
|
|
|
|
const messageInput = screen.getByTestId('message-input');
|
|
const sendButton = screen.getByTestId('send-button');
|
|
|
|
await user.type(messageInput, 'Late reply');
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(storeObj.removeMessage).toHaveBeenCalledWith(
|
|
'test-task-id',
|
|
expect.any(String)
|
|
);
|
|
expect(storeObj.setIsPending).toHaveBeenCalledWith(
|
|
'test-task-id',
|
|
false
|
|
);
|
|
expect(storeObj.setActiveAskList).toHaveBeenCalledWith(
|
|
'test-task-id',
|
|
[]
|
|
);
|
|
expect(storeObj.setActiveAsk).toHaveBeenCalledWith('test-task-id', '');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
it('keeps an ordinary human question pending without a skip timer', () => {
|
|
const timeoutSpy = vi.spyOn(window, 'setTimeout');
|
|
|
|
const activeAskTask = {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
activeAsk: 'test-agent',
|
|
hasMessages: true,
|
|
messages: [
|
|
{
|
|
id: 'ask-1',
|
|
role: 'agent',
|
|
content: 'Please clarify',
|
|
step: 'ask',
|
|
},
|
|
],
|
|
};
|
|
const storeObj = {
|
|
...defaultChatStoreState,
|
|
tasks: { 'test-task-id': activeAskTask },
|
|
} as any;
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: storeObj,
|
|
});
|
|
|
|
renderChatBox();
|
|
|
|
expect(
|
|
timeoutSpy.mock.calls.filter(([, delay]) => delay === 30000)
|
|
).toHaveLength(0);
|
|
expect(_mockFetchPost).not.toHaveBeenCalledWith(
|
|
'/chat/test-project-id/human-reply',
|
|
expect.objectContaining({ reply: 'skip' })
|
|
);
|
|
expect(storeObj.setActiveAsk).not.toHaveBeenCalled();
|
|
expect(storeObj.setIsPending).not.toHaveBeenCalled();
|
|
timeoutSpy.mockRestore();
|
|
});
|
|
|
|
it('should not auto-skip a question while replaying history', () => {
|
|
const timeoutSpy = vi.spyOn(window, 'setTimeout');
|
|
const replayTask = {
|
|
...defaultChatStoreState.tasks['test-task-id'],
|
|
type: 'replay',
|
|
activeAsk: 'test-agent',
|
|
hasMessages: true,
|
|
messages: [
|
|
{
|
|
id: 'historical-ask-1',
|
|
role: 'agent',
|
|
content: 'Historical question',
|
|
step: 'ask',
|
|
},
|
|
],
|
|
};
|
|
mockUseChatStoreAdapter.mockReturnValue({
|
|
projectStore: defaultProjectStoreState as any,
|
|
chatStore: {
|
|
...defaultChatStoreState,
|
|
tasks: { 'test-task-id': replayTask },
|
|
} as any,
|
|
});
|
|
|
|
renderChatBox();
|
|
|
|
expect(
|
|
timeoutSpy.mock.calls.filter(([, delay]) => delay === 30000)
|
|
).toHaveLength(0);
|
|
timeoutSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
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(() => {
|
|
const foundCloud = !!(
|
|
document.body.textContent &&
|
|
document.body.textContent.includes('Self-hosted')
|
|
);
|
|
const hasInput = !!screen.queryByTestId('message-input');
|
|
expect(foundCloud || hasInput).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should show search key warning when missing API keys', async () => {
|
|
mockProxyFetchGet.mockImplementation((url: string) => {
|
|
if (url === '/api/providers' || url === '/api/v1/providers') {
|
|
return Promise.resolve({
|
|
items: [{ id: 'test-provider', name: 'Test' }],
|
|
});
|
|
}
|
|
if (url === '/api/v1/configs') {
|
|
return Promise.resolve([]); // No API keys
|
|
}
|
|
return Promise.resolve({});
|
|
});
|
|
|
|
mockUseAuthStore.mockReturnValue({
|
|
modelType: 'local',
|
|
} as any);
|
|
|
|
renderChatBox();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
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 configs fetch errors', async () => {
|
|
const consoleErrorSpy = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => {});
|
|
|
|
mockProxyFetchGet.mockRejectedValue(new Error('Configs fetch failed'));
|
|
|
|
expect(() => renderChatBox()).not.toThrow();
|
|
|
|
await waitFor(() => {
|
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|