Merge branch 'test-multiturn-logic' into enhance-replay

This commit is contained in:
a7m-1st 2025-11-08 18:56:33 +03:00
commit da56183193
10 changed files with 3378 additions and 0 deletions

View file

@ -0,0 +1,769 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { generateUniqueId } from '../../../src/lib'
// Import proxy mock to enable API mocking
import '../../mocks/proxy.mock'
// Also Mock authStore & sse
import '../../mocks/authStore.mock'
import '../../mocks/sse.mock'
// Import chat store to ensure it's available
import '../../../src/store/chatStore'
import { useProjectStore } from '../../../src/store/projectStore'
import useChatStoreAdapter from '../../../src/hooks/useChatStoreAdapter'
import { mockFetchEventSource } from '../../mocks/sse.mock'
// Helper function for sequential SSE events
const createSSESequence = (events: Array<{ event: any; delay: number }>) => {
return async (onMessage: (data: any) => void) => {
for (let i = 0; i < events.length; i++) {
const { event, delay } = events[i]
await new Promise<void>((resolve) => {
setTimeout(() => {
console.log(`Sending SSE Event ${i + 1}:`, event.step);
onMessage({
data: JSON.stringify(event)
})
resolve()
}, delay)
})
}
}
}
// Mock electron IPC
(global as any).ipcRenderer = {
invoke: vi.fn((channel) => {
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({})
if (channel === 'get-file-list') return Promise.resolve([])
return Promise.resolve()
}),
}
// Mock window.electronAPI
Object.defineProperty(window, 'electronAPI', {
value: {
uploadLog: vi.fn().mockResolvedValue(undefined),
// Add other electronAPI methods as needed
},
writable: true,
})
describe("Case 3: Add to the workforce queue", () => {
beforeEach(() => {
vi.clearAllMocks();
const { result } = renderHook(() => useProjectStore());
//Reset projectStore
result.current.getAllProjects().forEach(project => {
result.current.removeProject(project.id)
})
//Create initial Project
const projectId = result.current.createProject(
'Queue Test Project',
'Testing message queue functionality'
)
expect(projectId).toBeDefined()
// Get chatStore (automatically created)
let chatStore = result.current.getActiveChatStore(projectId)!
expect(chatStore).toBeDefined()
const initiatorTaskId = chatStore.getState().activeTaskId!
expect(initiatorTaskId).toBeDefined()
})
afterEach(() => {
vi.clearAllMocks()
})
it("should queue messages when task is busy and process them after completion", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
let sseCallCount = 0
let queuedTaskIds: string[] = []
// Mock SSE stream with controlled events - delay setup until after task IDs are available
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
sseCallCount++
console.log(`SSE Call #${sseCallCount} initiated`)
if (options.onmessage) {
// First send the immediate events (confirmed, to_sub_tasks, end)
const immediateSequence = createSSESequence([
{
event: {
step: 'confirmed',
data: { question: 'Build a calculator app' }
},
delay: 100
},
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Calculator App|Build a simple calculator',
sub_tasks: [
{ id: 'task-1', content: 'Create UI components', status: '' },
{ id: 'task-2', content: 'Implement calculator logic', status: '' },
],
},
},
delay: 200
},
{
event: {
step: "end",
data: "--- Calculator Task Result ---\nCalculator app completed successfully!"
},
delay: 300
}
])
await immediateSequence(options.onmessage)
// Wait for queuedTaskIds to be populated, then send queue-related events
const checkForTaskIds = () => {
return new Promise<void>((resolve) => {
const pollForIds = () => {
if (queuedTaskIds.length > 0) {
console.log(`Found queued task IDs: ${queuedTaskIds}`)
resolve()
} else {
setTimeout(pollForIds, 50) // Check every 50ms
}
}
pollForIds()
})
}
await checkForTaskIds()
// Now send the queue-related events with actual task IDs
const queueSequence = createSSESequence([
{
event: {
step: 'new_task_state',
data: {
task_id: queuedTaskIds[0], // First queued task ID
content: 'Build a calculator app 2',
project_id: projectId
}
},
delay: 100
},
{
event: {
step: 'remove_task',
data: {
task_id: queuedTaskIds[0], // Remove first task from queue
project_id: projectId
}
},
delay: 200
},
{
event: {
step: 'confirmed',
data: { question: 'Build a calculator app 2' }
},
delay: 100
},
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Calculator App|Build a simple calculator 2',
sub_tasks: [
{ id: 'task-1', content: 'Create UI components 2', status: '' },
{ id: 'task-2', content: 'Implement calculator logic 2', status: '' },
],
},
},
delay: 200
},
{
event: {
step: "end",
data: "--- Queue Result ---\nCalculator app completed successfully!"
},
delay: 300
}
])
await queueSequence(options.onmessage)
}
})
// Get initial state
const { chatStore: initialChatStore, projectStore } = result.current
const projectId = projectStore.activeProjectId as string
const initiatorTaskId = initialChatStore.activeTaskId
// Verify initial queue is empty
expect(projectStore.getProjectById(projectId)?.queuedMessages).toEqual([])
// Step 1: Start first task
await act(async () => {
const userMessage = 'Build a calculator app'
await initialChatStore.startTask(initiatorTaskId, undefined, undefined, undefined, userMessage)
rerender()
})
// Wait for task to start and reach 'to_sub_tasks' phase (task becomes busy)
await waitFor(() => {
rerender()
const { chatStore, projectStore } = result.current
const taskId = chatStore.activeTaskId
const task = chatStore.tasks[taskId]
// Task should have subtasks (making it busy)
expect(task.summaryTask).toBe('Calculator App|Build a simple calculator')
expect(task.taskInfo).toHaveLength(3)
console.log("Task reached to_sub_tasks phase - now busy")
}, { timeout: 1500 })
// Step 2: Add messages to queue while task is busy
await act(async () => {
rerender()
const { projectStore } = result.current
const projectId = projectStore.activeProjectId as string
// Add first queued message
const tempMessageContent1 = 'Build a todo app'
const currentAttaches1: any[] = []
const new_task_id_1 = projectStore.addQueuedMessage(
projectId,
tempMessageContent1,
currentAttaches1
)
// Add second queued message
const tempMessageContent2 = 'Create a weather app'
const currentAttaches2: any[] = []
const new_task_id_2 = projectStore.addQueuedMessage(
projectId,
tempMessageContent2,
currentAttaches2
)
expect(new_task_id_1).toBeDefined()
expect(new_task_id_2).toBeDefined()
expect(new_task_id_1).not.toBe(new_task_id_2)
// Store task IDs for SSE events
queuedTaskIds = [new_task_id_1!, new_task_id_2!]
console.log("Added messages to queue:", { new_task_id_1, new_task_id_2 })
})
// Step 3: Verify messages are in queue
await waitFor(() => {
rerender()
const { projectStore } = result.current
const project = projectStore.getProjectById(projectId)
expect(project?.queuedMessages).toHaveLength(2)
expect(project?.queuedMessages?.[0].content).toBe('Build a todo app')
expect(project?.queuedMessages?.[1].content).toBe('Create a weather app')
console.log("Queue verified with 2 messages")
})
// Step 4: Wait for task completion
await waitFor(() => {
rerender()
const { chatStore } = result.current
const taskId = chatStore.activeTaskId
const task = chatStore.tasks[taskId]
expect(task.status).toBe('finished')
console.log("Main task completed")
}, { timeout: 2000 })
// Step 5: Wait for new_task_state event to process queue
await waitFor(() => {
rerender()
//Get new appended chatStore
const { chatStore } = result.current
const taskId = chatStore.activeTaskId
const task = chatStore.tasks[taskId]
// Look for new_task_state in messages
const hasNewTaskState = task.messages.some((m: any) => m.content === 'Build a calculator app 2')
expect(hasNewTaskState).toBe(true)
console.log("new_task_state event detected - new chat created")
}, { timeout: 3000 })
// Step 6: Wait for remove_task event to clear queue
await waitFor(() => {
rerender()
const { projectStore } = result.current
const project = projectStore.getProjectById(projectId)
// After remove_task event, first queued message should be removed, leaving 1 message
expect(project?.queuedMessages).toHaveLength(1)
expect(project?.queuedMessages?.[0].content).toBe('Create a weather app')
console.log("Queue processed - first message removed")
}, { timeout: 4000 });
//Waitfor end sse
await waitFor(() => {
rerender()
const { chatStore: finalChatStore, projectStore: finalProjectStore } = result.current;
const finalTaskId = finalChatStore.activeTaskId;
const finalTask = finalChatStore.tasks[finalTaskId];
expect(finalTask.status).toBe('finished');
})
// Step 7: Verify final state
const { chatStore: finalChatStore, projectStore: finalProjectStore } = result.current
const finalProject = finalProjectStore.getProjectById(projectId)
// Queue should have 1 remaining message (the second one)
expect(finalProject?.queuedMessages).toHaveLength(1)
expect(finalProject?.queuedMessages?.[0].content).toBe('Create a weather app')
// Verify task completed successfully
const finalTaskId = finalChatStore.activeTaskId
const finalTask = finalChatStore.tasks[finalTaskId]
expect(finalTask.status).toBe('finished')
//Not to be because its a new chatStore
expect(finalTask.summaryTask).not.toBe('Calculator App|Build a simple calculator')
expect(finalTask.summaryTask).toBe('Calculator App|Build a simple calculator 2')
console.log("Test completed - queue management verified: one task processed, one remains")
})
it("should handle multiple queue additions and removals correctly", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { projectStore } = result.current
const projectId = projectStore.activeProjectId as string
// Verify initial state
expect(projectStore.getProjectById(projectId)?.queuedMessages).toEqual([])
await act(async () => {
// Add multiple messages to queue
const messages = [
'Build a calculator',
'Create a todo app',
'Develop a weather app',
'Make a chat application'
]
const taskIds: string[] = []
messages.forEach((message, index) => {
const taskId = projectStore.addQueuedMessage(
projectId,
message,
[]
)
taskIds.push(taskId)
expect(taskId).toBeDefined()
})
// Verify all messages are queued
const project = projectStore.getProjectById(projectId)
expect(project?.queuedMessages).toHaveLength(4)
messages.forEach((message, index) => {
expect(project?.queuedMessages?.[index].content).toBe(message)
expect(project?.queuedMessages?.[index].task_id).toBe(taskIds[index])
})
// Remove middle message
projectStore.removeQueuedMessage(projectId, taskIds[1])
// Verify removal
const updatedProject = projectStore.getProjectById(projectId)
expect(updatedProject?.queuedMessages).toHaveLength(3)
expect(updatedProject?.queuedMessages?.map((m: any) => m.content)).toEqual([
'Build a calculator',
'Develop a weather app',
'Make a chat application'
])
// Remove first message
projectStore.removeQueuedMessage(projectId, taskIds[0])
// Verify second removal
const finalProject = projectStore.getProjectById(projectId)
expect(finalProject?.queuedMessages).toHaveLength(2)
expect(finalProject?.queuedMessages?.map((m: any) => m.content)).toEqual([
'Develop a weather app',
'Make a chat application'
])
})
})
it("should restore queued message when removal fails", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { projectStore } = result.current
const projectId = projectStore.activeProjectId as string
await act(async () => {
// Add a message to queue
const messageContent = 'Test message for restoration'
const attachments: any[] = [{ fileName: 'test.txt', filePath: '/test/path' }]
const taskId = projectStore.addQueuedMessage(
projectId,
messageContent,
attachments
)
// Verify message is queued
let project = projectStore.getProjectById(projectId)
expect(project?.queuedMessages).toHaveLength(1)
expect(project?.queuedMessages?.[0].content).toBe(messageContent)
expect(project?.queuedMessages?.[0].attaches).toEqual(attachments)
// Store original message for comparison
const originalMessage = project?.queuedMessages?.[0]
// Remove the message (this would normally trigger an API call)
projectStore.removeQueuedMessage(projectId, taskId)
// Verify optimistic removal
project = projectStore.getProjectById(projectId)
expect(project?.queuedMessages).toHaveLength(0)
// Simulate restoration (as would happen on API failure)
if (originalMessage) {
projectStore.restoreQueuedMessage(projectId, {
task_id: originalMessage.task_id,
content: originalMessage.content,
timestamp: originalMessage.timestamp,
attaches: originalMessage.attaches
})
}
// Verify message is restored
project = projectStore.getProjectById(projectId)
expect(project?.queuedMessages).toHaveLength(1)
expect(project?.queuedMessages?.[0].content).toBe(messageContent)
expect(project?.queuedMessages?.[0].attaches).toEqual(attachments)
expect(project?.queuedMessages?.[0].task_id).toBe(taskId)
})
})
it("should maintain queue order and timestamps correctly", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { projectStore } = result.current
const projectId = projectStore.activeProjectId as string
await act(async () => {
const messages = ['First message', 'Second message', 'Third message']
const taskIds: string[] = []
const timestamps: number[] = []
// Add messages with small delays to ensure different timestamps
for (let i = 0; i < messages.length; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
const taskId = projectStore.addQueuedMessage(
projectId,
messages[i],
[]
)
taskIds.push(taskId)
const project = projectStore.getProjectById(projectId)
const addedMessage = project?.queuedMessages?.find((m: any) => m.task_id === taskId)
if (addedMessage) {
timestamps.push(addedMessage.timestamp)
}
}
// Verify order and timestamps
const project = projectStore.getProjectById(projectId)
expect(project?.queuedMessages).toHaveLength(3)
project?.queuedMessages?.forEach((message: any, index: number) => {
expect(message.content).toBe(messages[index])
expect(message.task_id).toBe(taskIds[index])
expect(message.timestamp).toBe(timestamps[index])
// Verify timestamps are in ascending order
if (index > 0) {
expect(message.timestamp).toBeGreaterThanOrEqual(timestamps[index - 1])
}
})
})
})
it("should handle confirmed -> subtasks -> end -> new_task_state -> remove_task sequence with queue processing", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
let comprehensiveQueuedTaskIds: string[] = []
// Get initial state
const { chatStore: initialChatStore, projectStore } = result.current
const projectId = projectStore.activeProjectId as string
const initiatorTaskId = initialChatStore.activeTaskId
// Verify initial queue is empty
expect(projectStore.getProjectById(projectId)?.queuedMessages).toEqual([])
// Mock SSE stream with controlled events - delay queue-related events until task IDs are available
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
if (options.onmessage) {
// First send the immediate events (confirmed, to_sub_tasks, end)
const immediateSequence = createSSESequence([
{
event: {
step: 'confirmed',
data: { question: 'Build a calculator app' }
},
delay: 100
},
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Calculator App|Build a comprehensive calculator',
sub_tasks: [
{ id: 'calc-ui', content: 'Create calculator UI', status: '' },
{ id: 'calc-logic', content: 'Implement calculation logic', status: '' },
{ id: 'calc-tests', content: 'Write unit tests', status: '' },
],
},
},
delay: 200
},
{
event: {
step: "end",
data: "--- Task Completed Successfully ---\nCalculator app development finished!"
},
delay: 300
}
])
await immediateSequence(options.onmessage)
// Wait for comprehensiveQueuedTaskIds to be populated, then send queue-related events
const checkForTaskIds = () => {
return new Promise<void>((resolve) => {
const pollForIds = () => {
if (comprehensiveQueuedTaskIds.length > 0) {
console.log(`Found comprehensive queued task IDs: ${comprehensiveQueuedTaskIds}`)
resolve()
} else {
setTimeout(pollForIds, 50) // Check every 50ms
}
}
pollForIds()
})
}
await checkForTaskIds()
// Now send the queue-related events with actual task IDs
const queueSequence = createSSESequence([
{
event: {
step: 'confirmed',
data: {
task_id: comprehensiveQueuedTaskIds[0], // First queued task ID
question: 'Build a todo application',
}
},
delay: 100
},
{
event: {
step: 'new_task_state',
data: {
task_id: comprehensiveQueuedTaskIds[0], // First queued task ID
content: 'Build a todo application',
project_id: projectId
}
},
delay: 100
},
{
event: {
step: 'remove_task',
data: {
task_id: comprehensiveQueuedTaskIds[0], // Remove first task from queue
project_id: projectId
}
},
delay: 200
},
{
event: {
step: "end",
data: "--- Task Completed Successfully ---\nCalculator app development finished! 2"
},
delay: 300
}
])
await queueSequence(options.onmessage)
}
})
// Step 1: Start the main task
await act(async () => {
const userMessage = 'Build a calculator app'
await initialChatStore.startTask(initiatorTaskId, undefined, undefined, undefined, userMessage)
rerender()
})
// Step 2: Wait for confirmed event
await waitFor(() => {
rerender()
const { chatStore } = result.current
const taskId = chatStore.activeTaskId
const task = chatStore.tasks[taskId]
// Check for confirmed step
const hasContent = task.messages.some((m: any) => m.content === 'Build a calculator app')
expect(hasContent).toBe(true)
console.log("✓ Confirmed event received")
}, { timeout: 1000 })
// Step 3: Wait for subtasks event
await waitFor(() => {
rerender()
const { chatStore } = result.current
const taskId = chatStore.activeTaskId
const task = chatStore.tasks[taskId]
// Task should have subtasks
expect(task.summaryTask).toBe('Calculator App|Build a comprehensive calculator')
expect(task.taskInfo).toHaveLength(4) // main task + 3 subtasks
expect(task.taskRunning).toHaveLength(4)
console.log("✓ Subtasks created")
}, { timeout: 1500 })
// Step 4: Add messages to queue while task is in subtasks phase
await act(async () => {
rerender()
const { projectStore } = result.current
const projectId = projectStore.activeProjectId as string
// Add queued messages
const tempMessageContent1 = 'Build a todo application'
const currentAttaches1: any[] = [{ fileName: 'requirements.txt', filePath: '/path/to/req.txt' }]
const new_task_id_1 = projectStore.addQueuedMessage(
projectId,
tempMessageContent1,
currentAttaches1
)
const tempMessageContent2 = 'Create a weather dashboard'
const currentAttaches2: any[] = []
const new_task_id_2 = projectStore.addQueuedMessage(
projectId,
tempMessageContent2,
currentAttaches2
)
expect(new_task_id_1).toBeDefined()
expect(new_task_id_2).toBeDefined()
// Store task IDs for SSE events
comprehensiveQueuedTaskIds = [new_task_id_1!, new_task_id_2!]
console.log("✓ Messages added to queue during subtasks phase")
})
// Step 5: Verify messages are properly queued
await waitFor(() => {
rerender()
const { projectStore } = result.current
const project = projectStore.getProjectById(projectId)
expect(project?.queuedMessages).toHaveLength(2)
expect(project?.queuedMessages?.[0].content).toBe('Build a todo application')
expect(project?.queuedMessages?.[1].content).toBe('Create a weather dashboard')
expect(project?.queuedMessages?.[0].attaches).toHaveLength(1)
expect(project?.queuedMessages?.[1].attaches).toHaveLength(0)
console.log("✓ Queue contains expected messages with attachments")
})
// Step 6: Wait for task completion
await waitFor(() => {
rerender()
const { chatStore } = result.current
const taskId = chatStore.activeTaskId
const task = chatStore.tasks[taskId]
expect(task.status).toBe('finished')
console.log("✓ Main task completed")
}, { timeout: 2000 })
// Step 7: Wait for new_task_state event (new chat creation)
await waitFor(() => {
rerender()
const { chatStore } = result.current
const taskId = chatStore.activeTaskId
const task = chatStore.tasks[taskId]
// Look for new_task_state event
const hasNewTaskState = task.messages.some((m: any) => m.content === 'Build a todo application')
expect(hasNewTaskState).toBe(true)
console.log("✓ new_task_state event received - new chat created")
}, { timeout: 3000 })
// Step 8: Wait for remove_task event to process queue
await waitFor(() => {
rerender()
const { projectStore } = result.current
const project = projectStore.getProjectById(projectId)
// After remove_task event, first queued message should be removed, leaving 1 message
expect(project?.queuedMessages).toHaveLength(1)
expect(project?.queuedMessages?.[0].content).toBe('Create a weather dashboard')
console.log("✓ Queue processed - first message removed")
}, { timeout: 4000 })
//Waitfor end sse
await waitFor(() => {
rerender()
const { chatStore: finalChatStore, projectStore: finalProjectStore } = result.current;
const finalTaskId = finalChatStore.activeTaskId;
const finalTask = finalChatStore.tasks[finalTaskId];
expect(finalTask.status).toBe('finished');
})
// Step 9: Final verification
const { chatStore: finalChatStore, projectStore: finalProjectStore } = result.current
const finalProject = finalProjectStore.getProjectById(projectId)
const finalTaskId = finalChatStore.activeTaskId
const finalTask = finalChatStore.tasks[finalTaskId]
// Queue should have 1 remaining message (the second one)
expect(finalProject?.queuedMessages).toHaveLength(1)
expect(finalProject?.queuedMessages?.[0].content).toBe('Create a weather dashboard')
expect(finalTask.status).toBe('finished')
//This time lets not add sub_task sse
expect(finalTask.summaryTask).not.toBe('Calculator App|Build a comprehensive calculator')
//Get previous chatStore
const [initial, first, last] = finalProjectStore.getAllChatStores(finalProjectStore.activeProjectId);
const originalTaskId = first.chatStore.getState().activeTaskId;
const originalFinalTask = first.chatStore.getState().tasks[originalTaskId];
expect(originalFinalTask.summaryTask).toBe('Calculator App|Build a comprehensive calculator')
expect(originalFinalTask.taskInfo).toHaveLength(4)
// Verify all subtasks are properly marked as skipped after completion
originalFinalTask.taskRunning.forEach((task: any) => {
expect(task.status).toBe("skipped")
})
originalFinalTask.taskInfo.forEach((task: any) => {
expect(task.status).toBe("skipped")
})
console.log("✓ Complete test sequence verified: confirmed → subtasks → end → new_task_state → remove_task → one task processed, one remains")
})
})

