Skip to content

Commit 3f4dee7

Browse files
committed
test(frontend/tests): add loading indicator and chat status update tests
Implemented tests for the LoadingIndicator component and added E2E tests for chat status updates during function calls. Modified files (4): - __tests__/components/shared/LoadingIndicator.test.tsx: Added tests for rendering and status messages. - __tests__/components/chat/ChatMessage.test.tsx: Added tests for status message visibility. - e2e/chat.spec.ts: Added tests for displaying status messages during function calls. - e2e/fixtures/mock-data.ts: Updated mock data to include status updates.
1 parent 34dcead commit 3f4dee7

4 files changed

Lines changed: 317 additions & 4 deletions

File tree

src/frontend/task-agent-web/__tests__/components/chat/ChatMessage.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,60 @@ describe('ChatMessage', () => {
202202
const cursor = document.querySelector('.animate-pulse');
203203
expect(cursor).not.toBeInTheDocument();
204204
});
205+
206+
it('should show status message when streaming with empty content', () => {
207+
const emptyAssistantMessage: ChatMessageType = {
208+
id: 'msg-empty',
209+
role: 'assistant',
210+
content: '',
211+
createdAt: '2025-12-06T10:00:00Z',
212+
};
213+
214+
render(
215+
<ChatMessage
216+
message={emptyAssistantMessage}
217+
isStreaming={true}
218+
statusMessage="Creating task..."
219+
/>
220+
);
221+
222+
expect(screen.getByText('Creating task...')).toBeInTheDocument();
223+
});
224+
225+
it('should not show status message when content has been received', () => {
226+
render(
227+
<ChatMessage
228+
message={assistantMessage}
229+
isStreaming={true}
230+
statusMessage="Creating task..."
231+
/>
232+
);
233+
234+
// Status should not appear because content is not empty
235+
expect(screen.queryByText('Creating task...')).not.toBeInTheDocument();
236+
});
237+
238+
it('should show cursor alongside status message', () => {
239+
const emptyAssistantMessage: ChatMessageType = {
240+
id: 'msg-empty',
241+
role: 'assistant',
242+
content: '',
243+
createdAt: '2025-12-06T10:00:00Z',
244+
};
245+
246+
render(
247+
<ChatMessage
248+
message={emptyAssistantMessage}
249+
isStreaming={true}
250+
statusMessage="Searching tasks..."
251+
/>
252+
);
253+
254+
// Both status and cursor should be visible
255+
expect(screen.getByText('Searching tasks...')).toBeInTheDocument();
256+
const cursor = document.querySelector('.animate-pulse');
257+
expect(cursor).toBeInTheDocument();
258+
});
205259
});
206260

