Privacy Dialog
@@ -76,12 +116,15 @@ vi.mock('../../../src/components/Dialog/Privacy', () => ({
)
}))
-describe('ChatBox Component', () => {
- const mockUseChatStore = vi.mocked(useChatStore)
+describe('ChatBox Component', async () => {
const mockUseAuthStore = vi.mocked(useAuthStore)
const mockFetchPost = vi.mocked(fetchPost)
const mockProxyFetchGet = vi.mocked(proxyFetchGet)
+ // 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: {
@@ -103,7 +146,8 @@ describe('ChatBox Component', () => {
cotList: [],
activeWorkSpace: null,
snapshots: [],
- isTaskEdit: false
+ isTaskEdit: false,
+ isContextExceeded: false
}
},
setHasMessages: vi.fn(),
@@ -122,7 +166,44 @@ describe('ChatBox Component', () => {
setIsTaskEdit: vi.fn(),
addTaskInfo: vi.fn(),
updateTaskInfo: vi.fn(),
- deleteTaskInfo: 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 = {
@@ -132,9 +213,13 @@ describe('ChatBox Component', () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
-
+
// Setup default store states
- mockUseChatStore.mockReturnValue(defaultChatStoreState as any)
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: defaultChatStoreState as any
+ })
+ mockUseProjectStore.mockReturnValue(defaultProjectStoreState as any)
mockUseAuthStore.mockReturnValue(defaultAuthStoreState as any)
// Setup default API responses
@@ -179,15 +264,15 @@ describe('ChatBox Component', () => {
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 input component', () => {
+ it('should render bottom box component', () => {
renderChatBox()
-
- expect(screen.getByTestId('bottom-input')).toBeInTheDocument()
+
+ expect(screen.getByTestId('bottom-box')).toBeInTheDocument()
})
it('should fetch privacy settings on mount', async () => {
@@ -275,7 +360,7 @@ describe('ChatBox Component', () => {
describe('Chat Interface', () => {
beforeEach(() => {
- mockUseChatStore.mockReturnValue({
+ const updatedChatState = {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -297,34 +382,68 @@ describe('ChatBox Component', () => {
hasMessages: true
}
}
- } as any)
+ }
+
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: updatedChatState as any
+ })
})
- it('should render chat messages when they exist', () => {
+ it('should render project chat container when messages exist', () => {
renderChatBox()
-
- expect(screen.getByTestId('message-user')).toHaveTextContent('Hello')
- expect(screen.getByTestId('message-assistant')).toHaveTextContent('Hi there!')
+
+ 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)
-
- expect(defaultChatStoreState.addMessages).toHaveBeenCalledWith(
- 'test-task-id',
- expect.objectContaining({
- role: 'user',
- content: 'Test message'
- })
- )
+
+ // The component should call fetchPost for continuing conversation
+ await waitFor(() => {
+ expect(mockFetchPost).toHaveBeenCalled()
+ })
})
it('should not send empty messages', async () => {
@@ -340,8 +459,10 @@ describe('ChatBox Component', () => {
})
describe('Task Management', () => {
- it('should render task card when step is to_sub_tasks', () => {
- mockUseChatStore.mockReturnValue({
+ it('should render project chat container when tasks have messages', () => {
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -360,15 +481,19 @@ describe('ChatBox Component', () => {
cotList: []
}
}
- } as any)
+ } as any
+ })
renderChatBox()
-
- expect(screen.getByTestId('task-card')).toBeInTheDocument()
+
+ // With the new architecture, task cards are rendered inside ProjectChatContainer
+ expect(screen.getByTestId('project-chat-container')).toBeInTheDocument()
})
- it('should render notice card when appropriate', () => {
- mockUseChatStore.mockReturnValue({
+ it('should render project chat container for notice card scenario', () => {
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -386,17 +511,21 @@ describe('ChatBox Component', () => {
cotList: ['item1']
}
}
- } as any)
+ } as any
+ })
renderChatBox()
-
- expect(screen.getByTestId('notice-card')).toBeInTheDocument()
+
+ // With the new architecture, notice cards are rendered inside ProjectChatContainer
+ expect(screen.getByTestId('project-chat-container')).toBeInTheDocument()
})
})
describe('Loading States', () => {
- it('should show skeleton when task is pending', () => {
- mockUseChatStore.mockReturnValue({
+ it('should render project chat container when task is pending', () => {
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -413,17 +542,21 @@ describe('ChatBox Component', () => {
isTakeControl: false
}
}
- } as any)
+ } as any
+ })
renderChatBox()
-
- expect(screen.getByTestId('skeleton')).toBeInTheDocument()
+
+ // With the new architecture, loading states are handled inside ProjectChatContainer
+ expect(screen.getByTestId('project-chat-container')).toBeInTheDocument()
})
})
describe('File Handling', () => {
- it('should render file list when message has end step with files', () => {
- mockUseChatStore.mockReturnValue({
+ it('should render project chat container when message has files', () => {
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -446,18 +579,19 @@ describe('ChatBox Component', () => {
hasMessages: true
}
}
- } as any)
+ } as any
+ })
renderChatBox()
-
- expect(screen.getByText('test-file')).toBeInTheDocument()
- expect(screen.getByText('PDF')).toBeInTheDocument()
+
+ // With the new architecture, file lists are rendered inside ProjectChatContainer
+ expect(screen.getByTestId('project-chat-container')).toBeInTheDocument()
})
- it('should handle file selection', async () => {
- const user = userEvent.setup()
-
- mockUseChatStore.mockReturnValue({
+ it('should render project chat container for file handling', () => {
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -480,34 +614,23 @@ describe('ChatBox Component', () => {
hasMessages: true
}
}
- } as any)
+ } as any
+ })
renderChatBox()
-
- const fileElement = screen.getByText('test-file').closest('div')
- if (fileElement) {
- await user.click(fileElement)
-
- expect(defaultChatStoreState.setSelectedFile).toHaveBeenCalledWith(
- 'test-task-id',
- expect.objectContaining({
- name: 'test-file.pdf',
- type: 'PDF'
- })
- )
- expect(defaultChatStoreState.setActiveWorkSpace).toHaveBeenCalledWith(
- 'test-task-id',
- 'documentWorkSpace'
- )
- }
+
+ // 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()
-
- mockUseChatStore.mockReturnValue({
+
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -517,19 +640,21 @@ describe('ChatBox Component', () => {
hasMessages: true
}
}
- } as any)
+ } 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-task-id/human-reply',
+ '/chat/test-project-id/human-reply',
{
agent: 'test-agent',
reply: 'Test reply'
@@ -561,9 +686,12 @@ describe('ChatBox Component', () => {
}
} as any
- mockUseChatStore.mockReturnValue(storeObj)
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: storeObj
+ })
- renderChatBox()
+ renderChatBox()
// Type a non-empty message so handleSend proceeds to process the ask list
const messageInput = screen.getByTestId('message-input')
@@ -688,20 +816,34 @@ describe('ChatBox Component', () => {
})
describe('Keyboard Shortcuts', () => {
- it('should handle Ctrl+Enter keyboard shortcut', async () => {
+ 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')
-
- // Simulate Ctrl+Enter
- // Not all test environments simulate Ctrl+Enter handlers; click the send button instead
- const sendButton = screen.getByTestId('send-button')
- await user.click(sendButton)
- expect(defaultChatStoreState.addMessages).toHaveBeenCalled()
+ // 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()
+ })
})
})
@@ -712,7 +854,9 @@ describe('ChatBox Component', () => {
mockFetchPost.mockRejectedValue(new Error('API Error'))
// Force a code path that calls fetchPost by setting activeAsk on the task
- mockUseChatStore.mockReturnValue({
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: {
...defaultChatStoreState,
tasks: {
'test-task-id': {
@@ -721,7 +865,8 @@ describe('ChatBox Component', () => {
hasMessages: true
}
}
- } as any)
+ } as any
+ })
renderChatBox()
diff --git a/test/unit/components/SearchInput.test.tsx b/test/unit/components/SearchInput.test.tsx
index ec2b8215f..f8e47aaf3 100644
--- a/test/unit/components/SearchInput.test.tsx
+++ b/test/unit/components/SearchInput.test.tsx
@@ -7,7 +7,15 @@ import { useState } from 'react'
// Mock the Input component from ui (matching relative import in component)
vi.mock('../../../src/components/ui/input', () => ({
- Input: vi.fn().mockImplementation((props) =>
)
+ Input: vi.fn().mockImplementation((props) => {
+ const { leadingIcon, ...restProps } = props
+ return (
+
+ {leadingIcon &&
{leadingIcon}
}
+
+
+ )
+ })
}))
// Mock lucide-react
@@ -15,6 +23,16 @@ vi.mock('lucide-react', () => ({
Search: vi.fn().mockImplementation((props) =>
)
}))
+// Mock i18next
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => {
+ if (key === 'setting.search-mcp') return 'Search MCPs'
+ return key
+ }
+ })
+}))
+
describe('SearchInput Component', () => {
const defaultProps = {
value: '',
@@ -32,73 +50,69 @@ describe('SearchInput Component', () => {
describe('Initial Render', () => {
it('should render input field', () => {
render(
)
-
+
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should render with empty value initially', () => {
render(
)
-
+
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should render with provided value', () => {
render(
)
-
+
const input = screen.getByRole('textbox')
expect(input).toHaveValue('test search')
})
it('should render search icon', () => {
render(
)
-
+
const searchIcons = screen.getAllByTestId('search-icon')
expect(searchIcons.length).toBeGreaterThan(0)
})
})
describe('Placeholder Behavior', () => {
- it('should show placeholder when value is empty and not focused', () => {
+ it('should have placeholder attribute', () => {
render(
)
-
- expect(screen.getByText('Search MCPs')).toBeInTheDocument()
+
+ const input = screen.getByPlaceholderText('Search MCPs')
+ expect(input).toBeInTheDocument()
})
- it('should hide placeholder when input has value', () => {
+ it('should show placeholder when value is empty', () => {
+ render(
)
+
+ const input = screen.getByPlaceholderText('Search MCPs')
+ expect(input).toHaveValue('')
+ })
+
+ it('should not show placeholder text when input has value', () => {
render(
)
-
- expect(screen.queryByText('Search MCPs')).not.toBeInTheDocument()
+
+ const input = screen.getByRole('textbox') as HTMLInputElement
+ expect(input.value).toBe('search term')
})
- it('should hide placeholder when input is focused', async () => {
+ it('should maintain placeholder after focus and blur when empty', async () => {
const user = userEvent.setup()
render(
)
-
- const input = screen.getByRole('textbox')
- await user.click(input)
-
- await waitFor(() => {
- expect(screen.queryByText('Search MCPs')).not.toBeInTheDocument()
- })
- })
- it('should show placeholder again when input loses focus and is empty', async () => {
- const user = userEvent.setup()
- render(
)
-
const input = screen.getByRole('textbox')
-
+
// Focus the input
await user.click(input)
-
+
// Blur the input
await user.tab()
-
- await waitFor(() => {
- expect(screen.getByText('Search MCPs')).toBeInTheDocument()
- })
+
+ // Placeholder should still be present
+ expect(screen.getByPlaceholderText('Search MCPs')).toBeInTheDocument()
})
})
@@ -106,45 +120,34 @@ describe('SearchInput Component', () => {
it('should handle focus event', async () => {
const user = userEvent.setup()
render(
)
-
+
const input = screen.getByRole('textbox')
await user.click(input)
-
+
expect(input).toHaveFocus()
})
it('should handle blur event', async () => {
const user = userEvent.setup()
render(
)
-
+
const input = screen.getByRole('textbox')
await user.click(input)
await user.tab()
-
+
expect(input).not.toHaveFocus()
})
- it('should change text alignment when focused', async () => {
+ it('should accept text input when focused', async () => {
const user = userEvent.setup()
- render(
)
-
- const input = screen.getByRole('textbox')
-
- // Initially should have center alignment (when empty and not focused)
- expect(input).toHaveStyle({ textAlign: 'center' })
-
- // Focus the input
- await user.click(input)
-
- // Should have left alignment when focused
- expect(input).toHaveStyle({ textAlign: 'left' })
- })
+ const mockOnChange = vi.fn()
+ render(
)
- it('should change text alignment when has value', () => {
- render(
)
-
const input = screen.getByRole('textbox')
- expect(input).toHaveStyle({ textAlign: 'left' })
+ await user.click(input)
+ await user.keyboard('test')
+
+ expect(mockOnChange).toHaveBeenCalled()
})
})
@@ -201,91 +204,50 @@ describe('SearchInput Component', () => {
})
describe('Icon Positioning', () => {
- it('should position search icon in center when placeholder is shown', () => {
+ it('should render search icon in component', () => {
render(
)
-
- const placeholderContainer = screen.getByText('Search MCPs').parentElement
- expect(placeholderContainer).toHaveClass('justify-center')
+
+ const searchIcon = screen.getByTestId('search-icon')
+ expect(searchIcon).toBeInTheDocument()
})
- it('should position search icon on left when input has value', () => {
+ it('should include leading icon when value is empty', () => {
+ render(
)
+
+ // The component should render with a leading icon
+ const iconWrapper = document.querySelector('.leading-icon-wrapper')
+ expect(iconWrapper).toBeInTheDocument()
+ })
+
+ it('should include leading icon when input has value', () => {
render(
)
-
- // When value exists, the left-positioned icon should be visible
- const leftIcon = document.querySelector('.absolute.left-4')
- expect(leftIcon).toBeInTheDocument()
- })
- it('should position search icon on left when input is focused', async () => {
- const user = userEvent.setup()
- render(
)
-
- const input = screen.getByRole('textbox')
- await user.click(input)
-
- await waitFor(() => {
- const leftIcon = document.querySelector('.absolute.left-4')
- expect(leftIcon).toBeInTheDocument()
- })
+ // The component should render with a leading icon
+ const iconWrapper = document.querySelector('.leading-icon-wrapper')
+ expect(iconWrapper).toBeInTheDocument()
})
})
describe('Styling and Classes', () => {
- it('should apply correct CSS classes to input', () => {
+ it('should render within a container with relative positioning', () => {
render(
)
-
- const input = screen.getByRole('textbox')
- expect(input).toHaveClass(
- 'h-6',
- 'pl-12',
- 'pr-4',
- 'py-2',
- 'bg-bg-surface-tertiary',
- 'rounded-[24px]',
- 'border-none',
- 'shadow-none',
- 'focus-visible:ring-0',
- 'focus-visible:ring-transparent',
- 'focus-visible:border-none',
- 'text-gray-900'
- )
- })
- it('should apply correct classes to container', () => {
- render(
)
-
const container = screen.getByRole('textbox').parentElement
expect(container).toHaveClass('relative', 'w-full')
})
- it('should apply correct classes to placeholder', () => {
+ it('should apply placeholder to input', () => {
render(
)
-
- const placeholder = screen.getByText('Search MCPs').parentElement
- expect(placeholder).toHaveClass(
- 'pointer-events-none',
- 'absolute',
- 'inset-0',
- 'flex',
- 'items-center',
- 'justify-center',
- 'text-text-secondary',
- 'select-none'
- )
+
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveAttribute('placeholder', 'Search MCPs')
})
- it('should apply correct classes to search icon in placeholder', () => {
+ it('should render search icon component', () => {
render(
)
-
- const searchIcon = screen.getAllByTestId('search-icon')[0]
- expect(searchIcon).toHaveClass('w-4', 'h-4', 'mr-2', 'text-icon-secondary')
- })
- it('should apply correct classes to search text in placeholder', () => {
- render(
)
-
- const searchText = screen.getByText('Search MCPs')
- expect(searchText).toHaveClass('text-xs', 'leading-none', 'text-text-body')
+ const searchIcon = screen.getByTestId('search-icon')
+ expect(searchIcon).toBeInTheDocument()
})
})
@@ -454,42 +416,39 @@ describe('SearchInput Component', () => {
})
describe('Component State Management', () => {
- it('should maintain internal focus state correctly', async () => {
+ it('should handle value changes correctly', async () => {
const user = userEvent.setup()
- render(
)
-
+ const Controlled = () => {
+ const [val, setVal] = useState('')
+ return
setVal(e.target.value)} />
+ }
+
+ render()
+
const input = screen.getByRole('textbox')
-
- // Initially not focused
- expect(screen.getByText('Search MCPs')).toBeInTheDocument()
-
- // Focus
- await user.click(input)
- expect(screen.queryByText('Search MCPs')).not.toBeInTheDocument()
-
- // Blur
- await user.tab()
- await waitFor(() => {
- expect(screen.getByText('Search MCPs')).toBeInTheDocument()
- })
+
+ // Type text
+ await user.type(input, 'test')
+
+ expect((input as HTMLInputElement).value).toBe('test')
})
- it('should handle rapid focus/blur events', async () => {
+ it('should handle rapid value changes', async () => {
const user = userEvent.setup()
- render()
-
- const input = screen.getByRole('textbox')
-
- // Rapid focus and blur
+ const Controlled = () => {
+ const [val, setVal] = useState('')
+ return setVal(e.target.value)} />
+ }
+
+ render()
+
+ const input = screen.getByRole('textbox') as HTMLInputElement
+
+ // Rapid focus and type
await user.click(input)
- await user.tab()
- await user.click(input)
- await user.tab()
-
- // Should end up showing placeholder
- await waitFor(() => {
- expect(screen.getByText('Search MCPs')).toBeInTheDocument()
- })
+ await user.keyboard('quick')
+
+ expect(input.value).toBe('quick')
})
})
diff --git a/test/unit/components/Terminal.test.tsx b/test/unit/components/Terminal.test.tsx
index 369527449..fa2c4987b 100644
--- a/test/unit/components/Terminal.test.tsx
+++ b/test/unit/components/Terminal.test.tsx
@@ -30,10 +30,14 @@ import userEvent from '@testing-library/user-event'
// Import the mocked Terminal constructor so we can reset implementation
// Note: Terminal mock will be accessed via require() in beforeEach to avoid hoisting issues
-// Mock dependencies
-// The mock path must match the import used later (three levels up from this test file)
-vi.mock('../../../src/store/chatStore', () => ({
- useChatStore: 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 xterm.js and its addons
@@ -76,14 +80,12 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({
// Now import the modules that depend on the mocked packages
import TerminalComponent from '../../../src/components/Terminal/index'
-import { useChatStore } from '../../../src/store/chatStore'
-describe('Terminal Component', () => {
- // Ensure we treat useChatStore as a mockable function in tests.
- // Some module resolution modes may not present it as a vi.fn, so coerce to `any` and
- // create a mockReturnValue helper when missing.
- const mockUseChatStore: any = useChatStore as any;
-
+describe('Terminal Component', async () => {
+ // Import the mocked hooks
+ 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: {
@@ -93,13 +95,45 @@ describe('Terminal Component', () => {
}
}
+ 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(),
+ getProjectTotalTokens: vi.fn(),
+ setHistoryId: vi.fn(),
+ getHistoryId: vi.fn()
+ }
+
beforeEach(() => {
vi.clearAllMocks()
- // If the imported useChatStore wasn't a vi.fn, ensure it has mockReturnValue
- if (typeof mockUseChatStore.mockReturnValue !== 'function') {
- mockUseChatStore.mockReturnValue = vi.fn()
- }
- mockUseChatStore.mockReturnValue(defaultChatStoreState as any)
+
+ // Setup default store states
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: defaultChatStoreState as any
+ })
+ mockUseProjectStore.mockReturnValue(defaultProjectStoreState as any)
// Reset terminal mock
Object.keys(mockTerminal).forEach(key => {
@@ -403,19 +437,24 @@ describe('Terminal Component', () => {
describe('Task Switching', () => {
it('should clear terminal when task changes', async () => {
const { rerender } = render()
-
+
// Change active task
- mockUseChatStore.mockReturnValue({
+ const newChatState = {
activeTaskId: 'new-task-id',
tasks: {
'new-task-id': {
terminal: []
}
}
- } as any)
-
+ }
+
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: newChatState as any
+ })
+
rerender()
-
+
await waitFor(() => {
expect(mockTerminal.clear).toHaveBeenCalled()
})
@@ -423,19 +462,24 @@ describe('Terminal Component', () => {
it('should show task switch message when showWelcome is true', async () => {
const { rerender } = render()
-
+
// Change active task
- mockUseChatStore.mockReturnValue({
+ const newChatState = {
activeTaskId: 'new-task-id',
tasks: {
'new-task-id': {
terminal: []
}
}
- } as any)
-
+ }
+
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: newChatState as any
+ })
+
rerender()
-
+
await waitFor(() => {
expect(mockTerminal.writeln).toHaveBeenCalledWith('\x1b[32mTask switched...\x1b[0m')
}, { timeout: 300 })
@@ -443,21 +487,26 @@ describe('Terminal Component', () => {
it('should restore previous output when task has history', async () => {
const historyContent = ['Previous command output']
-
- mockUseChatStore.mockReturnValue({
+
+ const newChatState = {
activeTaskId: 'task-with-history',
tasks: {
'task-with-history': {
terminal: historyContent
}
}
- } as any)
-
+ }
+
+ mockUseChatStoreAdapter.mockReturnValue({
+ projectStore: defaultProjectStoreState as any,
+ chatStore: newChatState as any
+ })
+
const { rerender } = render()
-
+
// Trigger task switch
rerender()
-
+
await waitFor(() => {
const calls = (mockTerminal.writeln as any).mock.calls.flat().map(String)
const hasStart = calls.some(c => c.includes('--- Previous Output ---'))
diff --git a/test/unit/hooks/useInstallationSetup.test.ts b/test/unit/hooks/useInstallationSetup.test.ts
index 0c0cef34c..0c1d49cf5 100644
--- a/test/unit/hooks/useInstallationSetup.test.ts
+++ b/test/unit/hooks/useInstallationSetup.test.ts
@@ -360,51 +360,4 @@ describe('useInstallationSetup Hook', () => {
})
})
- describe('Console Logging', () => {
- it('should log installation status check', async () => {
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
-
- renderHook(() => useInstallationSetup())
-
- await vi.waitFor(() => {
- expect(consoleLogSpy).toHaveBeenCalledWith(
- '[useInstallationSetup] Installation status check:',
- expect.any(Object)
- )
- })
-
- consoleLogSpy.mockRestore()
- })
-
- it('should log when installation listeners are registered', () => {
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
-
- renderHook(() => useInstallationSetup())
-
- expect(consoleLogSpy).toHaveBeenCalledWith(
- '[useInstallationSetup] Installation listeners registered'
- )
-
- consoleLogSpy.mockRestore()
- })
-
- it('should log install complete events', () => {
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
-
- renderHook(() => useInstallationSetup())
-
- const completeCallback = electronAPI.onInstallDependenciesComplete.mock.calls[0][0]
-
- act(() => {
- completeCallback({ success: true })
- })
-
- expect(consoleLogSpy).toHaveBeenCalledWith(
- '[useInstallationSetup] Install complete event received:',
- { success: true }
- )
-
- consoleLogSpy.mockRestore()
- })
- })
})
\ No newline at end of file
diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts
new file mode 100644
index 000000000..166dc4621
--- /dev/null
+++ b/test/unit/store/chatStore.test.ts
@@ -0,0 +1,486 @@
+/**
+ * ChatStore Unit Tests - Core Functionality
+ *
+ * Tests basic chatStore operations:
+ * - Task creation and removal
+ * - Status management
+ * - Token tracking
+ * - Message handling
+ */
+
+import { describe, it, expect, beforeEach, vi, MockedFunction } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+
+// 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(),
+ proxyFetchPut: vi.fn(),
+ proxyFetchGet: vi.fn(),
+ uploadFile: vi.fn(),
+ fetchDelete: vi.fn(),
+}))
+
+vi.mock('@microsoft/fetch-event-source', () => ({
+ fetchEventSource: vi.fn(),
+}))
+
+vi.mock('../../../src/store/authStore', () => ({
+ useAuthStore: {
+ token: null,
+ username: null,
+ email: null,
+ user_id: null,
+ appearance: 'light',
+ language: 'system',
+ isFirstLaunch: true,
+ modelType: 'cloud' as const,
+ cloud_model_type: 'gpt-4.1' as const,
+ initState: 'permissions' as const,
+ share_token: null,
+ workerListData: {},
+ },
+ getAuthStore: vi.fn(() => ({
+ token: null,
+ username: null,
+ email: null,
+ user_id: null,
+ appearance: 'light',
+ language: 'system',
+ isFirstLaunch: true,
+ modelType: 'cloud' as const,
+ cloud_model_type: 'gpt-4.1' as const,
+ initState: 'permissions' as const,
+ share_token: null,
+ workerListData: {},
+ })),
+ useWorkerList: vi.fn(() => [])
+}))
+
+import { useChatStore } from '../../../src/store/chatStore'
+import { useProjectStore } from '../../../src/store/projectStore'
+import { generateUniqueId } from '../../../src/lib'
+
+// 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)
+ })
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index 22e4977ad..bf5abc28e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,12 +5,12 @@
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
- "esModuleInterop": false,
+ "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
- "moduleResolution": "Node",
+ "moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,