View file

@ -0,0 +1,637 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { generateUniqueId } from '../../../src/lib'
// Import proxy mock to enable API mocking
import '../../mocks/proxy.mock'
// Also Mock authStore & sse
import '../../mocks/authStore.mock'
import '../../mocks/sse.mock'
// Import chat store to ensure it's available
import '../../../src/store/chatStore'
import { useProjectStore } from '../../../src/store/projectStore'
import useChatStoreAdapter from '../../../src/hooks/useChatStoreAdapter'
import { mockFetchEventSource } from '../../mocks/sse.mock'
// Helper function for sequential SSE events
const createSSESequence = (events: Array<{ event: any; delay: number }>) => {
return async (onMessage: (data: any) => void) => {
for (let i = 0; i < events.length; i++) {
const { event, delay } = events[i]
await new Promise<void>((resolve) => {
setTimeout(() => {
console.log(`Sending SSE Event ${i + 1}:`, event.step);
onMessage({
data: JSON.stringify(event)
})
resolve()
}, delay)
})
}
}
}
// Mock electron IPC
(global as any).ipcRenderer = {
invoke: vi.fn((channel) => {
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({})
if (channel === 'get-file-list') return Promise.resolve([])
return Promise.resolve()
}),
}
// Mock window.electronAPI
Object.defineProperty(window, 'electronAPI', {
value: {
uploadLog: vi.fn().mockResolvedValue(undefined),
// Add other electronAPI methods as needed
},
writable: true,
})
describe('Integration Test: Case 2 - same session new chat', () => {
beforeEach(() => {
vi.clearAllMocks();
const { result } = renderHook(() => useProjectStore());
//Reset projectStore
result.current.getAllProjects().forEach(project => {
result.current.removeProject(project.id)
})
//Create initial Project
const projectId = result.current.createProject(
'Complete Journey Test',
'Testing full flow'
)
expect(projectId).toBeDefined()
// 2. Get chatStore (automatically created)
let chatStore = result.current.getActiveChatStore(projectId)!
expect(chatStore).toBeDefined()
const initiatorTaskId = chatStore.getState().activeTaskId!
expect(initiatorTaskId).toBeDefined()
})
afterEach(() => {
vi.clearAllMocks()
})
it("Sequential startTask appends chatStores to same project", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
// Setup the events sequence
const eventSequence = createSSESequence([
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Calculator App|Build a simple calculator',
sub_tasks: [
{ id: 'task-1', content: 'Create UI components', status: '' },
{ id: 'task-2', content: 'Implement calculator logic', status: '' },
],
},
},
delay: 100
},
{
event: {
step: "end",
data: "--- Subtask 1760647257350-6021.1 Result ---\nI'm doing well, thank you for asking! How are you today?"
},
delay: 400
}
])
// Mock SSE stream with controlled events
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
if (options.onmessage) {
await eventSequence(options.onmessage)
}
})
await act(async () => {
// Complete Flow Test
const {chatStore, projectStore} = result.current
const initiatorTaskId = chatStore.activeTaskId;
//User Message to send
const userMessage = 'Build a calculator app';
// 6. Start task
// NOTE: startTask creates a NEW chatStore and switches to it, the old chatStore is no longer active
await chatStore.startTask(initiatorTaskId, undefined, undefined, undefined, userMessage)
//Rerender to get the latest chatStore
rerender()
})
// Test 1: Check initial task creation and first SSE event (to_sub_tasks)
await waitFor(() => {
rerender()
const {chatStore: newChatStore, projectStore} = result.current;
expect(newChatStore).toBeDefined()
let taskId = newChatStore?.activeTaskId!
const task = newChatStore?.tasks[taskId]
expect(taskId).toBeDefined()
if(task) {
expect(task.hasMessages).toBe(true)
expect(task.messages[0].content).toBe('Build a calculator app')
expect(task.status).toBe('pending')
}
const updatedTask = newChatStore?.tasks[taskId]
expect(updatedTask?.summaryTask).toBe('Calculator App|Build a simple calculator')
expect(updatedTask?.taskInfo).toHaveLength(3)
expect(updatedTask?.taskRunning).toHaveLength(3)
//Two chatStores - first initial
expect(projectStore.getAllChatStores(projectStore.activeProjectId)).toHaveLength(2)
}, { timeout: 1000 })
// Test 2: Check progress event has been processed
await waitFor(() => {
rerender()
const {chatStore: newChatStore} = result.current;
let taskId = newChatStore?.activeTaskId!
const task = newChatStore?.tasks[taskId]
// Check if progress message exists in messages or status updates
// Adjust this based on how your app handles progress events
expect(task).toBeDefined()
console.log("Progress test - task status:", task?.status);
}, { timeout: 1500 })
// Test 3: Rerender untill status is "finished"
await waitFor(() => {
rerender()
const {chatStore: newChatStore} = result.current;
let taskId = newChatStore?.activeTaskId!
const task = newChatStore?.tasks[taskId]
if(task) {
// Check if task is completed or has final result
// Adjust these assertions based on your app's behavior
console.log("End test - task status:", task?.status);
console.log("End test - task messages:", task?.messages?.length);
}
expect(task.status).toBe("finished")
console.log(task);
}, { timeout: 2000 });
//Before starting new chatStore
const { chatStore, projectStore } = result.current;
expect(Object.keys(chatStore.tasks)).toHaveLength(1);
//Initial ChatStore + appendedOne
expect(projectStore.getAllChatStores(projectStore.activeProjectId)).toHaveLength(2)
//Make all tasks are skipped after end
chatStore.tasks[chatStore.activeTaskId].taskRunning.forEach((task:any) => {
expect(task.status).toBe("skipped")
})
chatStore.tasks[chatStore.activeTaskId].taskInfo.forEach((task:any) => {
expect(task.status).toBe("skipped")
})
// Test: Start second chat session with different events
await act(async () => {
rerender()
const {chatStore, projectStore} = result.current
const initiatorTaskId = chatStore.activeTaskId;
// Setup different events for second session
const secondEventSequence = createSSESequence([
{
event: {
"step": "confirmed",
"data": {"question": "how are you?"}
},
delay: 100
},
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Todo App|Build a todo application',
sub_tasks: [
{ id: 'task-3', content: 'Design todo interface', status: '' },
{ id: 'task-4', content: 'Implement todo logic', status: '' },
],
},
},
delay: 200
},
{
event: {
step: "end",
data: "--- Second Task Result ---\nTodo app planning completed!"
},
delay: 300
},
{
event: {
step: "end",
data: "--- Subtask 1760647257350-6021.1 Result ---\nI'm doing well, thank you for asking! How are you today?"
},
delay: 400
}
])
// Update the mock for the second call
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
if (options.onmessage) {
await secondEventSequence(options.onmessage)
}
})
const userMessage = 'Build a todo app';
await chatStore.startTask(initiatorTaskId, undefined, undefined, undefined, userMessage)
rerender()
})
// Test the second session results
await waitFor(() => {
rerender()
const {chatStore: newChatStore, projectStore} = result.current;
let taskId = newChatStore?.activeTaskId!
const task = newChatStore?.tasks[taskId]
if(task) {
expect(task.messages[0].content).toBe('Build a todo app')
// Check if the new summary task is set correctly
expect(task.summaryTask).toBe('Todo App|Build a todo application')
}
// Should now have 3 chat stores (initial + 2 task sessions)
expect(projectStore.getAllChatStores(projectStore.activeProjectId)).toHaveLength(3)
}, { timeout: 1500 })
// CHECK POST End State of Run 2
await waitFor(() => {
rerender()
const {chatStore: newChatStore} = result.current;
let taskId = newChatStore?.activeTaskId!
const task = newChatStore?.tasks[taskId]
if(task) {
// Check if task is completed or has final result
// Adjust these assertions based on your app's behavior
console.log("End test 2 - task status:", task?.status);
console.log("End test 2 - task messages:", task?.messages?.length);
}
expect(task.status).toBe("finished")
console.log(task);
}, { timeout: 2000 });
//Before starting new chatStore
const { chatStore:secondChatStore } = result.current;
expect(Object.keys(secondChatStore.tasks)).toHaveLength(1);
//Initial ChatStore + appendedOne
expect(projectStore.getAllChatStores(projectStore.activeProjectId)).toHaveLength(3)
//Make all tasks are skipped after end
secondChatStore.tasks[secondChatStore.activeTaskId].taskRunning.forEach((task:any) => {
expect(task.status).toBe("skipped")
})
secondChatStore.tasks[secondChatStore.activeTaskId].taskInfo.forEach((task:any) => {
expect(task.status).toBe("skipped")
})
})
it("should handle individual SSE events with precise timing", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
let messageCallback: ((data: any) => void) | null = null
// Mock SSE to capture the callback for manual event triggering
mockFetchEventSource.mockImplementation((url: string, options: any) => {
messageCallback = options.onmessage
// Don't send any events automatically
})
// Start the task
await act(async () => {
const {chatStore} = result.current
const initiatorTaskId = chatStore.activeTaskId;
await chatStore.startTask(initiatorTaskId, undefined, undefined, undefined, 'Test manual events')
rerender()
})
// Manually send first event and test result
await act(async () => {
messageCallback?.({
data: JSON.stringify({
step: 'to_sub_tasks',
data: {
summary_task: 'Manual Test|Testing manual event control',
sub_tasks: [{ id: 'manual-1', content: 'Manual task 1', status: '' }],
},
})
})
})
// Test first event result
await waitFor(() => {
rerender()
const {chatStore} = result.current;
let taskId = chatStore?.activeTaskId!
const task = chatStore?.tasks[taskId]
expect(task?.summaryTask).toBe('Manual Test|Testing manual event control')
})
// Send second event and test result
await act(async () => {
messageCallback?.({
data: JSON.stringify({
step: 'progress',
data: 'Manual progress update'
})
})
})
// Test second event result
await waitFor(() => {
rerender()
const {chatStore} = result.current;
let taskId = chatStore?.activeTaskId!
const task = chatStore?.tasks[taskId]
// Add your specific assertions for progress events here
expect(task).toBeDefined()
})
// Send final event
await act(async () => {
messageCallback?.({
data: JSON.stringify({
step: 'end',
data: 'Manual test completed successfully'
})
})
})
// Test final state
await waitFor(() => {
rerender()
const {chatStore} = result.current;
let taskId = chatStore?.activeTaskId!
const task = chatStore?.tasks[taskId]
// Add your specific assertions for end state here
expect(task).toBeDefined()
})
})
//TODO: Don't let new startTask untill newChatStore appended
it("Parallel startTask calls with separate chatStores (startTask -> wait for append -> startTask)", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
let sseCallCount = 0
let firstTaskChatStore: any = null
let secondTaskChatStore: any = null
// Setup SSE events for the first task
const firstTaskEventSequence = createSSESequence([
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'First Task|Build a calculator app',
sub_tasks: [
{ id: 'first-1', content: 'Create calculator UI', status: '' },
{ id: 'first-2', content: 'Implement calc logic', status: '' },
],
},
},
delay: 100
},
{
event: {
step: "end",
data: "--- First Task Result ---\nCalculator app completed successfully!"
},
delay: 300
}
])
// Setup SSE events for the second task
const secondTaskEventSequence = createSSESequence([
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Second Task|Build a todo app',
sub_tasks: [
{ id: 'second-1', content: 'Create todo UI', status: '' },
{ id: 'second-2', content: 'Implement todo logic', status: '' },
],
},
},
delay: 100
},
{
event: {
step: "end",
data: "--- Second Task Result ---\nTodo app completed successfully!"
},
delay: 300
}
])
// Mock SSE to handle sequential calls with different events
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
sseCallCount++
console.log(`SSE Call #${sseCallCount} initiated`)
if (sseCallCount === 1) {
// First task gets first event sequence
console.log('Processing first task events')
if (options.onmessage) {
await firstTaskEventSequence(options.onmessage)
}
} else if (sseCallCount === 2) {
// Second task gets second event sequence
console.log('Processing second task events')
if (options.onmessage) {
await secondTaskEventSequence(options.onmessage)
}
}
})
// Get initial chatStore reference
const { chatStore: initialChatStore, projectStore } = result.current
const initialChatStoreRef = initialChatStore
const initiatorTaskId = initialChatStore.activeTaskId
// Step 1: Start first task
await act(async () => {
const userMessage1 = 'Build a calculator app'
await initialChatStore.startTask(initiatorTaskId, undefined, undefined, undefined, userMessage1)
rerender()
})
// Verify first task started and chatStore was created
await waitFor(() => {
rerender()
const { chatStore: currentChatStore, projectStore } = result.current
// Should have 2 chatStores: initial + first task
const allChatStores = projectStore.getAllChatStores(projectStore.activeProjectId)
expect(allChatStores).toHaveLength(2)
// Current chatStore should be different from initial (new one created)
expect(currentChatStore).not.toBe(initialChatStoreRef)
firstTaskChatStore = currentChatStore
// Verify first task details
const activeTaskId = currentChatStore.activeTaskId
const activeTask = currentChatStore.tasks[activeTaskId]
expect(activeTask).toBeDefined()
expect(activeTask.hasMessages).toBe(true)
expect(activeTask.messages[0].content).toBe('Build a calculator app')
}, { timeout: 1000 })
// Wait for first task SSE events to process (summary_task)
await waitFor(() => {
rerender()
const { chatStore: currentChatStore } = result.current
const activeTaskId = currentChatStore.activeTaskId
const activeTask = currentChatStore.tasks[activeTaskId]
// Check that SSE events have been processed
expect(activeTask.summaryTask).toBe('First Task|Build a calculator app')
expect(activeTask.taskInfo).toHaveLength(3) // main task + 2 subtasks
expect(activeTask.taskRunning).toHaveLength(3)
console.log("First task SSE events processed")
}, { timeout: 1500 })
// Wait for first task to complete
await waitFor(() => {
rerender()
const { chatStore: currentChatStore } = result.current
const activeTaskId = currentChatStore.activeTaskId
const activeTask = currentChatStore.tasks[activeTaskId]
expect(activeTask.status).toBe("finished")
console.log("First task completed")
}, { timeout: 2000 })
// Step 2: Wait for project append to complete before starting second task
await waitFor(() => {
rerender()
const { projectStore } = result.current
// Ensure the project has been properly updated with the appended first chatStore
const allChatStores = projectStore.getAllChatStores(projectStore.activeProjectId)
expect(allChatStores).toHaveLength(2)
// Verify the active chatStore is properly set and ready for next task
const activeChatStore = projectStore.getActiveChatStore(projectStore.activeProjectId)
expect(activeChatStore).toBeDefined()
expect(activeChatStore.getState()?.activeTaskId).toBeDefined()
console.log("Project append completed, ready for second task")
}, { timeout: 1000 })
// Step 3: Start second task on the same chatStore
await act(async () => {
rerender()
const { chatStore: currentChatStore } = result.current
const currentInitiatorTaskId = currentChatStore.activeTaskId
const userMessage2 = 'Build a todo app'
await currentChatStore.startTask(currentInitiatorTaskId, undefined, undefined, undefined, userMessage2)
rerender()
})
// Verify second task started and new chatStore was created
await waitFor(() => {
rerender()
const { chatStore: currentChatStore, projectStore } = result.current
// Should now have 3 chatStores: initial + first task + second task
const allChatStores = projectStore.getAllChatStores(projectStore.activeProjectId)
expect(allChatStores).toHaveLength(3)
// Current chatStore should be different from first task chatStore
expect(currentChatStore).not.toBe(firstTaskChatStore)
secondTaskChatStore = currentChatStore
// Verify second task details
const activeTaskId = currentChatStore.activeTaskId
const activeTask = currentChatStore.tasks[activeTaskId]
expect(activeTask).toBeDefined()
expect(activeTask.hasMessages).toBe(true)
expect(activeTask.messages[0].content).toBe('Build a todo app')
}, { timeout: 1000 })
// Wait for second task SSE events to process (summary_task)
await waitFor(() => {
rerender()
const { chatStore: currentChatStore } = result.current
const activeTaskId = currentChatStore.activeTaskId
const activeTask = currentChatStore.tasks[activeTaskId]
// Check that SSE events have been processed
expect(activeTask.summaryTask).toBe('Second Task|Build a todo app')
expect(activeTask.taskInfo).toHaveLength(3) // main task + 2 subtasks
expect(activeTask.taskRunning).toHaveLength(3)
console.log("Second task SSE events processed")
}, { timeout: 1500 })
// Wait for second task to complete
await waitFor(() => {
rerender()
const { chatStore: currentChatStore } = result.current
const activeTaskId = currentChatStore.activeTaskId
const activeTask = currentChatStore.tasks[activeTaskId]
expect(activeTask.status).toBe("finished")
console.log("Second task completed")
}, { timeout: 2000 })
// Final verification: Both chatStores should have separate states
const { projectStore: finalProjectStore } = result.current
const allFinalChatStores = finalProjectStore.getAllChatStores(finalProjectStore.activeProjectId)
// Verify we have 3 separate chatStores with their own states
expect(allFinalChatStores).toHaveLength(3)
expect(sseCallCount).toBe(2) // Two separate SSE calls
// Verify each chatStore has its own task with different content
expect(firstTaskChatStore).not.toBe(secondTaskChatStore)
// Get the current state of chatStores from the project store to verify final states
const [initialChatStoreFromProject, firstChatStoreFromProject, secondChatStoreFromProject] = allFinalChatStores
// Verify first chatStore state (should be the second in the array after initial)
const firstTaskId = firstChatStoreFromProject.chatStore.getState().activeTaskId
const firstTask = firstChatStoreFromProject.chatStore.getState().tasks[firstTaskId]
expect(firstTask.messages[0].content).toBe('Build a calculator app')
expect(firstTask.summaryTask).toBe('First Task|Build a calculator app')
// Verify second chatStore state (should be the third in the array)
const secondTaskId = secondChatStoreFromProject.chatStore.getState().activeTaskId
const secondTask = secondChatStoreFromProject.chatStore.getState().tasks[secondTaskId]
expect(secondTask.messages[0].content).toBe('Build a todo app')
expect(secondTask.summaryTask).toBe('Second Task|Build a todo app')
console.log('Sequential startTask test with separate chatStores completed successfully')
})
})