207261
describe('function call filtering', () => {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen, act } from '@testing-library/react';
3+
import { LoadingIndicator } from '@/components/shared/LoadingIndicator';
4+
5+
describe('LoadingIndicator', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
vi.runOnlyPendingTimers();
12+
vi.useRealTimers();
13+
});
14+
15+
describe('rendering', () => {
16+
it('should render the loading indicator with assistant label', () => {
17+
render(<LoadingIndicator />);
18+
19+
expect(screen.getByText('🤖')).toBeInTheDocument();
20+
expect(screen.getByText('Task Assistant')).toBeInTheDocument();
21+
});
22+
23+
it('should render animated bounce dots', () => {
24+
render(<LoadingIndicator />);
25+
26+
// There should be 3 bouncing dots
27+
const dots = document.querySelectorAll('.animate-bounce');
28+
expect(dots).toHaveLength(3);
29+
});
30+
});
31+
32+
describe('server status message', () => {
33+
it('should display server status when provided', () => {
34+
render(<LoadingIndicator serverStatus="Creating task..." />);
35+
36+
expect(screen.getByText('Creating task...')).toBeInTheDocument();
37+
});
38+
39+
it('should prioritize server status over context message', () => {
40+
render(
41+
<LoadingIndicator
42+
serverStatus="Searching tasks..."
43+
contextMessage="This should not appear"
44+
/>
45+
);
46+
47+
expect(screen.getByText('Searching tasks...')).toBeInTheDocument();
48+
expect(screen.queryByText('This should not appear')).not.toBeInTheDocument();
49+
});
50+
51+
it('should not rotate messages when server status is provided', async () => {
52+
render(<LoadingIndicator serverStatus="Processing your request..." />);
53+
54+
// Wait for rotation interval
55+
act(() => {
56+
vi.advanceTimersByTime(5000);
57+
});
58+
59+
// Should still show server status
60+
expect(screen.getByText('Processing your request...')).toBeInTheDocument();
61+
});
62+
63+
it('should display different server statuses correctly', () => {
64+
const { rerender } = render(<LoadingIndicator serverStatus="Creating task..." />);
65+
expect(screen.getByText('Creating task...')).toBeInTheDocument();
66+
67+
rerender(<LoadingIndicator serverStatus="Searching tasks..." />);
68+
expect(screen.getByText('Searching tasks...')).toBeInTheDocument();
69+
70+
rerender(<LoadingIndicator serverStatus="Updating task..." />);
71+
expect(screen.getByText('Updating task...')).toBeInTheDocument();
72+
});
73+
});
74+
75+
describe('context message', () => {
76+
it('should display context message when no server status', () => {
77+
render(<LoadingIndicator contextMessage="Custom loading message" />);
78+
79+
expect(screen.getByText('Custom loading message')).toBeInTheDocument();
80+
});
81+
82+
it('should not rotate messages when context message is provided', async () => {
83+
render(<LoadingIndicator contextMessage="Static message" />);
84+
85+
// Wait for rotation interval
86+
act(() => {
87+
vi.advanceTimersByTime(5000);
88+
});
89+
90+
// Should still show context message
91+
expect(screen.getByText('Static message')).toBeInTheDocument();
92+
});
93+
});
94+
95+
describe('default rotation', () => {
96+
it('should show default message when no props provided', () => {
97+
render(<LoadingIndicator />);
98+
99+
// Should show one of the default messages
100+
expect(screen.getByText('🤔 Analyzing your request...')).toBeInTheDocument();
101+
});
102+
103+
it('should rotate through default messages', async () => {
104+
render(<LoadingIndicator />);
105+
106+
// Initial message
107+
expect(screen.getByText('🤔 Analyzing your request...')).toBeInTheDocument();
108+
109+
// Advance timer to trigger rotation
110+
act(() => {
111+
vi.advanceTimersByTime(2000);
112+
});
113+
114+
// Second message
115+
expect(screen.getByText('🔍 Processing task information...')).toBeInTheDocument();
116+
117+
// Advance timer again
118+
act(() => {
119+
vi.advanceTimersByTime(2000);
120+
});
121+
122+
// Third message
123+
expect(screen.getByText('⚡ Generating response...')).toBeInTheDocument();
124+
});
125+
});
126+
127+
describe('transitions', () => {
128+
it('should switch from server status to default when server status becomes null', () => {
129+
const { rerender } = render(<LoadingIndicator serverStatus="Creating task..." />);
130+
expect(screen.getByText('Creating task...')).toBeInTheDocument();
131+
132+
// Server status cleared
133+
rerender(<LoadingIndicator serverStatus={null} />);
134+
135+
// Should now show default message
136+
expect(screen.getByText('🤔 Analyzing your request...')).toBeInTheDocument();
137+
});
138+
});
139+
});

src/frontend/task-agent-web/e2e/chat.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test, expect } from '@playwright/test';
22
import { setupApiMocks } from './fixtures/api-mocks';
3+
import { createMockSSEResponseWithFunctionCall } from './fixtures/mock-data';
34

45
/**
56
* Chat functionality E2E tests
@@ -163,3 +164,75 @@ test.describe('Chat Error Handling', () => {
163164
await expect(page.locator('body')).toBeVisible();
164165
});
165166
});
167+
168+
test.describe('Chat Status Updates', () => {
169+
test.beforeEach(async ({ page }) => {
170+
await setupApiMocks(page);
171+
});
172+
173+
test('should display status message during function call processing', async ({ page }) => {
174+
// Override the chat mock to use a slower response with status updates
175+
await page.route('**/api/agent/chat', async (route) => {
176+
// Create a mock response with function call and status updates
177+
const sseResponse = createMockSSEResponseWithFunctionCall(
178+
'✅ Task created successfully!',
179+
'CreateTask'
180+
);
181+
182+
// Add delay to simulate processing time - status should be visible
183+
await new Promise(resolve => setTimeout(resolve, 500));
184+
185+
await route.fulfill({
186+
status: 200,
187+
contentType: 'text/event-stream',
188+
body: sseResponse,
189+
});
190+
});
191+
192+
await page.goto('/');
193+
await page.waitForLoadState('networkidle');
194+
195+
const chatInput = page.locator('textarea').first();
196+
await chatInput.fill('Create a new task');
197+
198+
// Submit the message
199+
await chatInput.press('Enter');
200+
201+
// Verify the page handles the SSE stream correctly
202+
await page.waitForTimeout(1000);
203+
204+
// After response, the message should be displayed
205+
await expect(page.locator('body')).toBeVisible();
206+
});
207+
208+
test('should display searching status for list tasks operation', async ({ page }) => {
209+
// Override the chat mock for search operation
210+
await page.route('**/api/agent/chat', async (route) => {
211+
const sseResponse = createMockSSEResponseWithFunctionCall(
212+
'Here are your tasks...',
213+
'ListTasks'
214+
);
215+
216+
await new Promise(resolve => setTimeout(resolve, 300));
217+
218+
await route.fulfill({
219+
status: 200,
220+
contentType: 'text/event-stream',
221+
body: sseResponse,
222+
});
223+
});
224+
225+
await page.goto('/');
226+
await page.waitForLoadState('networkidle');
227+
228+
const chatInput = page.locator('textarea').first();
229+
await chatInput.fill('Show me all tasks');
230+
await chatInput.press('Enter');
231+
232+
// Wait for response to complete
233+
await page.waitForTimeout(800);
234+
235+
// The UI should process the response correctly
236+
await expect(page.locator('body')).toBeVisible();
237+
});
238+
});

