blob: 314c5a070a5d1366eaf9f6eb3f38ecbaa5c2ee17 [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
bankseane59a2e12025-06-28 01:38:19 +000099 await expect(timeline.locator("[data-testid='welcome-box']")).toBeVisible();
100 await expect(
101 timeline.locator("[data-testid='welcome-box-title']"),
102 ).toContainText("How to use Sketch");
Sean McCulloughb7744372025-06-19 00:01:07 +0000103});
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
bankseane59a2e12025-06-28 01:38:19 +0000123 await expect(
124 timeline.locator("[data-testid='timeline-container']"),
125 ).toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000126 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
127});
128
129test("shows thinking indicator when agent is active", async ({ mount }) => {
130 const messages = createMockMessages(3);
banksean54777362025-06-19 16:38:30 +0000131 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000132
133 const timeline = await mount(SketchTimeline, {
134 props: {
135 messages,
136 llmCalls: 1,
137 toolCalls: ["thinking"],
banksean54777362025-06-19 16:38:30 +0000138 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000139 },
140 });
141
banksean54777362025-06-19 16:38:30 +0000142 // Directly set the isInitialLoadComplete state to bypass the event system for testing
143 await timeline.evaluate((element: SketchTimeline) => {
144 (element as any).isInitialLoadComplete = true;
bankseane59a2e12025-06-28 01:38:19 +0000145 console.log("Set isInitialLoadComplete to true");
146 console.log("llmCalls:", element.llmCalls);
147 console.log("toolCalls:", element.toolCalls);
148 console.log(
149 "isInitialLoadComplete:",
150 (element as any).isInitialLoadComplete,
151 );
banksean54777362025-06-19 16:38:30 +0000152 element.requestUpdate();
153 return element.updateComplete;
154 });
155
bankseane59a2e12025-06-28 01:38:19 +0000156 // Debug: Check if the element exists and what its computed style is
157 const indicatorExists = await timeline
158 .locator("[data-testid='thinking-indicator']")
159 .count();
160 console.log("Thinking indicator exists:", indicatorExists);
161
162 if (indicatorExists > 0) {
163 const style = await timeline
164 .locator("[data-testid='thinking-indicator']")
165 .evaluate((el) => {
166 const computed = window.getComputedStyle(el);
167 return {
168 display: computed.display,
169 visibility: computed.visibility,
170 opacity: computed.opacity,
171 className: el.className,
172 };
173 });
174 console.log("Thinking indicator style:", style);
175 }
176 // Wait for the component to render with a longer timeout
177 await expect(
178 timeline.locator("[data-testid='thinking-indicator']"),
179 ).toBeVisible({ timeout: 10000 });
180 await expect(
181 timeline.locator("[data-testid='thinking-bubble']"),
182 ).toBeVisible();
183 await expect(timeline.locator("[data-testid='thinking-dot']")).toHaveCount(3);
Sean McCulloughb7744372025-06-19 00:01:07 +0000184});
185
186test("filters out messages with hide_output flag", async ({ mount }) => {
187 const messages = [
188 createMockMessage({ idx: 0, content: "Visible message 1" }),
189 createMockMessage({ idx: 1, content: "Hidden message", hide_output: true }),
190 createMockMessage({ idx: 2, content: "Visible message 2" }),
191 ];
banksean54777362025-06-19 16:38:30 +0000192 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000193
194 const timeline = await mount(SketchTimeline, {
195 props: {
196 messages,
banksean54777362025-06-19 16:38:30 +0000197 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000198 },
199 });
200
banksean54777362025-06-19 16:38:30 +0000201 // Directly set the isInitialLoadComplete state to bypass the event system for testing
202 await timeline.evaluate((element: SketchTimeline) => {
203 (element as any).isInitialLoadComplete = true;
204 element.requestUpdate();
205 return element.updateComplete;
206 });
207
Sean McCulloughb7744372025-06-19 00:01:07 +0000208 // Should only show 2 visible messages
209 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(2);
210
211 // Verify the hidden message is not rendered by checking each visible message individually
212 const visibleMessages = timeline.locator("sketch-timeline-message");
213 await expect(visibleMessages.nth(0)).toContainText("Visible message 1");
214 await expect(visibleMessages.nth(1)).toContainText("Visible message 2");
215
216 // Check that no message contains the hidden text
217 const firstMessageText = await visibleMessages.nth(0).textContent();
218 const secondMessageText = await visibleMessages.nth(1).textContent();
219 expect(firstMessageText).not.toContain("Hidden message");
220 expect(secondMessageText).not.toContain("Hidden message");
221});
222
223// Viewport Management Tests
224
225test("limits initial message count based on initialMessageCount property", async ({
226 mount,
227}) => {
228 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000229 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000230
231 const timeline = await mount(SketchTimeline, {
232 props: {
233 messages,
234 initialMessageCount: 10,
banksean54777362025-06-19 16:38:30 +0000235 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000236 },
237 });
238
banksean54777362025-06-19 16:38:30 +0000239 // Directly set the isInitialLoadComplete state to bypass the event system for testing
240 await timeline.evaluate((element: SketchTimeline) => {
241 (element as any).isInitialLoadComplete = true;
242 element.requestUpdate();
243 return element.updateComplete;
244 });
245
Sean McCulloughb7744372025-06-19 00:01:07 +0000246 // Should only render the most recent 10 messages initially
247 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
248
249 // Should show the most recent messages (41-50)
250 await expect(
251 timeline.locator("sketch-timeline-message").first(),
252 ).toContainText("Message 41");
253 await expect(
254 timeline.locator("sketch-timeline-message").last(),
255 ).toContainText("Message 50");
256});
257
258test("handles viewport expansion correctly", async ({ mount }) => {
259 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000260 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000261
262 const timeline = await mount(SketchTimeline, {
263 props: {
264 messages,
265 initialMessageCount: 10,
266 loadChunkSize: 5,
banksean54777362025-06-19 16:38:30 +0000267 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000268 },
269 });
270
banksean54777362025-06-19 16:38:30 +0000271 // Directly set the isInitialLoadComplete state to bypass the event system for testing
272 await timeline.evaluate((element: SketchTimeline) => {
273 (element as any).isInitialLoadComplete = true;
274 element.requestUpdate();
275 return element.updateComplete;
276 });
277
Sean McCulloughb7744372025-06-19 00:01:07 +0000278 // Initially shows 10 messages
279 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
280
281 // Simulate expanding viewport by setting visibleMessageStartIndex
282 await timeline.evaluate((element: SketchTimeline) => {
283 (element as any).visibleMessageStartIndex = 5;
284 element.requestUpdate();
285 return element.updateComplete;
286 });
287
288 // Should now show 15 messages (10 initial + 5 chunk)
289 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(15);
290
291 // Should show messages 36-50
292 await expect(
293 timeline.locator("sketch-timeline-message").first(),
294 ).toContainText("Message 36");
295 await expect(
296 timeline.locator("sketch-timeline-message").last(),
297 ).toContainText("Message 50");
298});
299
300test("resetViewport method resets to most recent messages", async ({
301 mount,
302}) => {
303 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000304 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000305
306 const timeline = await mount(SketchTimeline, {
307 props: {
308 messages,
309 initialMessageCount: 10,
banksean54777362025-06-19 16:38:30 +0000310 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000311 },
312 });
313
banksean54777362025-06-19 16:38:30 +0000314 // Directly set the isInitialLoadComplete state to bypass the event system for testing
315 await timeline.evaluate((element: SketchTimeline) => {
316 (element as any).isInitialLoadComplete = true;
317 element.requestUpdate();
318 return element.updateComplete;
319 });
320
Sean McCulloughb7744372025-06-19 00:01:07 +0000321 // Expand viewport
322 await timeline.evaluate((element: SketchTimeline) => {
323 (element as any).visibleMessageStartIndex = 10;
324 element.requestUpdate();
325 return element.updateComplete;
326 });
327
328 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(20);
329
330 // Reset viewport
331 await timeline.evaluate((element: SketchTimeline) => {
332 element.resetViewport();
333 return element.updateComplete;
334 });
335
336 // Should be back to initial count
337 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
338 await expect(
339 timeline.locator("sketch-timeline-message").first(),
340 ).toContainText("Message 41");
341});
342
343// Scroll State Management Tests
344
345test("shows jump-to-latest button when not pinned to latest", async ({
346 mount,
347}) => {
348 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000349 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000350
351 const timeline = await mount(SketchTimeline, {
352 props: {
353 messages,
banksean54777362025-06-19 16:38:30 +0000354 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000355 },
356 });
357
banksean54777362025-06-19 16:38:30 +0000358 // Directly set the isInitialLoadComplete state to bypass the event system for testing
359 await timeline.evaluate((element: SketchTimeline) => {
360 (element as any).isInitialLoadComplete = true;
361 element.requestUpdate();
362 return element.updateComplete;
363 });
364
Sean McCulloughb7744372025-06-19 00:01:07 +0000365 // Initially should be pinned to latest (button hidden)
366 await expect(timeline.locator("#jump-to-latest.floating")).not.toBeVisible();
367
368 // Simulate floating state
369 await timeline.evaluate((element: SketchTimeline) => {
370 (element as any).scrollingState = "floating";
371 element.requestUpdate();
372 return element.updateComplete;
373 });
374
bankseane59a2e12025-06-28 01:38:19 +0000375 // Button should now be visible - wait longer for CSS classes to apply
376 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible({
377 timeout: 10000,
378 });
Sean McCulloughb7744372025-06-19 00:01:07 +0000379});
380
381test("jump-to-latest button calls scroll method", async ({ mount }) => {
382 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000383 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000384
385 const timeline = await mount(SketchTimeline, {
386 props: {
387 messages,
banksean54777362025-06-19 16:38:30 +0000388 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000389 },
390 });
391
banksean54777362025-06-19 16:38:30 +0000392 // Directly set the isInitialLoadComplete state to bypass the event system for testing
393 await timeline.evaluate((element: SketchTimeline) => {
394 (element as any).isInitialLoadComplete = true;
395 element.requestUpdate();
396 return element.updateComplete;
397 });
398
Sean McCulloughb7744372025-06-19 00:01:07 +0000399 // Initialize the scroll tracking flag and set to floating state to show button
400 await timeline.evaluate((element: SketchTimeline) => {
401 // Initialize tracking flag
402 (window as any).scrollCalled = false;
403
404 // Set floating state
405 (element as any).scrollingState = "floating";
406
407 // Mock the scroll method
408 (element as any).scrollToBottomWithRetry = async function () {
409 (window as any).scrollCalled = true;
410 return Promise.resolve();
411 };
412
413 element.requestUpdate();
414 return element.updateComplete;
415 });
416
bankseane59a2e12025-06-28 01:38:19 +0000417 // Verify button is visible before clicking - wait longer for CSS classes to apply
418 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible({
419 timeout: 10000,
420 });
Sean McCulloughb7744372025-06-19 00:01:07 +0000421
422 // Click the jump to latest button
423 await timeline.locator("#jump-to-latest").click();
424
425 // Check if scroll was called
426 const wasScrollCalled = await timeline.evaluate(
427 () => (window as any).scrollCalled,
428 );
429 expect(wasScrollCalled).toBe(true);
430});
431
432// Loading State Tests
433
434test("shows loading indicator when loading older messages", async ({
435 mount,
436}) => {
437 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000438 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000439
440 const timeline = await mount(SketchTimeline, {
441 props: {
442 messages,
banksean54777362025-06-19 16:38:30 +0000443 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000444 },
445 });
446
banksean54777362025-06-19 16:38:30 +0000447 // Set initial load complete first, then simulate loading older messages
Sean McCulloughb7744372025-06-19 00:01:07 +0000448 await timeline.evaluate((element: SketchTimeline) => {
banksean54777362025-06-19 16:38:30 +0000449 (element as any).isInitialLoadComplete = true;
Sean McCulloughb7744372025-06-19 00:01:07 +0000450 (element as any).isLoadingOlderMessages = true;
451 element.requestUpdate();
452 return element.updateComplete;
453 });
454
bankseane59a2e12025-06-28 01:38:19 +0000455 await expect(
456 timeline.locator("[data-testid='loading-indicator']"),
457 ).toBeVisible();
458 await expect(
459 timeline.locator("[data-testid='loading-spinner']"),
460 ).toBeVisible();
461 await expect(
462 timeline.locator("[data-testid='loading-indicator']"),
463 ).toContainText("Loading older messages...");
Sean McCulloughb7744372025-06-19 00:01:07 +0000464});
465
466test("hides loading indicator when not loading", async ({ mount }) => {
467 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000468 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000469
470 const timeline = await mount(SketchTimeline, {
471 props: {
472 messages,
banksean54777362025-06-19 16:38:30 +0000473 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000474 },
475 });
476
banksean54777362025-06-19 16:38:30 +0000477 // Set initial load complete so no loading indicator is shown
478 await timeline.evaluate((element: SketchTimeline) => {
479 (element as any).isInitialLoadComplete = true;
480 element.requestUpdate();
481 return element.updateComplete;
482 });
483
Sean McCulloughb7744372025-06-19 00:01:07 +0000484 // Should not show loading indicator by default
bankseane59a2e12025-06-28 01:38:19 +0000485 await expect(
486 timeline.locator("[data-testid='loading-indicator']"),
487 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000488});
489
490// Memory Management and Cleanup Tests
491
492test("handles scroll container changes properly", async ({ mount }) => {
493 const messages = createMockMessages(5);
494
495 const timeline = await mount(SketchTimeline, {
496 props: {
497 messages,
498 },
499 });
500
501 // Initialize call counters in window
502 await timeline.evaluate(() => {
503 (window as any).addListenerCalls = 0;
504 (window as any).removeListenerCalls = 0;
505 });
506
507 // Set first container
508 await timeline.evaluate((element: SketchTimeline) => {
509 const mockContainer1 = {
510 addEventListener: () => {
511 (window as any).addListenerCalls =
512 ((window as any).addListenerCalls || 0) + 1;
513 },
514 removeEventListener: () => {
515 (window as any).removeListenerCalls =
516 ((window as any).removeListenerCalls || 0) + 1;
517 },
518 isConnected: true,
519 scrollTop: 0,
520 scrollHeight: 1000,
521 clientHeight: 500,
522 };
523 (element as any).scrollContainer = { value: mockContainer1 };
524 element.requestUpdate();
525 return element.updateComplete;
526 });
527
528 // Change to second container (should clean up first)
529 await timeline.evaluate((element: SketchTimeline) => {
530 const mockContainer2 = {
531 addEventListener: () => {
532 (window as any).addListenerCalls =
533 ((window as any).addListenerCalls || 0) + 1;
534 },
535 removeEventListener: () => {
536 (window as any).removeListenerCalls =
537 ((window as any).removeListenerCalls || 0) + 1;
538 },
539 isConnected: true,
540 scrollTop: 0,
541 scrollHeight: 1000,
542 clientHeight: 500,
543 };
544 (element as any).scrollContainer = { value: mockContainer2 };
545 element.requestUpdate();
546 return element.updateComplete;
547 });
548
549 // Get the call counts
550 const addListenerCalls = await timeline.evaluate(
551 () => (window as any).addListenerCalls || 0,
552 );
553 const removeListenerCalls = await timeline.evaluate(
554 () => (window as any).removeListenerCalls || 0,
555 );
556
557 // Should have called addEventListener twice and removeEventListener once
558 expect(addListenerCalls).toBeGreaterThan(0);
559 expect(removeListenerCalls).toBeGreaterThan(0);
560});
561
562test("cancels loading operations on viewport reset", async ({ mount }) => {
563 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000564 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000565
566 const timeline = await mount(SketchTimeline, {
567 props: {
568 messages,
banksean54777362025-06-19 16:38:30 +0000569 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000570 },
571 });
572
banksean54777362025-06-19 16:38:30 +0000573 // Set initial load complete and then loading older messages state
Sean McCulloughb7744372025-06-19 00:01:07 +0000574 await timeline.evaluate((element: SketchTimeline) => {
banksean54777362025-06-19 16:38:30 +0000575 (element as any).isInitialLoadComplete = true;
Sean McCulloughb7744372025-06-19 00:01:07 +0000576 (element as any).isLoadingOlderMessages = true;
577 (element as any).loadingAbortController = new AbortController();
578 element.requestUpdate();
579 return element.updateComplete;
580 });
581
banksean54777362025-06-19 16:38:30 +0000582 // Verify loading state - should show only the "loading older messages" indicator
bankseane59a2e12025-06-28 01:38:19 +0000583 await expect(
584 timeline.locator("[data-testid='loading-indicator']"),
585 ).toContainText("Loading older messages...");
Sean McCulloughb7744372025-06-19 00:01:07 +0000586
587 // Reset viewport (should cancel loading)
588 await timeline.evaluate((element: SketchTimeline) => {
589 element.resetViewport();
590 return element.updateComplete;
591 });
592
593 // Loading should be cancelled
594 const isLoading = await timeline.evaluate(
595 (element: SketchTimeline) => (element as any).isLoadingOlderMessages,
596 );
597 expect(isLoading).toBe(false);
598
bankseane59a2e12025-06-28 01:38:19 +0000599 await expect(
600 timeline.locator("[data-testid='loading-indicator']"),
601 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000602});
603
604// Message Filtering and Ordering Tests
605
606test("displays messages in correct order (most recent at bottom)", async ({
607 mount,
608}) => {
609 const messages = [
610 createMockMessage({
611 idx: 0,
612 content: "First message",
613 timestamp: "2023-01-01T10:00:00Z",
614 }),
615 createMockMessage({
616 idx: 1,
617 content: "Second message",
618 timestamp: "2023-01-01T11:00:00Z",
619 }),
620 createMockMessage({
621 idx: 2,
622 content: "Third message",
623 timestamp: "2023-01-01T12:00:00Z",
624 }),
625 ];
banksean54777362025-06-19 16:38:30 +0000626 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000627
628 const timeline = await mount(SketchTimeline, {
629 props: {
630 messages,
banksean54777362025-06-19 16:38:30 +0000631 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000632 },
633 });
634
banksean54777362025-06-19 16:38:30 +0000635 // Directly set the isInitialLoadComplete state to bypass the event system for testing
636 await timeline.evaluate((element: SketchTimeline) => {
637 (element as any).isInitialLoadComplete = true;
638 element.requestUpdate();
639 return element.updateComplete;
640 });
641
Sean McCulloughb7744372025-06-19 00:01:07 +0000642 const messageElements = timeline.locator("sketch-timeline-message");
643
644 // Check order
645 await expect(messageElements.nth(0)).toContainText("First message");
646 await expect(messageElements.nth(1)).toContainText("Second message");
647 await expect(messageElements.nth(2)).toContainText("Third message");
648});
649
650test("handles previousMessage prop correctly for message context", async ({
651 mount,
652}) => {
653 const messages = [
654 createMockMessage({ idx: 0, content: "First message", type: "user" }),
655 createMockMessage({ idx: 1, content: "Second message", type: "agent" }),
656 createMockMessage({ idx: 2, content: "Third message", type: "user" }),
657 ];
banksean54777362025-06-19 16:38:30 +0000658 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000659
660 const timeline = await mount(SketchTimeline, {
661 props: {
662 messages,
banksean54777362025-06-19 16:38:30 +0000663 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000664 },
665 });
666
banksean54777362025-06-19 16:38:30 +0000667 // Directly set the isInitialLoadComplete state to bypass the event system for testing
668 await timeline.evaluate((element: SketchTimeline) => {
669 (element as any).isInitialLoadComplete = true;
670 element.requestUpdate();
671 return element.updateComplete;
672 });
673
Sean McCulloughb7744372025-06-19 00:01:07 +0000674 // Check that messages have the expected structure
675 // The first message should not have a previous message context
676 // The second message should have the first as previous, etc.
677
678 const messageElements = timeline.locator("sketch-timeline-message");
679 await expect(messageElements).toHaveCount(3);
680
681 // All messages should be rendered
682 await expect(messageElements.nth(0)).toContainText("First message");
683 await expect(messageElements.nth(1)).toContainText("Second message");
684 await expect(messageElements.nth(2)).toContainText("Third message");
685});
686
687// Event Handling Tests
688
689test("handles show-commit-diff events from message components", async ({
690 mount,
691}) => {
692 const messages = [
693 createMockMessage({
694 idx: 0,
695 content: "Message with commit",
696 commits: [
697 {
698 hash: "abc123def456",
699 subject: "Test commit",
700 body: "Test commit body",
701 pushed_branch: "main",
702 },
703 ],
704 }),
705 ];
706
707 const timeline = await mount(SketchTimeline, {
708 props: {
709 messages,
710 },
711 });
712
713 // Listen for the bubbled event
714 await timeline.evaluate((element) => {
715 element.addEventListener("show-commit-diff", (event: CustomEvent) => {
716 window.testEventFired = true;
717 window.testEventDetail = event.detail;
718 });
719 });
720
721 // Simulate the event being fired from a message component
722 await timeline.evaluate((element) => {
723 const event = new CustomEvent("show-commit-diff", {
724 detail: { commitHash: "abc123def456" },
725 bubbles: true,
726 composed: true,
727 });
728 element.dispatchEvent(event);
729 });
730
731 // Check that event was handled
732 const wasEventFired = await timeline.evaluate(() => window.testEventFired);
733 const detail = await timeline.evaluate(() => window.testEventDetail);
734
735 expect(wasEventFired).toBe(true);
736 expect(detail?.commitHash).toBe("abc123def456");
737});
738
739// Edge Cases and Error Handling
740
741test("handles empty filteredMessages gracefully", async ({ mount }) => {
742 // All messages are hidden - this will still render timeline structure
743 // because component only shows welcome box when messages.length === 0
744 const messages = [
745 createMockMessage({ idx: 0, content: "Hidden 1", hide_output: true }),
746 createMockMessage({ idx: 1, content: "Hidden 2", hide_output: true }),
747 ];
banksean54777362025-06-19 16:38:30 +0000748 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000749
750 const timeline = await mount(SketchTimeline, {
751 props: {
752 messages,
banksean54777362025-06-19 16:38:30 +0000753 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000754 },
755 });
756
banksean54777362025-06-19 16:38:30 +0000757 // Directly set the isInitialLoadComplete state to bypass the event system for testing
758 await timeline.evaluate((element: SketchTimeline) => {
759 (element as any).isInitialLoadComplete = true;
760 element.requestUpdate();
761 return element.updateComplete;
762 });
763
Sean McCulloughb7744372025-06-19 00:01:07 +0000764 // Should render the timeline structure but with no visible messages
765 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(0);
766
767 // Should not show welcome box when messages array has content (even if all hidden)
bankseane59a2e12025-06-28 01:38:19 +0000768 await expect(
769 timeline.locator("[data-testid='welcome-box']"),
770 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000771
772 // Should not show loading indicator
bankseane59a2e12025-06-28 01:38:19 +0000773 await expect(
774 timeline.locator("[data-testid='loading-indicator']"),
775 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000776
777 // Timeline container exists but may not be visible due to CSS
bankseane59a2e12025-06-28 01:38:19 +0000778 await expect(
779 timeline.locator("[data-testid='timeline-container']"),
780 ).toBeAttached();
Sean McCulloughb7744372025-06-19 00:01:07 +0000781});
782
783test("handles message array updates correctly", async ({ mount }) => {
784 const initialMessages = createMockMessages(5);
banksean54777362025-06-19 16:38:30 +0000785 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000786
787 const timeline = await mount(SketchTimeline, {
788 props: {
789 messages: initialMessages,
banksean54777362025-06-19 16:38:30 +0000790 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000791 },
792 });
793
banksean54777362025-06-19 16:38:30 +0000794 // Directly set the isInitialLoadComplete state to bypass the event system for testing
795 await timeline.evaluate((element: SketchTimeline) => {
796 (element as any).isInitialLoadComplete = true;
797 element.requestUpdate();
798 return element.updateComplete;
799 });
800
Sean McCulloughb7744372025-06-19 00:01:07 +0000801 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
802
803 // Update with more messages
804 const moreMessages = createMockMessages(10);
805 await timeline.evaluate(
806 (element: SketchTimeline, newMessages: AgentMessage[]) => {
807 element.messages = newMessages;
808 element.requestUpdate();
809 return element.updateComplete;
810 },
811 moreMessages,
812 );
813
814 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
815
816 // Update with fewer messages
817 const fewerMessages = createMockMessages(3);
818 await timeline.evaluate(
819 (element: SketchTimeline, newMessages: AgentMessage[]) => {
820 element.messages = newMessages;
821 element.requestUpdate();
822 return element.updateComplete;
823 },
824 fewerMessages,
825 );
826
827 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(3);
828});
829
830test("messageKey method generates unique keys correctly", async ({ mount }) => {
831 const timeline = await mount(SketchTimeline);
832
833 const message1 = createMockMessage({ idx: 1, tool_calls: [] });
834 const message2 = createMockMessage({ idx: 2, tool_calls: [] });
835 const message3 = createMockMessage({
836 idx: 1,
837 tool_calls: [
838 {
839 tool_call_id: "call_123",
840 name: "test",
841 input: "{}",
842 result_message: createMockMessage({ idx: 99, content: "result" }),
843 },
844 ],
845 });
846
847 const key1 = await timeline.evaluate(
848 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
849 message1,
850 );
851 const key2 = await timeline.evaluate(
852 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
853 message2,
854 );
855 const key3 = await timeline.evaluate(
856 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
857 message3,
858 );
859
860 // Keys should be unique
861 expect(key1).not.toBe(key2);
862 expect(key1).not.toBe(key3);
863 expect(key2).not.toBe(key3);
864
865 // Keys should include message index
866 expect(key1).toContain("message-1");
867 expect(key2).toContain("message-2");
868 expect(key3).toContain("message-1");
869
870 // Message with tool call should have different key than without
871 expect(key1).not.toBe(key3);
872});