View file

@ -0,0 +1,372 @@
/**
* Integration Test: Case 1 - New Project
*
* Tests the complete flow of creating a new project and sending the first message.
*
* Flow:
* 1. User creates a new project with initial message
* 2. System automatically creates initial chatStore
* 3. Task starts executing
*
* This is the most common user journey and serves as the foundation for all other cases.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { generateUniqueId } from '../../../src/lib'
// Import proxy mock to enable API mocking
import '../../mocks/proxy.mock'
// Also Mock authStore & sse
import '../../mocks/authStore.mock'
import '../../mocks/sse.mock'
// Import chat store to ensure it's available
import '../../../src/store/chatStore'
import { useProjectStore } from '../../../src/store/projectStore'
import { mockFetchEventSource } from '../../mocks/sse.mock'
// Mock electron IPC
(global as any).ipcRenderer = {
invoke: vi.fn((channel) => {
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({})
if (channel === 'get-file-list') return Promise.resolve([])
return Promise.resolve()
}),
}
describe('Integration Test: Case 1 - New Project', () => {
beforeEach(() => {
vi.clearAllMocks();
const { result } = renderHook(() => useProjectStore());
//Reset projectStore
result.current.getAllProjects().forEach(project => {
result.current.removeProject(project.id)
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('should create project with initial chatStore and task', async () => {
const { result, rerender } = renderHook(() => useProjectStore())
await act(async () => {
// Step 1: Create new project
const projectId = result.current.createProject(
'My First Project',
'A test project'
)
// Force a re-render to ensure state is updated
rerender()
// Debug: Log the store state immediately after creation
console.log('Created projectId:', projectId)
// Try to get project before asserting
const debugProject = result.current.getProjectById(projectId)
console.log('Retrieved project:', debugProject)
// Verify project created
expect(projectId).toBeDefined()
// First check: activeProjectId should be set
expect(debugProject?.id).toBe(projectId)
const project = result.current.getProjectById(projectId)
console.log('Retrieved project again:', project)
expect(project).toBeDefined()
expect(project?.name).toBe('My First Project')
expect(project?.description).toBe('A test project')
})
})
it('should automatically create initial chatStore in new project', () => {
const { result } = renderHook(() => useProjectStore())
act(() => {
const projectId = result.current.createProject('Test Project')
// Step 2: Verify chatStore created automatically
const chatStore = result.current.getActiveChatStore(projectId)
expect(chatStore).toBeDefined()
// Verify initial task exists
const chatState = chatStore!.getState()
expect(chatState.activeTaskId).toBeDefined()
expect(chatState.tasks[chatState.activeTaskId!]).toBeDefined()
})
})
it('should have correct initial task state', () => {
const { result } = renderHook(() => useProjectStore())
act(() => {
const projectId = result.current.createProject('Test Project')
const chatStore = result.current.getActiveChatStore(projectId)!
const chatState = chatStore.getState()
const taskId = chatState.activeTaskId!
const task = chatState.tasks[taskId]
// Verify task initial state
expect(task.status).toBe('pending')
expect(task.messages).toEqual([])
expect(task.tokens).toBe(0)
expect(task.isPending).toBe(false)
expect(task.hasMessages).toBe(false)
})
})
it('should add user message to task', () => {
const { result } = renderHook(() => useProjectStore())
act(() => {
const projectId = result.current.createProject('Test Project')
const chatStore = result.current.getActiveChatStore(projectId)!
const taskId = chatStore.getState().activeTaskId!
// Step 3: User sends message
const userMessage = {
id: generateUniqueId(),
role: 'user' as const,
content: 'Create a todo app with React',
attaches: [],
}
chatStore.getState().addMessages(taskId, userMessage)
chatStore.getState().setHasMessages(taskId, true)
// Verify message added
const task = chatStore.getState().tasks[taskId]
expect(task.messages).toHaveLength(1)
expect(task.messages[0].content).toBe('Create a todo app with React')
expect(task.hasMessages).toBe(true)
})
})
it('should create historyId after starting task', async () => {
const { result } = renderHook(() => useProjectStore())
await act(async () => {
const projectId = result.current.createProject('Test Project')
const chatStore = result.current.getActiveChatStore(projectId)!
const taskId = chatStore.getState().activeTaskId!
// Add message
chatStore.getState().addMessages(taskId, {
id: generateUniqueId(),
role: 'user',
content: 'Test message',
})
// Mock SSE to immediately close (simulating startTask)
mockFetchEventSource.mockImplementation((url: string, options: any) => {
// Call onopen
if (options.onopen) {
options.onopen({ ok: true, status: 200 })
}
return Promise.resolve()
})
// Step 4: Start task
await chatStore.getState().startTask(taskId)
// Wait for historyId to be set
await waitFor(() => {
const historyId = result.current.getHistoryId(projectId)
expect(historyId).toBeDefined()
expect(historyId).toMatch(/^history-/)
}, { timeout: 2000 })
})
})
it('should handle complete user journey from project creation to task start', async () => {
const { result } = renderHook(() => useProjectStore())
await act(async () => {
// Complete Flow Test
// 1. Create project
const projectId = result.current.createProject(
'Complete Journey Test',
'Testing full flow'
)
expect(projectId).toBeDefined()
// 2. Get chatStore (automatically created)
const chatStore = result.current.getActiveChatStore(projectId)!
expect(chatStore).toBeDefined()
const initiatorTaskId = chatStore.getState().activeTaskId!
expect(initiatorTaskId).toBeDefined()
// 3. Set user message
const userMessage = 'Build a calculator app';
// 4. Verify task ready to start
const initialTask = chatStore.getState().tasks[initiatorTaskId]
// 5. Mock SSE stream with to_sub_tasks event
mockFetchEventSource.mockImplementation((url: string, options: any) => {
setTimeout(() => {
console.log("Sending to_sub_tasks SSE Event");
// Simulate to_sub_tasks event
if (options.onmessage) {
options.onmessage({
data: JSON.stringify({
step: 'to_sub_tasks',
data: {
summary_task: 'Calculator App|Build a simple calculator',
sub_tasks: [
{ id: 'task-1', content: 'Create UI components', status: '' },
{ id: 'task-2', content: 'Implement calculator logic', status: '' },
],
},
}),
})
}
}, 200)
})
// 6. Start task
// NOTE: startTask creates a NEW chatStore and switches to it, the old chatStore is no longer active
await chatStore.getState().startTask(initiatorTaskId, undefined, undefined, undefined, userMessage)
// IMPORTANT: Get the NEW active chatStore after startTask creates it
const newChatStore = result.current.getActiveChatStore()
expect(newChatStore).toBeDefined()
expect(newChatStore).not.toBe(chatStore) // Should be a different instance
let taskId = newChatStore?.getState().activeTaskId!
const task = newChatStore?.getState().tasks[taskId]
expect(taskId).toBeDefined()
if(task) {
expect(task.hasMessages).toBe(true)
expect(task.messages[0].content).toBe('Build a calculator app')
expect(task.status).toBe('pending')
}
// 7. Wait for task breakdown
await waitFor(() => {
const updatedTask = newChatStore?.getState().tasks[taskId]
expect(updatedTask?.summaryTask).toBe('Calculator App|Build a simple calculator')
//Bcz of newTaskInfo { id: '', content: '', status: '' } we have 3 items
expect(updatedTask?.taskInfo).toHaveLength(3)
expect(updatedTask?.taskRunning).toHaveLength(3)
}, { timeout: 2000 })
})
})
it('should not create new project if empty project exists (optimization)', () => {
const { result } = renderHook(() => useProjectStore())
act(() => {
// Create first empty project
const projectId1 = result.current.createProject('First Project')
// Before adding any messages, create another project
// Should reuse the empty project
const projectId2 = result.current.createProject('Second Project')
// Should reuse the same project ID
expect(projectId2).toBe(projectId1)
// Project should be updated with new name
const project = result.current.getProjectById(projectId2)
expect(project?.name).toBe('Second Project')
})
})
it('should create new project if existing project has messages', () => {
const { result } = renderHook(() => useProjectStore())
act(() => {
// Create first project
const projectId1 = result.current.createProject('First Project')
// Add a message (making it non-empty)
const chatStore = result.current.getActiveChatStore(projectId1)!
const taskId = chatStore.getState().activeTaskId!
chatStore.getState().addMessages(taskId, {
id: generateUniqueId(),
role: 'user',
content: 'Test message',
})
// Now create second project
const projectId2 = result.current.createProject('Second Project')
// Should create new project
expect(projectId2).not.toBe(projectId1)
expect(result.current.getAllProjects()).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('should handle project creation with minimal data', async () => {
const { result } = renderHook(() => useProjectStore())
await act(async () => {
const projectId = result.current.createProject('Minimal Project')
// Wait a tick to ensure all state updates are complete
await new Promise(resolve => setTimeout(resolve, 0))
const project = result.current.getProjectById(projectId)
expect(project?.name).toBe('Minimal Project')
expect(project?.description).toBeUndefined()
})
})
it('should handle empty message gracefully', async () => {
const { result } = renderHook(() => useProjectStore())
await act(async () => {
const projectId = result.current.createProject('Test Project')
// Wait a tick for project creation to complete
await new Promise(resolve => setTimeout(resolve, 0))
const chatStore = result.current.getActiveChatStore(projectId)!
const taskId = chatStore.getState().activeTaskId!
// Add empty message
chatStore.getState().addMessages(taskId, {
id: generateUniqueId(),
role: 'user',
content: '',
})
// Wait for message update to complete
await new Promise(resolve => setTimeout(resolve, 0))
const task = chatStore.getState().tasks[taskId]
expect(task.messages).toHaveLength(1)
expect(task.messages[0].content).toBe('')
})
})
it('should handle rapid project creation', async () => {
const { result } = renderHook(() => useProjectStore())
await act(async () => {
const projectIds = []
for (let i = 0; i < 5; i++) {
projectIds.push(result.current.createProject(`Project ${i}`))
// Add small delay between each creation to ensure proper state updates
await new Promise(resolve => setTimeout(resolve, 0))
}
// Only first should be created, rest reuse until messages added
expect(new Set(projectIds).size).toBeLessThanOrEqual(1)
})
})
})
})

View file

@ -0,0 +1,526 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { generateUniqueId } from '../../../src/lib'
// Import proxy mock to enable API mocking
import '../../mocks/proxy.mock'
// Also Mock authStore & sse
import '../../mocks/authStore.mock'
import '../../mocks/sse.mock'
// Import chat store to ensure it's available
import '../../../src/store/chatStore'
import { useProjectStore } from '../../../src/store/projectStore'
import useChatStoreAdapter from '../../../src/hooks/useChatStoreAdapter'
import { mockFetchEventSource } from '../../mocks/sse.mock'
import { replayProject } from '../../../src/lib'
// Helper function for sequential SSE events
const createSSESequence = (events: Array<{ event: any; delay: number }>) => {
return async (onMessage: (data: any) => void) => {
for (let i = 0; i < events.length; i++) {
const { event, delay } = events[i]
await new Promise<void>((resolve) => {
setTimeout(() => {
console.log(`Sending SSE Event ${i + 1}:`, event.step);
onMessage({
data: JSON.stringify(event)
})
resolve()
}, delay)
})
}
}
}
// Mock navigate function
const mockNavigate = vi.fn() as any
// Mock electron IPC
(global as any).ipcRenderer = {
invoke: vi.fn((channel) => {
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({})
if (channel === 'get-file-list') return Promise.resolve([])
return Promise.resolve()
}),
}
// Mock window.electronAPI
Object.defineProperty(window, 'electronAPI', {
value: {
uploadLog: vi.fn().mockResolvedValue(undefined),
// Add other electronAPI methods as needed
},
writable: true,
})
describe('Integration Test: Replay Functionality', () => {
let initialProjectId: string
let initialTaskId: string
let projectStoreResult: any
beforeEach(() => {
vi.clearAllMocks();
mockNavigate.mockClear();
projectStoreResult = renderHook(() => useProjectStore());
//Reset projectStore
projectStoreResult.result.current.getAllProjects().forEach((project: any) => {
projectStoreResult.result.current.removeProject(project.id)
})
//Create initial Project for testing
initialProjectId = projectStoreResult.result.current.createProject(
'Original Project',
'Testing replay functionality'
)
expect(initialProjectId).toBeDefined()
// Get chatStore (automatically created)
const chatStore = projectStoreResult.result.current.getActiveChatStore(initialProjectId)!
expect(chatStore).toBeDefined()
initialTaskId = chatStore.getState().activeTaskId!
expect(initialTaskId).toBeDefined()
})
afterEach(() => {
vi.clearAllMocks()
})
it("should create replay project with correct taskId == projectId", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
// Setup replay events sequence
const replayEventSequence = createSSESequence([
{
event: {
step: 'confirmed',
data: { question: 'Build a calculator app' }
},
delay: 100
},
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Calculator App|Build a simple calculator',
sub_tasks: [
{ id: 'replay-task-1', content: 'Create UI components', status: '' },
{ id: 'replay-task-2', content: 'Implement calculator logic', status: '' },
],
},
},
delay: 200
},
{
event: {
step: "end",
data: "--- Replay Task Result ---\nCalculator app replay completed!"
},
delay: 300
}
])
// Mock SSE for replay
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
console.log('SSE URL called:', url)
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
await replayEventSequence(options.onmessage)
}
})
// Run replayProject
await act(async () => {
await replayProject(
result.current.projectStore,
mockNavigate,
generateUniqueId(), //Gotten from api
'Build a calculator app',
'test-history-id'
)
})
// Verify replay project was created
await waitFor(() => {
rerender()
const {projectStore} = result.current;
const projects = projectStore.getAllProjects()
// Should have original project + replay project
expect(projects).toHaveLength(2)
// Find the replay project
const replayProject = projects.find((p:any) => p.name.includes('Replay Project'))
expect(replayProject).toBeDefined()
expect(replayProject?.name).toBe('Replay Project Build a calculator app')
// Test critical requirement: taskId should equal projectId for replay
const replayChatStores = projectStore.getAllChatStores(replayProject!.id)
//Initial one is empty one - TODO: Reuse the empty one (even if projectid isgiven)
expect(replayChatStores).toHaveLength(2)
const replayChatStore = replayChatStores[1].chatStore
const replayTaskId = replayChatStore.getState().activeTaskId
// The main test: taskId should equal the projectId passed to replayProject
// In this case we passed generateUniqueId() as the projectId
expect(replayTaskId).toBeDefined()
expect(replayTaskId).not.toBe(initialProjectId) // Should be different from initial project
// Verify the replay task has correct properties
const replayTask = replayChatStore.getState().tasks[replayTaskId]
expect(replayTask).toBeDefined()
expect(replayTask.type).toBe('replay')
expect(replayTask.messages[0].content).toBe('Build a calculator app')
console.log('Replay Project ID:', replayProject!.id)
console.log('Replay Task ID:', replayTaskId)
console.log('Original Project ID:', initialProjectId)
}, { timeout: 2000 })
// Verify navigation was called
expect(mockNavigate).toHaveBeenCalledWith({ pathname: "/" })
})
it("should not append chatStore during replay (appendingChatStore logic)", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const projectStoreResult = renderHook(() => useProjectStore())
// Setup replay events with multiple steps to test appendingChatStore logic
const replayEventSequence = createSSESequence([
{
event: {
step: 'confirmed',
data: { question: 'Build a todo app' }
},
delay: 100
},
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Todo App|Build a todo application',
sub_tasks: [
{ id: 'todo-1', content: 'Design interface', status: '' },
{ id: 'todo-2', content: 'Implement logic', status: '' },
],
},
},
delay: 200
},
{
event: {
step: 'progress',
data: 'Processing todo app requirements...'
},
delay: 300
},
{
event: {
step: "end",
data: "--- Todo App Replay Result ---\nTodo app replay finished!"
},
delay: 400
}
])
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
await replayEventSequence(options.onmessage)
}
})
// Get initial project count
const initialProjectCount = projectStoreResult.result.current.getAllProjects().length
// Run replay
await act(async () => {
await replayProject(
projectStoreResult.result.current,
mockNavigate,
generateUniqueId(),
'Build a todo app',
'test-history-id-2'
)
})
// Wait for replay to complete
await waitFor(() => {
rerender()
const { projectStore } = result.current
const projects = projectStore.getAllProjects()
// We should have original project + replay project (so +1)
expect(projects).toHaveLength(initialProjectCount + 1)
const replayProject = projects.find((p: any) => p.name.includes('Replay Project Build a todo app'))
expect(replayProject).toBeDefined()
// Critical test: Should have exactly ONE chatStore in replay project
// This tests that appendingChatStore logic prevented additional chatStores
const replayChatStores = projectStore.getAllChatStores(replayProject!.id)
expect(replayChatStores).toHaveLength(2)
// Verify the single chatStore has the replay task
const replayChatStore = replayChatStores[1].chatStore
const activeTaskId = replayChatStore.getState().activeTaskId
const task = activeTaskId ? replayChatStore.getState().tasks[activeTaskId] : null
expect(task).toBeDefined()
expect(task?.summaryTask).toBe('Todo App|Build a todo application')
console.log('Replay ChatStore count:', replayChatStores.length)
console.log('Should be exactly 1 (no appending during replay)')
}, { timeout: 3000 })
})
it("should handle startTask on same project after replay completes", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const projectStoreResult = renderHook(() => useProjectStore())
// Step 1: Complete a replay first
const replayEventSequence = createSSESequence([
{
event: {
step: 'confirmed',
data: { question: 'Initial replay task' }
},
delay: 100
},
{
event: {
step: "end",
data: "--- Initial Replay Completed ---"
},
delay: 200
}
])
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
await replayEventSequence(options.onmessage)
}
})
// Run initial replay
await act(async () => {
await replayProject(
projectStoreResult.result.current,
mockNavigate,
generateUniqueId(),
'Initial replay task',
'replay-history-id'
)
})
// Wait for replay to complete
await waitFor(() => {
const projects = projectStoreResult.result.current.getAllProjects()
const replayProj = projects.find(p => p.name.includes('Replay Project'))
expect(replayProj).toBeDefined()
}, { timeout: 2000 })
// Step 2: Setup new SSE events for post-replay startTask
const postReplayEventSequence = createSSESequence([
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Post Replay Task|New task after replay',
sub_tasks: [
{ id: 'post-1', content: 'New task component', status: '' },
],
},
},
delay: 100
},
{
event: {
step: "end",
data: "--- Post Replay Task Completed ---"
},
delay: 200
}
])
// Update mock for post-replay events
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
if (!url.includes('/api/chat/steps/playback/') && options.onmessage) {
await postReplayEventSequence(options.onmessage)
}
})
// Step 3: Call startTask on replay project after replay completes
await act(async () => {
rerender()
const { chatStore } = result.current
// Should be connected to the replay project now
expect(chatStore).toBeDefined()
const currentTaskId = chatStore.activeTaskId
expect(currentTaskId).toBeDefined()
// Start a new task on the replay project
await chatStore.startTask(
currentTaskId,
undefined,
undefined,
undefined,
'New task after replay completion'
)
rerender()
})
// Step 4: Verify new chatStore was created for post-replay task
await waitFor(() => {
rerender()
const { chatStore: newChatStore, projectStore } = result.current
// Should have a new chatStore for the post-replay task
expect(newChatStore).toBeDefined()
const activeTaskId = newChatStore.activeTaskId
const activeTask = newChatStore.tasks[activeTaskId]
expect(activeTask).toBeDefined()
expect(activeTask.messages[0].content).toBe('New task after replay completion')
expect(activeTask.summaryTask).toBe('Post Replay Task|New task after replay')
// Verify we now have 2 chatStores in the replay project (replay + post-replay task)
const allChatStores = projectStore.getAllChatStores(projectStore.activeProjectId)
// Expected: on createProject + original replay chatStore + new post-replay chatStore = 3
expect(allChatStores).toHaveLength(3)
console.log('Post-replay chatStore count:', allChatStores.length)
console.log('Successfully created new chatStore after replay')
}, { timeout: 2000 })
})
it("should handle parallel startTask during replay (separate chatStores)", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const projectStoreResult = renderHook(() => useProjectStore())
// Setup automatic SSE for both replay and parallel tasks
const replayEventSequence = createSSESequence([
{
event: {
step: 'confirmed',
data: { question: 'Long running replay task' }
},
delay: 100
},
{
event: {
step: "end",
data: "--- Replay Task Completed ---"
},
delay: 500 // Longer delay to allow parallel task to start
}
])
const parallelEventSequence = createSSESequence([
{
event: {
step: 'to_sub_tasks',
data: {
summary_task: 'Parallel Task|Running alongside replay',
sub_tasks: [
{ id: 'parallel-1', content: 'Parallel component', status: '' },
],
},
},
delay: 100
},
{
event: {
step: "end",
data: "--- Parallel Task Completed ---"
},
delay: 200
}
])
// Mock SSE to handle both replay and parallel tasks automatically
mockFetchEventSource.mockImplementation(async (url: string, options: any) => {
console.log('Mock SSE called with URL:', url)
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
// This is replay SSE
console.log('Processing replay events')
await replayEventSequence(options.onmessage)
} else if (options.onmessage) {
// This is parallel startTask SSE
console.log('Processing parallel task events')
await parallelEventSequence(options.onmessage)
}
})
// Step 1: Start replay
await act(async () => {
await replayProject(
projectStoreResult.result.current,
mockNavigate,
generateUniqueId(),
'Long running replay task',
'long-replay-history'
)
})
// Verify replay started
await waitFor(() => {
const projects = projectStoreResult.result.current.getAllProjects()
const replayProj = projects.find(p => p.name.includes('Replay Project'))
expect(replayProj).toBeDefined()
}, { timeout: 1000 })
// Step 2: While replay is running, start parallel task on same project
await act(async () => {
rerender()
const { chatStore } = result.current
expect(chatStore).toBeDefined()
const currentTaskId = chatStore.activeTaskId
// Start parallel task
await chatStore.startTask(
currentTaskId,
undefined,
undefined,
undefined,
'Parallel task during replay'
)
rerender()
})
// Step 3: Verify both tasks completed independently
await waitFor(() => {
rerender()
const { projectStore } = result.current
const allChatStores = projectStore.getAllChatStores(projectStore.activeProjectId)
// Should have exactly 2 chatStores: onCreate + replay + parallel
expect(allChatStores).toHaveLength(3)
// Get both chatStores and verify they have different content
const chatStore1 = allChatStores[1].chatStore
const chatStore2 = allChatStores[2].chatStore
const task1 = chatStore1.getState().tasks[chatStore1.getState().activeTaskId]
const task2 = chatStore2.getState().tasks[chatStore2.getState().activeTaskId]
expect(task1).toBeDefined()
expect(task2).toBeDefined()
// Verify they have different messages
const contents = [task1.messages[0].content, task2.messages[0].content]
expect(contents).toContain('Long running replay task')
expect(contents).toContain('Parallel task during replay')
console.log('Parallel startTask during replay test completed successfully')
console.log('Both tasks ran independently with separate chatStores')
}, { timeout: 3000 })
})
})

View file

@ -0,0 +1,479 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { render, screen, waitFor, act, renderHook } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import ChatBox from '../../../src/components/ChatBox/index'
import { generateUniqueId } from '../../../src/lib'
// Import proxy mock to enable API mocking
import '../../mocks/proxy.mock'
// Also Mock authStore & sse
import '../../mocks/authStore.mock'
import '../../mocks/sse.mock'
// Import chat store to ensure it's available
import '../../../src/store/chatStore'
import { useProjectStore } from '../../../src/store/projectStore'
import useChatStoreAdapter from '../../../src/hooks/useChatStoreAdapter'
import { mockFetchEventSource } from '../../mocks/sse.mock'
// Mock electron IPC
(global as any).ipcRenderer = {
invoke: vi.fn((channel) => {
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({})
if (channel === 'get-file-list') return Promise.resolve([])
return Promise.resolve()
}),
}
// Mock window.electronAPI
Object.defineProperty(window, 'electronAPI', {
value: {
uploadLog: vi.fn().mockResolvedValue(undefined),
// Add other electronAPI methods as needed
},
writable: true,
})
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
)
describe('ChatBox Integration Tests - Different ChatStore Configurations', () => {
beforeEach(() => {
vi.clearAllMocks();
const { result } = renderHook(() => useProjectStore());
//Reset projectStore
result.current.getAllProjects().forEach(project => {
result.current.removeProject(project.id)
})
//Create initial Project
const projectId = result.current.createProject(
'ChatBox Test Project',
'Testing ChatBox UI functionality'
)
expect(projectId).toBeDefined()
// Get chatStore (automatically created)
let chatStore = result.current.getActiveChatStore(projectId)!
expect(chatStore).toBeDefined()
const initiatorTaskId = chatStore.getState().activeTaskId!
expect(initiatorTaskId).toBeDefined()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Task States and UI Rendering', () => {
it('should render welcome screen when no messages exist', () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
expect(screen.getByText(/layout.welcome-to-eigent/i)).toBeInTheDocument()
expect(screen.getByText(/layout.how-can-i-help-you/i)).toBeInTheDocument()
})
it('should render task splitting UI when task is in to_sub_tasks state', async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore, projectStore } = result.current
const projectId = projectStore.activeProjectId as string
const taskId = chatStore?.activeTaskId
if (!chatStore || !taskId) {
throw new Error('ChatStore or taskId is null')
}
// Simulate the state after receiving to_sub_tasks SSE event
await act(async () => {
chatStore.setHasMessages(taskId, true)
chatStore.addMessages(taskId, {
id: 'user-msg-1',
role: 'user',
content: 'Build a calculator app',
attaches: []
})
chatStore.addMessages(taskId, {
id: 'assistant-msg-1',
role: 'assistant',
content: '',
step: 'to_sub_tasks',
data: {
summary_task: 'Calculator App|Build a simple calculator app',
sub_tasks: [
{ id: 'task-1', content: 'Create UI components', status: '' },
{ id: 'task-2', content: 'Implement calculator logic', status: '' }
]
}
})
rerender()
})
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
// Should show the task card/splitting interface
await waitFor(() => {
// Look for elements that indicate task splitting UI - there might be multiple instances
const calculatorElements = screen.getAllByText('Build a calculator app')
expect(calculatorElements.length).toBeGreaterThanOrEqual(1)
// The component should show task breakdown
expect(screen.queryByText(/layout.welcome-to-eigent/i)).not.toBeInTheDocument()
})
})
it('should render active conversation with messages', async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore } = result.current
const taskId = chatStore?.activeTaskId
if (!chatStore || !taskId) {
throw new Error('ChatStore or taskId is null')
}
await act(async () => {
chatStore.setHasMessages(taskId, true)
chatStore.addMessages(taskId, {
id: 'user-1',
role: 'user',
content: 'Hello, how are you?',
attaches: []
})
chatStore.addMessages(taskId, {
id: 'assistant-1',
role: 'assistant',
content: 'I am doing well, thank you! layout.how-can-i-help-you?',
attaches: []
})
rerender()
})
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
await waitFor(() => {
expect(screen.getByText('Hello, how are you?')).toBeInTheDocument()
expect(screen.getByText('I am doing well, thank you! layout.how-can-i-help-you?')).toBeInTheDocument()
expect(screen.queryByText(/layout.welcome-to-eigent/i)).not.toBeInTheDocument()
})
})
it('should show loading state when task is pending', async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore } = result.current
const taskId = chatStore?.activeTaskId
if (!chatStore || !taskId) {
throw new Error('ChatStore or taskId is null')
}
await act(async () => {
chatStore.setHasMessages(taskId, true)
chatStore.addMessages(taskId, {
id: 'user-1',
role: 'user',
content: 'Calculate 2+2',
attaches: []
})
chatStore.setIsPending(taskId, true)
rerender()
})
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
await waitFor(() => {
expect(screen.getByText('Calculate 2+2')).toBeInTheDocument()
// Should show some loading indicator - adjust this based on actual UI
// For now, just check that we don't show the welcome screen
expect(screen.queryByText(/layout.welcome-to-eigent/i)).not.toBeInTheDocument()
})
})
it.skip('should render file attachments when message has fileList', async () => {
// This test requires understanding the exact structure of fileList handling
// Skipping for now until we understand the component's file attachment UI
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore } = result.current
const taskId = chatStore?.activeTaskId
if (!chatStore || !taskId) {
throw new Error('ChatStore or taskId is null')
}
await act(async () => {
chatStore.setHasMessages(taskId, true)
chatStore.addMessages(taskId, {
id: 'user-1',
role: 'user',
content: 'Generate a report',
attaches: []
})
chatStore.addMessages(taskId, {
id: 'assistant-1',
role: 'assistant',
content: 'I have generated the report for you.',
step: 'end',
fileList: [
{
name: 'report.pdf',
type: 'PDF',
path: '/tmp/report.pdf'
},
{
name: 'data.csv',
type: 'CSV',
path: '/tmp/data.csv'
}
]
})
rerender()
})
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
await waitFor(() => {
expect(screen.getByText('Generate a report')).toBeInTheDocument()
expect(screen.getByText('I have generated the report for you.')).toBeInTheDocument()
// These might be rendered differently in the actual component
// expect(screen.getByText(/report\.pdf/i)).toBeInTheDocument()
// expect(screen.getByText(/data\.csv/i)).toBeInTheDocument()
})
})
it.skip('should handle activeAsk state (waiting for human reply)', async () => {
// This test requires understanding the exact input field structure
// Skipping for now until we understand the component's input handling
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore } = result.current
const taskId = chatStore?.activeTaskId
if (!chatStore || !taskId) {
throw new Error('ChatStore or taskId is null')
}
await act(async () => {
chatStore.setHasMessages(taskId, true)
chatStore.addMessages(taskId, {
id: 'user-1',
role: 'user',
content: 'Help me decide between options',
attaches: []
})
chatStore.addMessages(taskId, {
id: 'assistant-1',
role: 'assistant',
content: 'Which option would you prefer: A or B?',
agent_name: 'decision-agent'
})
chatStore.setActiveAsk(taskId, 'decision-agent')
rerender()
})
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
await waitFor(() => {
expect(screen.getByText('Help me decide between options')).toBeInTheDocument()
expect(screen.getByText('Which option would you prefer: A or B?')).toBeInTheDocument()
})
})
})
describe('Multi-ChatStore Project UI', () => {
it.skip('should display multiple chat sessions in project view', async () => {
// This test requires understanding the startTask method and multi-store handling
// Skipping for now - needs investigation into the actual startTask implementation
})
it.skip('should handle queued messages UI when task is busy', async () => {
// This test requires understanding the queueing mechanism in the UI
// Skipping for now - needs investigation into the actual queue handling
})
})
describe('Error States and Edge Cases', () => {
it('should handle corrupted chatStore state gracefully', async () => {
// Test that the component doesn't crash when chatStore is in an invalid state
// This is more of a safety test to ensure the component has proper error boundaries
// Should not crash when rendering with potentially corrupted state
expect(() => {
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
}).not.toThrow()
// Should show some content (either welcome screen or handle the error gracefully)
expect(screen.getByText(/layout.welcome-to-eigent/i) || screen.getByText(/error/i) || screen.getByRole('main')).toBeTruthy()
})
it('should handle missing activeTaskId gracefully', async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore } = result.current
if (!chatStore) {
// If chatStore is null, that's fine for this test
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
expect(screen.getByText(/layout.welcome-to-eigent/i)).toBeInTheDocument()
return
}
await act(async () => {
// Try to set activeTaskId to null
try {
(chatStore as any).activeTaskId = null
rerender()
} catch (error) {
// Expected - the store might prevent this
}
})
expect(() => {
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
}).not.toThrow()
})
})
describe('User Interaction Flows', () => {
it('should show textarea input field with correct placeholder', async () => {
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
// Based on the error messages, the actual placeholder is "chat.ask-placeholder"
// This appears to be a translation key rather than plain text
const textarea = screen.getByPlaceholderText('chat.ask-placeholder')
expect(textarea).toBeInTheDocument()
expect(textarea.tagName).toBe('TEXTAREA')
})
it('should have send button that is initially disabled', async () => {
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
// Look for disabled send button (has arrow-right icon based on HTML structure)
const buttons = screen.getAllByRole('button')
const sendButton = buttons.find(btn =>
btn.querySelector('svg.lucide-arrow-right') &&
btn.hasAttribute('disabled')
)
expect(sendButton).toBeInTheDocument()
expect(sendButton).toBeDisabled()
})
it('should display layout.terms-of-use and layout.privacy-policy links', async () => {
render(
<TestWrapper>
<ChatBox />
</TestWrapper>
)
const termsLink = screen.getByRole('link', { name: /layout.terms-of-use/i })
const privacyLink = screen.getByRole('link', { name: /layout.privacy-policy/i })
expect(termsLink).toBeInTheDocument()
expect(termsLink).toHaveAttribute('href', 'https://www.eigent.ai/terms-of-use')
expect(termsLink).toHaveAttribute('target', '_blank')
expect(privacyLink).toBeInTheDocument()
expect(privacyLink).toHaveAttribute('href', 'https://www.eigent.ai/privacy-policy')
expect(privacyLink).toHaveAttribute('target', '_blank')
})
})
describe('Integration with useChatStoreAdapter', () => {
it('should properly integrate with useChatStoreAdapter hook', async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore, projectStore } = result.current
// Verify that the adapter returns the expected structure
expect(projectStore).toBeDefined()
expect(chatStore).toBeDefined()
// Verify that we can access basic properties
if (chatStore) {
expect(chatStore.activeTaskId).toBeDefined()
expect(typeof chatStore.activeTaskId).toBe('string')
}
if (projectStore) {
expect(projectStore.activeProjectId).toBeDefined()
expect(typeof projectStore.activeProjectId).toBe('string')
}
})
it('should handle chatStore state changes through adapter', async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())
const { chatStore } = result.current
const taskId = chatStore?.activeTaskId
if (!chatStore || !taskId) {
throw new Error('ChatStore or taskId is null')
}
// Test adding a message through the adapter
await act(async () => {
chatStore.setHasMessages(taskId, true)
chatStore.addMessages(taskId, {
id: 'test-message-1',
role: 'user',
content: 'Test message from adapter',
attaches: []
})
rerender()
})
// Verify the message was added
const updatedChatStore = result.current.chatStore
if (updatedChatStore && updatedChatStore.tasks[taskId]) {
expect(updatedChatStore.tasks[taskId].hasMessages).toBe(true)
expect(updatedChatStore.tasks[taskId].messages).toHaveLength(1)
expect(updatedChatStore.tasks[taskId].messages[0].content).toBe('Test message from adapter')
}
})
})
})

View file

@ -0,0 +1,33 @@
import { vi } from "vitest";
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(() => [])
}))

64
test/mocks/proxy.mock.ts Normal file
View file

@ -0,0 +1,64 @@
import { vi } from "vitest"
// Mock API calls for both relative and alias paths
const mockImplementation = {
fetchPost: vi.fn((url, data) => {
if (url.includes('/task/')) {
return Promise.resolve({ success: true })
}
return Promise.resolve({})
}),
fetchPut: vi.fn(() => Promise.resolve({ success: true })),
getBaseURL: vi.fn(() => Promise.resolve('http://localhost:8000')),
proxyFetchPost: vi.fn((url, data) => {
// Mock history creation
if (url.includes('/api/chat/history')) {
return Promise.resolve({ id: 'history-' + Date.now() })
}
// Mock provider info
if (url.includes('/api/providers')) {
return Promise.resolve({ items: [] })
}
return Promise.resolve({})
}),
proxyFetchPut: vi.fn(() => Promise.resolve({ success: true })),
proxyFetchGet: vi.fn((url, params) => {
// Mock user key
if (url.includes('/api/user/key')) {
return Promise.resolve({
value: 'test-api-key',
api_url: 'https://api.openai.com',
})
}
// Mock providers
if (url.includes('/api/providers')) {
return Promise.resolve({ items: [] })
}
// Mock privacy settings
if (url.includes('/api/user/privacy')) {
return Promise.resolve({
dataCollection: true,
analytics: true,
marketing: true
})
}
// Mock configs
if (url.includes('/api/configs')) {
return Promise.resolve([])
}
// Mock snapshots - return empty array to prevent the error
if (url.includes('/api/chat/snapshots')) {
return Promise.resolve([])
}
return Promise.resolve({})
}),
uploadFile: vi.fn(),
fetchDelete: vi.fn(),
}
// Mock both relative and alias paths
vi.mock('../../src/api/http', () => mockImplementation)
vi.mock('@/api/http', () => mockImplementation)
// Export the mocked functions for use in tests
export const { proxyFetchGet, proxyFetchPost, fetchPost } = mockImplementation

8
test/mocks/sse.mock.ts Normal file
View file

@ -0,0 +1,8 @@
import { vi } from "vitest"
// Mock fetchEventSource
export const mockFetchEventSource = vi.fn()
vi.mock('@microsoft/fetch-event-source', () => ({
fetchEventSource: (...args: any[]) => mockFetchEventSource(...args),
}))

View file

@ -30,6 +30,10 @@ vi.mock('react-i18next', () => ({
changeLanguage: vi.fn(),
},
}),
initReactI18next: {
type: '3rdParty',
init: vi.fn(),
},
}))
// Mock Electron APIs if needed

View file

@ -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)
})
})
})