src/frontend/task-agent-web/e2e/fixtures/mock-data.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,60 @@ export const mockMessages: MockMessage[] = [
6565
/**
6666
* Create a mock SSE stream response for chat
6767
* Returns events in the format expected by the frontend
68+
* Includes STATUS_UPDATE events to simulate real backend behavior
6869
*/
6970
export function createMockSSEResponse(message: string, threadId: string = 'thread-mock-1'): string {
7071
const events = [
71-
`event: TEXT_MESSAGE_START\ndata: {"messageId":"mock-msg-${Date.now()}","createdAt":"${new Date().toISOString()}"}\n\n`,
72-
`event: TEXT_MESSAGE_CONTENT\ndata: {"text":"${message}"}\n\n`,
73-
`event: TEXT_MESSAGE_END\ndata: {}\n\n`,
74-
`event: THREAD_STATE\ndata: {"threadId":"${threadId}","serializedState":"mock-state"}\n\n`,
72+
// Status update events (simulating backend processing stages)
73+
`data: {"type":"STATUS_UPDATE","status":"Processing your request..."}\n\n`,
74+
// Message events
75+
`event: TEXT_MESSAGE_START\ndata: {"type":"TEXT_MESSAGE_START","messageId":"mock-msg-${Date.now()}","createdAt":"${new Date().toISOString()}"}\n\n`,
76+
`event: TEXT_MESSAGE_CONTENT\ndata: {"type":"TEXT_MESSAGE_CONTENT","delta":"${message}"}\n\n`,
77+
`event: TEXT_MESSAGE_END\ndata: {"type":"TEXT_MESSAGE_END"}\n\n`,
78+
`event: THREAD_STATE\ndata: {"type":"THREAD_STATE","serializedState":"${threadId}"}\n\n`,
79+
'data: [DONE]\n\n',
80+
];
81+
return events.join('');
82+
}
83+
84+
/**
85+
* Create a mock SSE stream response with a function call (for testing status updates)
86+
* Simulates backend calling a function tool like CreateTask
87+
* Now includes AG-UI standard STEP_STARTED/STEP_FINISHED events
88+
*/
89+
export function createMockSSEResponseWithFunctionCall(
90+
message: string,
91+
functionName: string = 'CreateTask',
92+
threadId: string = 'thread-mock-1'
93+
): string {
94+
// Status messages are now dynamically generated from [Description] attributes in backend
95+
// These are examples of what the backend generates
96+
const statusMessages: Record<string, string> = {
97+
'CreateTask': 'Creating task...',
98+
'ListTasks': 'Listing tasks...',
99+
'UpdateTask': 'Updating task...',
100+
'DeleteTask': 'Deleting task...',
101+
'GetTaskDetails': 'Getting task details...',
102+
'GetTaskSummary': 'Generating summary...',
103+
};
104+
105+
const events = [
106+
// Initial status update
107+
`data: {"type":"STATUS_UPDATE","status":"Processing your request..."}\n\n`,
108+
// AG-UI standard STEP_STARTED event (function name for debugging/logging)
109+
`data: {"type":"STEP_STARTED","stepName":"${functionName}Async"}\n\n`,
110+
// Dynamic status update (user-friendly message from [Description] attribute)
111+
`data: {"type":"STATUS_UPDATE","status":"${statusMessages[functionName] || 'Processing...'}"}\n\n`,
112+
`data: {"type":"TOOL_CALL_START","toolName":"${functionName}Async","toolCallId":"call-1"}\n\n`,
113+
`data: {"type":"TOOL_CALL_RESULT","toolCallId":"call-1","result":"Success"}\n\n`,
114+
// AG-UI standard STEP_FINISHED event
115+
`data: {"type":"STEP_FINISHED","stepName":"${functionName}Async"}\n\n`,
116+
// Message events
117+
`data: {"type":"TEXT_MESSAGE_START","messageId":"mock-msg-${Date.now()}","createdAt":"${new Date().toISOString()}"}\n\n`,
118+
`data: {"type":"TEXT_MESSAGE_CONTENT","delta":"${message}"}\n\n`,
119+
`data: {"type":"TEXT_MESSAGE_END"}\n\n`,
120+
`data: {"type":"THREAD_STATE","serializedState":"${threadId}"}\n\n`,
121+
'data: [DONE]\n\n',
75122
];
76123
return events.join('');
77124
}

0 commit comments

Comments
 (0)