blob: e803a2e60553cb21d26655a9d5513458239cfa38 [file] [log] [blame]
Sean McCulloughb7744372025-06-19 00:01:07 +00001import { test, expect } from "@sand4rt/experimental-ct-web";
2import { SketchTimeline } from "./sketch-timeline";
3import { AgentMessage } from "../types";
4
banksean54777362025-06-19 16:38:30 +00005// Mock DataManager class that mimics the real DataManager interface
6class MockDataManager {
7 private eventListeners: Map<string, Array<(...args: any[]) => void>> =
8 new Map();
9 private isInitialLoadComplete: boolean = false;
10
11 constructor() {
12 this.eventListeners.set("initialLoadComplete", []);
13 }
14
15 addEventListener(event: string, callback: (...args: any[]) => void): void {
16 const listeners = this.eventListeners.get(event) || [];
17 listeners.push(callback);
18 this.eventListeners.set(event, listeners);
19 }
20
21 removeEventListener(event: string, callback: (...args: any[]) => void): void {
22 const listeners = this.eventListeners.get(event) || [];
23 const index = listeners.indexOf(callback);
24 if (index > -1) {
25 listeners.splice(index, 1);
26 }
27 }
28
29 getIsInitialLoadComplete(): boolean {
30 return this.isInitialLoadComplete;
31 }
32
33 triggerInitialLoadComplete(
34 messageCount: number = 0,
35 expectedCount: number = 0,
36 ): void {
37 this.isInitialLoadComplete = true;
38 const listeners = this.eventListeners.get("initialLoadComplete") || [];
39 // Call each listener with the event data object as expected by the component
40 listeners.forEach((listener) => {
41 try {
42 listener({ messageCount, expectedCount });
43 } catch (e) {
44 console.error("Error in event listener:", e);
45 }
46 });
47 }
48}
49
Sean McCulloughb7744372025-06-19 00:01:07 +000050// Helper function to create mock timeline messages
51function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
52 return {
53 idx: props.idx || 0,
54 type: props.type || "agent",
55 content: props.content || "Hello world",
56 timestamp: props.timestamp || "2023-05-15T12:00:00Z",
57 elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
58 end_of_turn: props.end_of_turn || false,
59 conversation_id: props.conversation_id || "conv123",
60 tool_calls: props.tool_calls || [],
61 commits: props.commits || [],
62 usage: props.usage,
63 hide_output: props.hide_output || false,
64 ...props,
65 };
66}
67
68// Extend window interface for test globals
69declare global {
70 interface Window {
71 scrollCalled?: boolean;
72 testEventFired?: boolean;
73 testEventDetail?: any;
74 }
75}
76
77// Helper function to create an array of mock messages
78function createMockMessages(count: number): AgentMessage[] {
79 return Array.from({ length: count }, (_, i) =>
80 createMockMessage({
81 idx: i,
82 content: `Message ${i + 1}`,
83 type: i % 3 === 0 ? "user" : "agent",
84 timestamp: new Date(Date.now() - (count - i) * 60000).toISOString(),
85 }),
86 );
87}
88
89test("renders empty state when no messages", async ({ mount }) => {
banksean54777362025-06-19 16:38:30 +000090 const mockDataManager = new MockDataManager();
91
Sean McCulloughb7744372025-06-19 00:01:07 +000092 const timeline = await mount(SketchTimeline, {
93 props: {
94 messages: [],
banksean54777362025-06-19 16:38:30 +000095 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +000096 },
97 });
98
99 await expect(timeline.locator(".welcome-box")).toBeVisible();
100 await expect(timeline.locator(".welcome-box-title")).toContainText(
101 "How to use Sketch",
102 );
103});
104
105test("renders messages when provided", async ({ mount }) => {
106 const messages = createMockMessages(5);
banksean54777362025-06-19 16:38:30 +0000107 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000108
109 const timeline = await mount(SketchTimeline, {
110 props: {
111 messages,
banksean54777362025-06-19 16:38:30 +0000112 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000113 },
114 });
115
banksean54777362025-06-19 16:38:30 +0000116 // Directly set the isInitialLoadComplete state to bypass the event system for testing
117 await timeline.evaluate((element: SketchTimeline) => {
118 (element as any).isInitialLoadComplete = true;
119 element.requestUpdate();
120 return element.updateComplete;
121 });
122
Sean McCulloughb7744372025-06-19 00:01:07 +0000123 await expect(timeline.locator(".timeline-container")).toBeVisible();
124 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
125});
126
127test("shows thinking indicator when agent is active", async ({ mount }) => {
128 const messages = createMockMessages(3);
banksean54777362025-06-19 16:38:30 +0000129 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000130
131 const timeline = await mount(SketchTimeline, {
132 props: {
133 messages,
134 llmCalls: 1,
135 toolCalls: ["thinking"],
banksean54777362025-06-19 16:38:30 +0000136 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000137 },
138 });
139
banksean54777362025-06-19 16:38:30 +0000140 // Directly set the isInitialLoadComplete state to bypass the event system for testing
141 await timeline.evaluate((element: SketchTimeline) => {
142 (element as any).isInitialLoadComplete = true;
143 element.requestUpdate();
144 return element.updateComplete;
145 });
146
Sean McCulloughb7744372025-06-19 00:01:07 +0000147 await expect(timeline.locator(".thinking-indicator")).toBeVisible();
148 await expect(timeline.locator(".thinking-bubble")).toBeVisible();
149 await expect(timeline.locator(".thinking-dots .dot")).toHaveCount(3);
150});
151
152test("filters out messages with hide_output flag", async ({ mount }) => {
153 const messages = [
154 createMockMessage({ idx: 0, content: "Visible message 1" }),
155 createMockMessage({ idx: 1, content: "Hidden message", hide_output: true }),
156 createMockMessage({ idx: 2, content: "Visible message 2" }),
157 ];
banksean54777362025-06-19 16:38:30 +0000158 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000159
160 const timeline = await mount(SketchTimeline, {
161 props: {
162 messages,
banksean54777362025-06-19 16:38:30 +0000163 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000164 },
165 });
166
banksean54777362025-06-19 16:38:30 +0000167 // Directly set the isInitialLoadComplete state to bypass the event system for testing
168 await timeline.evaluate((element: SketchTimeline) => {
169 (element as any).isInitialLoadComplete = true;
170 element.requestUpdate();
171 return element.updateComplete;
172 });
173
Sean McCulloughb7744372025-06-19 00:01:07 +0000174 // Should only show 2 visible messages
175 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(2);
176
177 // Verify the hidden message is not rendered by checking each visible message individually
178 const visibleMessages = timeline.locator("sketch-timeline-message");
179 await expect(visibleMessages.nth(0)).toContainText("Visible message 1");
180 await expect(visibleMessages.nth(1)).toContainText("Visible message 2");
181
182 // Check that no message contains the hidden text
183 const firstMessageText = await visibleMessages.nth(0).textContent();
184 const secondMessageText = await visibleMessages.nth(1).textContent();
185 expect(firstMessageText).not.toContain("Hidden message");
186 expect(secondMessageText).not.toContain("Hidden message");
187});
188
189// Viewport Management Tests
190
191test("limits initial message count based on initialMessageCount property", async ({
192 mount,
193}) => {
194 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000195 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000196
197 const timeline = await mount(SketchTimeline, {
198 props: {
199 messages,
200 initialMessageCount: 10,
banksean54777362025-06-19 16:38:30 +0000201 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000202 },
203 });
204
banksean54777362025-06-19 16:38:30 +0000205 // Directly set the isInitialLoadComplete state to bypass the event system for testing
206 await timeline.evaluate((element: SketchTimeline) => {
207 (element as any).isInitialLoadComplete = true;
208 element.requestUpdate();
209 return element.updateComplete;
210 });
211
Sean McCulloughb7744372025-06-19 00:01:07 +0000212 // Should only render the most recent 10 messages initially
213 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
214
215 // Should show the most recent messages (41-50)
216 await expect(
217 timeline.locator("sketch-timeline-message").first(),
218 ).toContainText("Message 41");
219 await expect(
220 timeline.locator("sketch-timeline-message").last(),
221 ).toContainText("Message 50");
222});
223
224test("handles viewport expansion correctly", async ({ mount }) => {
225 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000226 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000227
228 const timeline = await mount(SketchTimeline, {
229 props: {
230 messages,
231 initialMessageCount: 10,
232 loadChunkSize: 5,
banksean54777362025-06-19 16:38:30 +0000233 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000234 },
235 });
236
banksean54777362025-06-19 16:38:30 +0000237 // Directly set the isInitialLoadComplete state to bypass the event system for testing
238 await timeline.evaluate((element: SketchTimeline) => {
239 (element as any).isInitialLoadComplete = true;
240 element.requestUpdate();
241 return element.updateComplete;
242 });
243
Sean McCulloughb7744372025-06-19 00:01:07 +0000244 // Initially shows 10 messages
245 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
246
247 // Simulate expanding viewport by setting visibleMessageStartIndex
248 await timeline.evaluate((element: SketchTimeline) => {
249 (element as any).visibleMessageStartIndex = 5;
250 element.requestUpdate();
251 return element.updateComplete;
252 });
253
254 // Should now show 15 messages (10 initial + 5 chunk)
255 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(15);
256
257 // Should show messages 36-50
258 await expect(
259 timeline.locator("sketch-timeline-message").first(),
260 ).toContainText("Message 36");
261 await expect(
262 timeline.locator("sketch-timeline-message").last(),
263 ).toContainText("Message 50");
264});
265
266test("resetViewport method resets to most recent messages", async ({
267 mount,
268}) => {
269 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000270 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000271
272 const timeline = await mount(SketchTimeline, {
273 props: {
274 messages,
275 initialMessageCount: 10,
banksean54777362025-06-19 16:38:30 +0000276 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000277 },
278 });
279
banksean54777362025-06-19 16:38:30 +0000280 // Directly set the isInitialLoadComplete state to bypass the event system for testing
281 await timeline.evaluate((element: SketchTimeline) => {
282 (element as any).isInitialLoadComplete = true;
283 element.requestUpdate();
284 return element.updateComplete;
285 });
286
Sean McCulloughb7744372025-06-19 00:01:07 +0000287 // Expand viewport
288 await timeline.evaluate((element: SketchTimeline) => {
289 (element as any).visibleMessageStartIndex = 10;
290 element.requestUpdate();
291 return element.updateComplete;
292 });
293
294 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(20);
295
296 // Reset viewport
297 await timeline.evaluate((element: SketchTimeline) => {
298 element.resetViewport();
299 return element.updateComplete;
300 });
301
302 // Should be back to initial count
303 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
304 await expect(
305 timeline.locator("sketch-timeline-message").first(),
306 ).toContainText("Message 41");
307});
308
309// Scroll State Management Tests
310
311test("shows jump-to-latest button when not pinned to latest", async ({
312 mount,
313}) => {
314 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000315 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000316
317 const timeline = await mount(SketchTimeline, {
318 props: {
319 messages,
banksean54777362025-06-19 16:38:30 +0000320 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000321 },
322 });
323
banksean54777362025-06-19 16:38:30 +0000324 // Directly set the isInitialLoadComplete state to bypass the event system for testing
325 await timeline.evaluate((element: SketchTimeline) => {
326 (element as any).isInitialLoadComplete = true;
327 element.requestUpdate();
328 return element.updateComplete;
329 });
330
Sean McCulloughb7744372025-06-19 00:01:07 +0000331 // Initially should be pinned to latest (button hidden)
332 await expect(timeline.locator("#jump-to-latest.floating")).not.toBeVisible();
333
334 // Simulate floating state
335 await timeline.evaluate((element: SketchTimeline) => {
336 (element as any).scrollingState = "floating";
337 element.requestUpdate();
338 return element.updateComplete;
339 });
340
341 // Button should now be visible
342 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible();
343});
344
345test("jump-to-latest button calls scroll method", async ({ mount }) => {
346 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000347 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000348
349 const timeline = await mount(SketchTimeline, {
350 props: {
351 messages,
banksean54777362025-06-19 16:38:30 +0000352 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000353 },
354 });
355
banksean54777362025-06-19 16:38:30 +0000356 // Directly set the isInitialLoadComplete state to bypass the event system for testing
357 await timeline.evaluate((element: SketchTimeline) => {
358 (element as any).isInitialLoadComplete = true;
359 element.requestUpdate();
360 return element.updateComplete;
361 });
362
Sean McCulloughb7744372025-06-19 00:01:07 +0000363 // Initialize the scroll tracking flag and set to floating state to show button
364 await timeline.evaluate((element: SketchTimeline) => {
365 // Initialize tracking flag
366 (window as any).scrollCalled = false;
367
368 // Set floating state
369 (element as any).scrollingState = "floating";
370
371 // Mock the scroll method
372 (element as any).scrollToBottomWithRetry = async function () {
373 (window as any).scrollCalled = true;
374 return Promise.resolve();
375 };
376
377 element.requestUpdate();
378 return element.updateComplete;
379 });
380
381 // Verify button is visible before clicking
382 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible();
383
384 // Click the jump to latest button
385 await timeline.locator("#jump-to-latest").click();
386
387 // Check if scroll was called
388 const wasScrollCalled = await timeline.evaluate(
389 () => (window as any).scrollCalled,
390 );
391 expect(wasScrollCalled).toBe(true);
392});
393
394// Loading State Tests
395
396test("shows loading indicator when loading older messages", async ({
397 mount,
398}) => {
399 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000400 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000401
402 const timeline = await mount(SketchTimeline, {
403 props: {
404 messages,
banksean54777362025-06-19 16:38:30 +0000405 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000406 },
407 });
408
banksean54777362025-06-19 16:38:30 +0000409 // Set initial load complete first, then simulate loading older messages
Sean McCulloughb7744372025-06-19 00:01:07 +0000410 await timeline.evaluate((element: SketchTimeline) => {
banksean54777362025-06-19 16:38:30 +0000411 (element as any).isInitialLoadComplete = true;
Sean McCulloughb7744372025-06-19 00:01:07 +0000412 (element as any).isLoadingOlderMessages = true;
413 element.requestUpdate();
414 return element.updateComplete;
415 });
416
417 await expect(timeline.locator(".loading-indicator")).toBeVisible();
418 await expect(timeline.locator(".loading-spinner")).toBeVisible();
419 await expect(timeline.locator(".loading-indicator")).toContainText(
420 "Loading older messages...",
421 );
422});
423
424test("hides loading indicator when not loading", async ({ mount }) => {
425 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000426 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000427
428 const timeline = await mount(SketchTimeline, {
429 props: {
430 messages,
banksean54777362025-06-19 16:38:30 +0000431 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000432 },
433 });
434
banksean54777362025-06-19 16:38:30 +0000435 // Set initial load complete so no loading indicator is shown
436 await timeline.evaluate((element: SketchTimeline) => {
437 (element as any).isInitialLoadComplete = true;
438 element.requestUpdate();
439 return element.updateComplete;
440 });
441
Sean McCulloughb7744372025-06-19 00:01:07 +0000442 // Should not show loading indicator by default
443 await expect(timeline.locator(".loading-indicator")).not.toBeVisible();
444});
445
446// Memory Management and Cleanup Tests
447
448test("handles scroll container changes properly", async ({ mount }) => {
449 const messages = createMockMessages(5);
450
451 const timeline = await mount(SketchTimeline, {
452 props: {
453 messages,
454 },
455 });
456
457 // Initialize call counters in window
458 await timeline.evaluate(() => {
459 (window as any).addListenerCalls = 0;
460 (window as any).removeListenerCalls = 0;
461 });
462
463 // Set first container
464 await timeline.evaluate((element: SketchTimeline) => {
465 const mockContainer1 = {
466 addEventListener: () => {
467 (window as any).addListenerCalls =
468 ((window as any).addListenerCalls || 0) + 1;
469 },
470 removeEventListener: () => {
471 (window as any).removeListenerCalls =
472 ((window as any).removeListenerCalls || 0) + 1;
473 },
474 isConnected: true,
475 scrollTop: 0,
476 scrollHeight: 1000,
477 clientHeight: 500,
478 };
479 (element as any).scrollContainer = { value: mockContainer1 };
480 element.requestUpdate();
481 return element.updateComplete;
482 });
483
484 // Change to second container (should clean up first)
485 await timeline.evaluate((element: SketchTimeline) => {
486 const mockContainer2 = {
487 addEventListener: () => {
488 (window as any).addListenerCalls =
489 ((window as any).addListenerCalls || 0) + 1;
490 },
491 removeEventListener: () => {
492 (window as any).removeListenerCalls =
493 ((window as any).removeListenerCalls || 0) + 1;
494 },
495 isConnected: true,
496 scrollTop: 0,
497 scrollHeight: 1000,
498 clientHeight: 500,
499 };
500 (element as any).scrollContainer = { value: mockContainer2 };
501 element.requestUpdate();
502 return element.updateComplete;
503 });
504
505 // Get the call counts
506 const addListenerCalls = await timeline.evaluate(
507 () => (window as any).addListenerCalls || 0,
508 );
509 const removeListenerCalls = await timeline.evaluate(
510 () => (window as any).removeListenerCalls || 0,
511 );
512
513 // Should have called addEventListener twice and removeEventListener once
514 expect(addListenerCalls).toBeGreaterThan(0);
515 expect(removeListenerCalls).toBeGreaterThan(0);
516});
517
518test("cancels loading operations on viewport reset", async ({ mount }) => {
519 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000520 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000521
522 const timeline = await mount(SketchTimeline, {
523 props: {
524 messages,
banksean54777362025-06-19 16:38:30 +0000525 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000526 },
527 });
528
banksean54777362025-06-19 16:38:30 +0000529 // Set initial load complete and then loading older messages state
Sean McCulloughb7744372025-06-19 00:01:07 +0000530 await timeline.evaluate((element: SketchTimeline) => {
banksean54777362025-06-19 16:38:30 +0000531 (element as any).isInitialLoadComplete = true;
Sean McCulloughb7744372025-06-19 00:01:07 +0000532 (element as any).isLoadingOlderMessages = true;
533 (element as any).loadingAbortController = new AbortController();
534 element.requestUpdate();
535 return element.updateComplete;
536 });
537
banksean54777362025-06-19 16:38:30 +0000538 // Verify loading state - should show only the "loading older messages" indicator
539 await expect(timeline.locator(".loading-indicator")).toContainText(
540 "Loading older messages...",
541 );
Sean McCulloughb7744372025-06-19 00:01:07 +0000542
543 // Reset viewport (should cancel loading)
544 await timeline.evaluate((element: SketchTimeline) => {
545 element.resetViewport();
546 return element.updateComplete;
547 });
548
549 // Loading should be cancelled
550 const isLoading = await timeline.evaluate(
551 (element: SketchTimeline) => (element as any).isLoadingOlderMessages,
552 );
553 expect(isLoading).toBe(false);
554
555 await expect(timeline.locator(".loading-indicator")).not.toBeVisible();
556});
557
558// Message Filtering and Ordering Tests
559
560test("displays messages in correct order (most recent at bottom)", async ({
561 mount,
562}) => {
563 const messages = [
564 createMockMessage({
565 idx: 0,
566 content: "First message",
567 timestamp: "2023-01-01T10:00:00Z",
568 }),
569 createMockMessage({
570 idx: 1,
571 content: "Second message",
572 timestamp: "2023-01-01T11:00:00Z",
573 }),
574 createMockMessage({
575 idx: 2,
576 content: "Third message",
577 timestamp: "2023-01-01T12:00:00Z",
578 }),
579 ];
banksean54777362025-06-19 16:38:30 +0000580 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000581
582 const timeline = await mount(SketchTimeline, {
583 props: {
584 messages,
banksean54777362025-06-19 16:38:30 +0000585 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000586 },
587 });
588
banksean54777362025-06-19 16:38:30 +0000589 // Directly set the isInitialLoadComplete state to bypass the event system for testing
590 await timeline.evaluate((element: SketchTimeline) => {
591 (element as any).isInitialLoadComplete = true;
592 element.requestUpdate();
593 return element.updateComplete;
594 });
595
Sean McCulloughb7744372025-06-19 00:01:07 +0000596 const messageElements = timeline.locator("sketch-timeline-message");
597
598 // Check order
599 await expect(messageElements.nth(0)).toContainText("First message");
600 await expect(messageElements.nth(1)).toContainText("Second message");
601 await expect(messageElements.nth(2)).toContainText("Third message");
602});
603
604test("handles previousMessage prop correctly for message context", async ({
605 mount,
606}) => {
607 const messages = [
608 createMockMessage({ idx: 0, content: "First message", type: "user" }),
609 createMockMessage({ idx: 1, content: "Second message", type: "agent" }),
610 createMockMessage({ idx: 2, content: "Third message", type: "user" }),
611 ];
banksean54777362025-06-19 16:38:30 +0000612 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000613
614 const timeline = await mount(SketchTimeline, {
615 props: {
616 messages,
banksean54777362025-06-19 16:38:30 +0000617 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000618 },
619 });
620
banksean54777362025-06-19 16:38:30 +0000621 // Directly set the isInitialLoadComplete state to bypass the event system for testing
622 await timeline.evaluate((element: SketchTimeline) => {
623 (element as any).isInitialLoadComplete = true;
624 element.requestUpdate();
625 return element.updateComplete;
626 });
627
Sean McCulloughb7744372025-06-19 00:01:07 +0000628 // Check that messages have the expected structure
629 // The first message should not have a previous message context
630 // The second message should have the first as previous, etc.
631
632 const messageElements = timeline.locator("sketch-timeline-message");
633 await expect(messageElements).toHaveCount(3);
634
635 // All messages should be rendered
636 await expect(messageElements.nth(0)).toContainText("First message");
637 await expect(messageElements.nth(1)).toContainText("Second message");
638 await expect(messageElements.nth(2)).toContainText("Third message");
639});
640
641// Event Handling Tests
642
643test("handles show-commit-diff events from message components", async ({
644 mount,
645}) => {
646 const messages = [
647 createMockMessage({
648 idx: 0,
649 content: "Message with commit",
650 commits: [
651 {
652 hash: "abc123def456",
653 subject: "Test commit",
654 body: "Test commit body",
655 pushed_branch: "main",
656 },
657 ],
658 }),
659 ];
660
661 const timeline = await mount(SketchTimeline, {
662 props: {
663 messages,
664 },
665 });
666
667 // Listen for the bubbled event
668 await timeline.evaluate((element) => {
669 element.addEventListener("show-commit-diff", (event: CustomEvent) => {
670 window.testEventFired = true;
671 window.testEventDetail = event.detail;
672 });
673 });
674
675 // Simulate the event being fired from a message component
676 await timeline.evaluate((element) => {
677 const event = new CustomEvent("show-commit-diff", {
678 detail: { commitHash: "abc123def456" },
679 bubbles: true,
680 composed: true,
681 });
682 element.dispatchEvent(event);
683 });
684
685 // Check that event was handled
686 const wasEventFired = await timeline.evaluate(() => window.testEventFired);
687 const detail = await timeline.evaluate(() => window.testEventDetail);
688
689 expect(wasEventFired).toBe(true);
690 expect(detail?.commitHash).toBe("abc123def456");
691});
692
693// Edge Cases and Error Handling
694
695test("handles empty filteredMessages gracefully", async ({ mount }) => {
696 // All messages are hidden - this will still render timeline structure
697 // because component only shows welcome box when messages.length === 0
698 const messages = [
699 createMockMessage({ idx: 0, content: "Hidden 1", hide_output: true }),
700 createMockMessage({ idx: 1, content: "Hidden 2", hide_output: true }),
701 ];
banksean54777362025-06-19 16:38:30 +0000702 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000703
704 const timeline = await mount(SketchTimeline, {
705 props: {
706 messages,
banksean54777362025-06-19 16:38:30 +0000707 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000708 },
709 });
710
banksean54777362025-06-19 16:38:30 +0000711 // Directly set the isInitialLoadComplete state to bypass the event system for testing
712 await timeline.evaluate((element: SketchTimeline) => {
713 (element as any).isInitialLoadComplete = true;
714 element.requestUpdate();
715 return element.updateComplete;
716 });
717
Sean McCulloughb7744372025-06-19 00:01:07 +0000718 // Should render the timeline structure but with no visible messages
719 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(0);
720
721 // Should not show welcome box when messages array has content (even if all hidden)
722 await expect(timeline.locator(".welcome-box")).not.toBeVisible();
723
724 // Should not show loading indicator
725 await expect(timeline.locator(".loading-indicator")).not.toBeVisible();
726
727 // Timeline container exists but may not be visible due to CSS
728 await expect(timeline.locator(".timeline-container")).toBeAttached();
729});
730
731test("handles message array updates correctly", async ({ mount }) => {
732 const initialMessages = createMockMessages(5);
banksean54777362025-06-19 16:38:30 +0000733 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000734
735 const timeline = await mount(SketchTimeline, {
736 props: {
737 messages: initialMessages,
banksean54777362025-06-19 16:38:30 +0000738 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000739 },
740 });
741
banksean54777362025-06-19 16:38:30 +0000742 // Directly set the isInitialLoadComplete state to bypass the event system for testing
743 await timeline.evaluate((element: SketchTimeline) => {
744 (element as any).isInitialLoadComplete = true;
745 element.requestUpdate();
746 return element.updateComplete;
747 });
748
Sean McCulloughb7744372025-06-19 00:01:07 +0000749 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
750
751 // Update with more messages
752 const moreMessages = createMockMessages(10);
753 await timeline.evaluate(
754 (element: SketchTimeline, newMessages: AgentMessage[]) => {
755 element.messages = newMessages;
756 element.requestUpdate();
757 return element.updateComplete;
758 },
759 moreMessages,
760 );
761
762 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
763
764 // Update with fewer messages
765 const fewerMessages = createMockMessages(3);
766 await timeline.evaluate(
767 (element: SketchTimeline, newMessages: AgentMessage[]) => {
768 element.messages = newMessages;
769 element.requestUpdate();
770 return element.updateComplete;
771 },
772 fewerMessages,
773 );
774
775 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(3);
776});
777
778test("messageKey method generates unique keys correctly", async ({ mount }) => {
779 const timeline = await mount(SketchTimeline);
780
781 const message1 = createMockMessage({ idx: 1, tool_calls: [] });
782 const message2 = createMockMessage({ idx: 2, tool_calls: [] });
783 const message3 = createMockMessage({
784 idx: 1,
785 tool_calls: [
786 {
787 tool_call_id: "call_123",
788 name: "test",
789 input: "{}",
790 result_message: createMockMessage({ idx: 99, content: "result" }),
791 },
792 ],
793 });
794
795 const key1 = await timeline.evaluate(
796 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
797 message1,
798 );
799 const key2 = await timeline.evaluate(
800 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
801 message2,
802 );
803 const key3 = await timeline.evaluate(
804 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
805 message3,
806 );
807
808 // Keys should be unique
809 expect(key1).not.toBe(key2);
810 expect(key1).not.toBe(key3);
811 expect(key2).not.toBe(key3);
812
813 // Keys should include message index
814 expect(key1).toContain("message-1");
815 expect(key2).toContain("message-2");
816 expect(key3).toContain("message-1");
817
818 // Message with tool call should have different key than without
819 expect(key1).not.toBe(key3);
820});