ChatView.test.tsx (14630B)
1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 import { render, screen } from "@testing-library/react"; 3 import { ChatView } from "./ChatView"; 4 import { useChatStore } from "../../store/chat"; 5 6 const handlers: ((msg: { type: string; data?: unknown }) => void)[] = []; 7 const mockOnMessage = vi.fn((handler: unknown) => { handlers.push(handler as (msg: { type: string; data?: unknown }) => void); }); 8 const mockConnect = vi.fn(); 9 const mockDestroy = vi.fn(); 10 const mockSend = vi.fn(); 11 12 vi.mock("../../lib/ws", () => ({ 13 WsClient: vi.fn().mockImplementation(() => ({ 14 connect: mockConnect, 15 onMessage: mockOnMessage, 16 send: mockSend, 17 destroy: mockDestroy, 18 })), 19 })); 20 21 vi.mock("../Chat/MessageList", () => ({ 22 MessageList: ({ messages }: { messages: unknown[] }) => ( 23 <div data-testid="message-list">{messages.length} messages</div> 24 ), 25 })); 26 27 vi.mock("../Composer/Composer", () => ({ 28 Composer: ({ onSend, disabled }: { onSend: (t: string) => void; disabled: boolean }) => ( 29 <div data-testid="composer"> 30 <button onClick={() => onSend("test")} disabled={disabled}>Send</button> 31 </div> 32 ), 33 })); 34 35 vi.mock("../../lib/api", () => ({ 36 api: { 37 get: vi.fn().mockResolvedValue({ models: [] }), 38 post: vi.fn(), 39 }, 40 uploadAttachment: vi.fn(), 41 })); 42 43 vi.mock("../Composer/ModelPicker", () => ({ 44 ModelPicker: () => <div data-testid="model-picker">ModelPicker</div>, 45 })); 46 47 vi.mock("../BranchIndicator/BranchIndicator", () => ({ 48 BranchIndicator: () => <div data-testid="branch-indicator">BranchIndicator</div>, 49 })); 50 51 const mockCreateSession = vi.fn().mockResolvedValue({ id: "new-session" }); 52 const mockListSessions = vi.fn().mockResolvedValue(undefined); 53 const mockSetActiveSession = vi.fn(); 54 55 vi.mock("../../store/session", () => ({ 56 useSessionStore: Object.assign( 57 () => ({ 58 activeSessionId: null, 59 updateSession: vi.fn(), 60 incrementMessageCount: vi.fn(), 61 createSession: mockCreateSession, 62 listSessions: mockListSessions, 63 setActiveSession: mockSetActiveSession, 64 }), 65 { getState: () => ({ activeSessionId: null, 66 updateSession: vi.fn(), 67 incrementMessageCount: vi.fn(), createSession: mockCreateSession, listSessions: mockListSessions, setActiveSession: mockSetActiveSession }) }, 68 ), 69 })); 70 71 function emit(msg: { type: string; data?: unknown }) { 72 for (const h of handlers) h(msg); 73 } 74 75 beforeEach(() => { 76 useChatStore.setState({ messages: [], streaming: false }); 77 handlers.length = 0; 78 vi.clearAllMocks(); 79 }); 80 81 afterEach(() => { 82 handlers.length = 0; 83 }); 84 85 describe("ChatView", () => { 86 it("renders message list and composer", () => { 87 render(<ChatView />); 88 expect(screen.getByTestId("message-list")).toBeInTheDocument(); 89 expect(screen.getByTestId("composer")).toBeInTheDocument(); 90 }); 91 92 it("shows connecting indicator initially", () => { 93 render(<ChatView />); 94 expect(screen.getByText("Connecting to Hermes...")).toBeInTheDocument(); 95 }); 96 97 it("disables composer when streaming", () => { 98 useChatStore.setState({ streaming: true }); 99 render(<ChatView />); 100 expect(screen.getByText("Send")).toBeDisabled(); 101 }); 102 103 it("sends message via WS and adds user message", async () => { 104 render(<ChatView />); 105 screen.getByText("Send").click(); 106 await vi.waitFor(() => { 107 expect(mockSend).toHaveBeenCalledWith("chat", expect.objectContaining({ content: "test" })); 108 }); 109 expect(useChatStore.getState().messages).toHaveLength(1); 110 expect(useChatStore.getState().messages[0].role).toBe("user"); 111 }); 112 113 it("handles run.started event", () => { 114 render(<ChatView />); 115 emit({ type: "run.started" }); 116 expect(useChatStore.getState().streaming).toBe(true); 117 }); 118 119 it("handles run.completed event", () => { 120 useChatStore.setState({ streaming: true }); 121 render(<ChatView />); 122 emit({ type: "run.completed" }); 123 expect(useChatStore.getState().streaming).toBe(false); 124 }); 125 126 it("handles message.started event", () => { 127 render(<ChatView />); 128 emit({ type: "message.started", data: { id: "msg-1", role: "assistant", model: "gpt-4" } }); 129 expect(useChatStore.getState().messages).toHaveLength(1); 130 expect(useChatStore.getState().messages[0].id).toBe("msg-1"); 131 }); 132 133 it("handles message.started without model", () => { 134 render(<ChatView />); 135 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 136 expect(useChatStore.getState().messages[0].model).toBeUndefined(); 137 }); 138 139 it("handles message.started with invalid data", () => { 140 render(<ChatView />); 141 emit({ type: "message.started", data: "invalid" }); 142 expect(useChatStore.getState().messages).toHaveLength(0); 143 }); 144 145 it("handles message.started with missing role", () => { 146 render(<ChatView />); 147 emit({ type: "message.started", data: { id: "msg-1" } }); 148 expect(useChatStore.getState().messages).toHaveLength(0); 149 }); 150 151 it("handles assistant.delta event", () => { 152 render(<ChatView />); 153 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 154 emit({ type: "assistant.delta", data: { id: "msg-1", content: "Hello " } }); 155 emit({ type: "assistant.delta", data: { id: "msg-1", content: "world" } }); 156 expect(useChatStore.getState().messages[0].content).toBe("Hello world"); 157 }); 158 159 it("handles assistant.delta for non-existent message", () => { 160 render(<ChatView />); 161 emit({ type: "assistant.delta", data: { id: "nonexistent", content: "test" } }); 162 expect(useChatStore.getState().messages).toHaveLength(0); 163 }); 164 165 it("handles assistant.delta with invalid data", () => { 166 render(<ChatView />); 167 emit({ type: "assistant.delta", data: "invalid" }); 168 expect(useChatStore.getState().messages).toHaveLength(0); 169 }); 170 171 it("handles assistant.completed event", () => { 172 render(<ChatView />); 173 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 174 emit({ type: "assistant.completed", data: { id: "msg-1", content: "Final answer" } }); 175 expect(useChatStore.getState().messages[0].content).toBe("Final answer"); 176 }); 177 178 it("handles assistant.completed without content", () => { 179 render(<ChatView />); 180 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 181 emit({ type: "assistant.completed", data: { id: "msg-1" } }); 182 expect(useChatStore.getState().messages[0].content).toBe(""); 183 }); 184 185 it("handles assistant.completed with invalid data", () => { 186 render(<ChatView />); 187 emit({ type: "assistant.completed", data: "invalid" }); 188 expect(useChatStore.getState().messages).toHaveLength(0); 189 }); 190 191 it("handles tool.started event", () => { 192 render(<ChatView />); 193 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 194 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: '{"q":"test"}' } } }); 195 expect(useChatStore.getState().messages[0].toolCalls).toHaveLength(1); 196 expect(useChatStore.getState().messages[0].toolCalls[0].name).toBe("search"); 197 }); 198 199 it("handles tool.started with invalid data", () => { 200 render(<ChatView />); 201 emit({ type: "tool.started", data: "invalid" }); 202 expect(useChatStore.getState().messages).toHaveLength(0); 203 }); 204 205 it("handles tool.completed event", () => { 206 render(<ChatView />); 207 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 208 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 209 emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1", result: "found it" } }); 210 expect(useChatStore.getState().messages[0].toolCalls[0].status).toBe("complete"); 211 }); 212 213 it("handles tool.completed without result", () => { 214 render(<ChatView />); 215 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 216 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 217 emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1" } }); 218 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBeNull(); 219 }); 220 221 it("populates tool results from run.completed turn messages", () => { 222 render(<ChatView />); 223 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 224 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 225 emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1" } }); 226 // Result is null before run.completed 227 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBeNull(); 228 // run.completed includes turn messages with tool results 229 emit({ 230 type: "run.completed", 231 data: { 232 completed: true, 233 messages: [ 234 { role: "tool", tool_call_id: "tc-1", content: "found it" }, 235 ], 236 }, 237 }); 238 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("found it"); 239 }); 240 241 it("run.completed does not overwrite existing tool results", () => { 242 render(<ChatView />); 243 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 244 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 245 emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1", result: "original" } }); 246 emit({ 247 type: "run.completed", 248 data: { 249 completed: true, 250 messages: [ 251 { role: "tool", tool_call_id: "tc-1", content: "overwritten" }, 252 ], 253 }, 254 }); 255 // Should NOT overwrite the existing result 256 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("original"); 257 }); 258 259 it("run.completed handles multiple tool calls", () => { 260 render(<ChatView />); 261 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 262 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 263 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-2", name: "read", args: "{}" } } }); 264 emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1" } }); 265 emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-2" } }); 266 emit({ 267 type: "run.completed", 268 data: { 269 completed: true, 270 messages: [ 271 { role: "tool", tool_call_id: "tc-1", content: "search result" }, 272 { role: "tool", tool_call_id: "tc-2", content: "file content" }, 273 ], 274 }, 275 }); 276 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("search result"); 277 expect(useChatStore.getState().messages[0].toolCalls[1].result).toBe("file content"); 278 }); 279 280 it("handles tool.completed with invalid data", () => { 281 render(<ChatView />); 282 emit({ type: "tool.completed", data: "invalid" }); 283 expect(useChatStore.getState().messages).toHaveLength(0); 284 }); 285 286 it("handles tool.failed event", () => { 287 render(<ChatView />); 288 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 289 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 290 emit({ type: "tool.failed", data: { messageId: "msg-1", toolCallId: "tc-1", error: "timeout" } }); 291 expect(useChatStore.getState().messages[0].toolCalls[0].status).toBe("error"); 292 }); 293 294 it("handles tool.failed with default error message", () => { 295 render(<ChatView />); 296 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 297 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 298 emit({ type: "tool.failed", data: { messageId: "msg-1", toolCallId: "tc-1" } }); 299 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("Tool failed"); 300 }); 301 302 it("handles tool.failed with invalid data", () => { 303 render(<ChatView />); 304 emit({ type: "tool.failed", data: "invalid" }); 305 expect(useChatStore.getState().messages).toHaveLength(0); 306 }); 307 308 it("handles tool.progress event", () => { 309 render(<ChatView />); 310 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 311 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 312 emit({ type: "tool.progress", data: { messageId: "msg-1", toolCallId: "tc-1", progress: "50%" } }); 313 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("50%"); 314 }); 315 316 it("handles tool.progress without progress value", () => { 317 render(<ChatView />); 318 emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } }); 319 emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } }); 320 emit({ type: "tool.progress", data: { messageId: "msg-1", toolCallId: "tc-1" } }); 321 expect(useChatStore.getState().messages[0].toolCalls[0].result).toBeNull(); 322 }); 323 324 it("handles tool.progress with invalid data", () => { 325 render(<ChatView />); 326 emit({ type: "tool.progress", data: "invalid" }); 327 expect(useChatStore.getState().messages).toHaveLength(0); 328 }); 329 330 it("ignores unknown event types", () => { 331 render(<ChatView />); 332 emit({ type: "unknown.event" }); 333 expect(useChatStore.getState().messages).toHaveLength(0); 334 }); 335 336 it("creates WebSocket and connects", () => { 337 render(<ChatView />); 338 expect(mockConnect).toHaveBeenCalled(); 339 expect(mockOnMessage).toHaveBeenCalled(); 340 }); 341 342 it("destroys WS on unmount", () => { 343 const { unmount } = render(<ChatView />); 344 unmount(); 345 expect(mockDestroy).toHaveBeenCalled(); 346 }); 347 348 it("renders model picker and branch indicator", () => { 349 render(<ChatView />); 350 expect(screen.getByTestId("model-picker")).toBeInTheDocument(); 351 expect(screen.getByTestId("branch-indicator")).toBeInTheDocument(); 352 }); 353 354 it("send includes model selection", async () => { 355 render(<ChatView />); 356 screen.getByText("Send").click(); 357 await vi.waitFor(() => { 358 expect(mockSend).toHaveBeenCalledWith( 359 "chat", 360 expect.objectContaining({ model: null }), 361 ); 362 }); 363 }); 364 365 it("run.started and run.completed toggle streaming", () => { 366 render(<ChatView />); 367 emit({ type: "run.started" }); 368 expect(useChatStore.getState().streaming).toBe(true); 369 emit({ type: "run.completed" }); 370 expect(useChatStore.getState().streaming).toBe(false); 371 }); 372 });