blob: 64323c0d0d5f175e1592d4d5780d84eb882269fd [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
Sean McCulloughb7744372025-06-19 00:01:07 +00002import { test, expect } from "@sand4rt/experimental-ct-web";
3import { SketchTimeline } from "./sketch-timeline";
4import { AgentMessage } from "../types";
5
banksean54777362025-06-19 16:38:30 +00006// Mock DataManager class that mimics the real DataManager interface
7class MockDataManager {
8 private eventListeners: Map<string, Array<(...args: any[]) => void>> =
9 new Map();
10 private isInitialLoadComplete: boolean = false;
11
12 constructor() {
13 this.eventListeners.set("initialLoadComplete", []);
14 }
15
16 addEventListener(event: string, callback: (...args: any[]) => void): void {
17 const listeners = this.eventListeners.get(event) || [];
18 listeners.push(callback);
19 this.eventListeners.set(event, listeners);
20 }
21
22 removeEventListener(event: string, callback: (...args: any[]) => void): void {
23 const listeners = this.eventListeners.get(event) || [];
24 const index = listeners.indexOf(callback);
25 if (index > -1) {
26 listeners.splice(index, 1);
27 }
28 }
29
30 getIsInitialLoadComplete(): boolean {
31 return this.isInitialLoadComplete;
32 }
33
34 triggerInitialLoadComplete(
35 messageCount: number = 0,
36 expectedCount: number = 0,
37 ): void {
38 this.isInitialLoadComplete = true;
39 const listeners = this.eventListeners.get("initialLoadComplete") || [];
40 // Call each listener with the event data object as expected by the component
41 listeners.forEach((listener) => {
42 try {
43 listener({ messageCount, expectedCount });
44 } catch (e) {
45 console.error("Error in event listener:", e);
46 }
47 });
48 }
49}
50
Sean McCulloughb7744372025-06-19 00:01:07 +000051// Helper function to create mock timeline messages
52function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
53 return {
54 idx: props.idx || 0,
55 type: props.type || "agent",
56 content: props.content || "Hello world",
57 timestamp: props.timestamp || "2023-05-15T12:00:00Z",
58 elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
59 end_of_turn: props.end_of_turn || false,
60 conversation_id: props.conversation_id || "conv123",
61 tool_calls: props.tool_calls || [],
62 commits: props.commits || [],
63 usage: props.usage,
64 hide_output: props.hide_output || false,
65 ...props,
66 };
67}
68
69// Extend window interface for test globals
70declare global {
71 interface Window {
72 scrollCalled?: boolean;
73 testEventFired?: boolean;
74 testEventDetail?: any;
75 }
76}
77
78// Helper function to create an array of mock messages
79function createMockMessages(count: number): AgentMessage[] {
80 return Array.from({ length: count }, (_, i) =>
81 createMockMessage({
82 idx: i,
83 content: `Message ${i + 1}`,
84 type: i % 3 === 0 ? "user" : "agent",
85 timestamp: new Date(Date.now() - (count - i) * 60000).toISOString(),
86 }),
87 );
88}
89
90test("renders empty state when no messages", async ({ mount }) => {
banksean54777362025-06-19 16:38:30 +000091 const mockDataManager = new MockDataManager();
92
Sean McCulloughb7744372025-06-19 00:01:07 +000093 const timeline = await mount(SketchTimeline, {
94 props: {
95 messages: [],
banksean54777362025-06-19 16:38:30 +000096 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +000097 },
98 });
99
bankseane59a2e12025-06-28 01:38:19 +0000100 await expect(timeline.locator("[data-testid='welcome-box']")).toBeVisible();
101 await expect(
102 timeline.locator("[data-testid='welcome-box-title']"),
103 ).toContainText("How to use Sketch");
Sean McCulloughb7744372025-06-19 00:01:07 +0000104});
105
106test("renders messages when provided", async ({ mount }) => {
107 const messages = createMockMessages(5);
banksean54777362025-06-19 16:38:30 +0000108 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000109
110 const timeline = await mount(SketchTimeline, {
111 props: {
112 messages,
banksean54777362025-06-19 16:38:30 +0000113 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000114 },
115 });
116
banksean54777362025-06-19 16:38:30 +0000117 // Directly set the isInitialLoadComplete state to bypass the event system for testing
118 await timeline.evaluate((element: SketchTimeline) => {
119 (element as any).isInitialLoadComplete = true;
120 element.requestUpdate();
121 return element.updateComplete;
122 });
123
bankseane59a2e12025-06-28 01:38:19 +0000124 await expect(
125 timeline.locator("[data-testid='timeline-container']"),
126 ).toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000127 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
128});
129
130test("shows thinking indicator when agent is active", async ({ mount }) => {
131 const messages = createMockMessages(3);
banksean54777362025-06-19 16:38:30 +0000132 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000133
134 const timeline = await mount(SketchTimeline, {
135 props: {
136 messages,
137 llmCalls: 1,
138 toolCalls: ["thinking"],
banksean54777362025-06-19 16:38:30 +0000139 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000140 },
141 });
142
banksean54777362025-06-19 16:38:30 +0000143 // Directly set the isInitialLoadComplete state to bypass the event system for testing
144 await timeline.evaluate((element: SketchTimeline) => {
145 (element as any).isInitialLoadComplete = true;
bankseane59a2e12025-06-28 01:38:19 +0000146 console.log("Set isInitialLoadComplete to true");
147 console.log("llmCalls:", element.llmCalls);
148 console.log("toolCalls:", element.toolCalls);
149 console.log(
150 "isInitialLoadComplete:",
151 (element as any).isInitialLoadComplete,
152 );
banksean54777362025-06-19 16:38:30 +0000153 element.requestUpdate();
154 return element.updateComplete;
155 });
156
bankseane59a2e12025-06-28 01:38:19 +0000157 // Debug: Check if the element exists and what its computed style is
158 const indicatorExists = await timeline
159 .locator("[data-testid='thinking-indicator']")
160 .count();
161 console.log("Thinking indicator exists:", indicatorExists);
162
163 if (indicatorExists > 0) {
164 const style = await timeline
165 .locator("[data-testid='thinking-indicator']")
166 .evaluate((el) => {
167 const computed = window.getComputedStyle(el);
168 return {
169 display: computed.display,
170 visibility: computed.visibility,
171 opacity: computed.opacity,
172 className: el.className,
173 };
174 });
175 console.log("Thinking indicator style:", style);
176 }
177 // Wait for the component to render with a longer timeout
178 await expect(
179 timeline.locator("[data-testid='thinking-indicator']"),
180 ).toBeVisible({ timeout: 10000 });
181 await expect(
182 timeline.locator("[data-testid='thinking-bubble']"),
183 ).toBeVisible();
184 await expect(timeline.locator("[data-testid='thinking-dot']")).toHaveCount(3);
Sean McCulloughb7744372025-06-19 00:01:07 +0000185});
186
187test("filters out messages with hide_output flag", async ({ mount }) => {
188 const messages = [
189 createMockMessage({ idx: 0, content: "Visible message 1" }),
190 createMockMessage({ idx: 1, content: "Hidden message", hide_output: true }),
191 createMockMessage({ idx: 2, content: "Visible message 2" }),
192 ];
banksean54777362025-06-19 16:38:30 +0000193 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000194
195 const timeline = await mount(SketchTimeline, {
196 props: {
197 messages,
banksean54777362025-06-19 16:38:30 +0000198 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000199 },
200 });
201
banksean54777362025-06-19 16:38:30 +0000202 // Directly set the isInitialLoadComplete state to bypass the event system for testing
203 await timeline.evaluate((element: SketchTimeline) => {
204 (element as any).isInitialLoadComplete = true;
205 element.requestUpdate();
206 return element.updateComplete;
207 });
208
Sean McCulloughb7744372025-06-19 00:01:07 +0000209 // Should only show 2 visible messages
210 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(2);
211
212 // Verify the hidden message is not rendered by checking each visible message individually
213 const visibleMessages = timeline.locator("sketch-timeline-message");
214 await expect(visibleMessages.nth(0)).toContainText("Visible message 1");
215 await expect(visibleMessages.nth(1)).toContainText("Visible message 2");
216
217 // Check that no message contains the hidden text
218 const firstMessageText = await visibleMessages.nth(0).textContent();
219 const secondMessageText = await visibleMessages.nth(1).textContent();
220 expect(firstMessageText).not.toContain("Hidden message");
221 expect(secondMessageText).not.toContain("Hidden message");
222});
223
224// Viewport Management Tests
225
226test("limits initial message count based on initialMessageCount property", async ({
227 mount,
228}) => {
229 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000230 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000231
232 const timeline = await mount(SketchTimeline, {
233 props: {
234 messages,
235 initialMessageCount: 10,
banksean54777362025-06-19 16:38:30 +0000236 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000237 },
238 });
239
banksean54777362025-06-19 16:38:30 +0000240 // Directly set the isInitialLoadComplete state to bypass the event system for testing
241 await timeline.evaluate((element: SketchTimeline) => {
242 (element as any).isInitialLoadComplete = true;
243 element.requestUpdate();
244 return element.updateComplete;
245 });
246
Sean McCulloughb7744372025-06-19 00:01:07 +0000247 // Should only render the most recent 10 messages initially
248 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
249
250 // Should show the most recent messages (41-50)
251 await expect(
252 timeline.locator("sketch-timeline-message").first(),
253 ).toContainText("Message 41");
254 await expect(
255 timeline.locator("sketch-timeline-message").last(),
256 ).toContainText("Message 50");
257});
258
259test("handles viewport expansion correctly", async ({ mount }) => {
260 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000261 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000262
263 const timeline = await mount(SketchTimeline, {
264 props: {
265 messages,
266 initialMessageCount: 10,
267 loadChunkSize: 5,
banksean54777362025-06-19 16:38:30 +0000268 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000269 },
270 });
271
banksean54777362025-06-19 16:38:30 +0000272 // Directly set the isInitialLoadComplete state to bypass the event system for testing
273 await timeline.evaluate((element: SketchTimeline) => {
274 (element as any).isInitialLoadComplete = true;
275 element.requestUpdate();
276 return element.updateComplete;
277 });
278
Sean McCulloughb7744372025-06-19 00:01:07 +0000279 // Initially shows 10 messages
280 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
281
282 // Simulate expanding viewport by setting visibleMessageStartIndex
283 await timeline.evaluate((element: SketchTimeline) => {
284 (element as any).visibleMessageStartIndex = 5;
285 element.requestUpdate();
286 return element.updateComplete;
287 });
288
289 // Should now show 15 messages (10 initial + 5 chunk)
290 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(15);
291
292 // Should show messages 36-50
293 await expect(
294 timeline.locator("sketch-timeline-message").first(),
295 ).toContainText("Message 36");
296 await expect(
297 timeline.locator("sketch-timeline-message").last(),
298 ).toContainText("Message 50");
299});
300
301test("resetViewport method resets to most recent messages", async ({
302 mount,
303}) => {
304 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000305 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000306
307 const timeline = await mount(SketchTimeline, {
308 props: {
309 messages,
310 initialMessageCount: 10,
banksean54777362025-06-19 16:38:30 +0000311 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000312 },
313 });
314
banksean54777362025-06-19 16:38:30 +0000315 // Directly set the isInitialLoadComplete state to bypass the event system for testing
316 await timeline.evaluate((element: SketchTimeline) => {
317 (element as any).isInitialLoadComplete = true;
318 element.requestUpdate();
319 return element.updateComplete;
320 });
321
Sean McCulloughb7744372025-06-19 00:01:07 +0000322 // Expand viewport
323 await timeline.evaluate((element: SketchTimeline) => {
324 (element as any).visibleMessageStartIndex = 10;
325 element.requestUpdate();
326 return element.updateComplete;
327 });
328
329 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(20);
330
331 // Reset viewport
332 await timeline.evaluate((element: SketchTimeline) => {
333 element.resetViewport();
334 return element.updateComplete;
335 });
336
337 // Should be back to initial count
338 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
339 await expect(
340 timeline.locator("sketch-timeline-message").first(),
341 ).toContainText("Message 41");
342});
343
344// Scroll State Management Tests
345
346test("shows jump-to-latest button when not pinned to latest", async ({
347 mount,
348}) => {
349 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000350 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000351
352 const timeline = await mount(SketchTimeline, {
353 props: {
354 messages,
banksean54777362025-06-19 16:38:30 +0000355 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000356 },
357 });
358
banksean54777362025-06-19 16:38:30 +0000359 // Directly set the isInitialLoadComplete state to bypass the event system for testing
360 await timeline.evaluate((element: SketchTimeline) => {
361 (element as any).isInitialLoadComplete = true;
362 element.requestUpdate();
363 return element.updateComplete;
364 });
365
Sean McCulloughb7744372025-06-19 00:01:07 +0000366 // Initially should be pinned to latest (button hidden)
367 await expect(timeline.locator("#jump-to-latest.floating")).not.toBeVisible();
368
369 // Simulate floating state
370 await timeline.evaluate((element: SketchTimeline) => {
371 (element as any).scrollingState = "floating";
372 element.requestUpdate();
373 return element.updateComplete;
374 });
375
bankseane59a2e12025-06-28 01:38:19 +0000376 // Button should now be visible - wait longer for CSS classes to apply
377 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible({
378 timeout: 10000,
379 });
Sean McCulloughb7744372025-06-19 00:01:07 +0000380});
381
382test("jump-to-latest button calls scroll method", async ({ mount }) => {
383 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000384 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000385
386 const timeline = await mount(SketchTimeline, {
387 props: {
388 messages,
banksean54777362025-06-19 16:38:30 +0000389 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000390 },
391 });
392
banksean54777362025-06-19 16:38:30 +0000393 // Directly set the isInitialLoadComplete state to bypass the event system for testing
394 await timeline.evaluate((element: SketchTimeline) => {
395 (element as any).isInitialLoadComplete = true;
396 element.requestUpdate();
397 return element.updateComplete;
398 });
399
Sean McCulloughb7744372025-06-19 00:01:07 +0000400 // Initialize the scroll tracking flag and set to floating state to show button
401 await timeline.evaluate((element: SketchTimeline) => {
402 // Initialize tracking flag
403 (window as any).scrollCalled = false;
404
405 // Set floating state
406 (element as any).scrollingState = "floating";
407
408 // Mock the scroll method
409 (element as any).scrollToBottomWithRetry = async function () {
410 (window as any).scrollCalled = true;
411 return Promise.resolve();
412 };
413
414 element.requestUpdate();
415 return element.updateComplete;
416 });
417
bankseane59a2e12025-06-28 01:38:19 +0000418 // Verify button is visible before clicking - wait longer for CSS classes to apply
419 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible({
420 timeout: 10000,
421 });
Sean McCulloughb7744372025-06-19 00:01:07 +0000422
423 // Click the jump to latest button
424 await timeline.locator("#jump-to-latest").click();
425
426 // Check if scroll was called
427 const wasScrollCalled = await timeline.evaluate(
428 () => (window as any).scrollCalled,
429 );
430 expect(wasScrollCalled).toBe(true);
431});
432
433// Loading State Tests
434
435test("shows loading indicator when loading older messages", async ({
436 mount,
437}) => {
438 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000439 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000440
441 const timeline = await mount(SketchTimeline, {
442 props: {
443 messages,
banksean54777362025-06-19 16:38:30 +0000444 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000445 },
446 });
447
banksean54777362025-06-19 16:38:30 +0000448 // Set initial load complete first, then simulate loading older messages
Sean McCulloughb7744372025-06-19 00:01:07 +0000449 await timeline.evaluate((element: SketchTimeline) => {
banksean54777362025-06-19 16:38:30 +0000450 (element as any).isInitialLoadComplete = true;
Sean McCulloughb7744372025-06-19 00:01:07 +0000451 (element as any).isLoadingOlderMessages = true;
452 element.requestUpdate();
453 return element.updateComplete;
454 });
455
bankseane59a2e12025-06-28 01:38:19 +0000456 await expect(
457 timeline.locator("[data-testid='loading-indicator']"),
458 ).toBeVisible();
459 await expect(
460 timeline.locator("[data-testid='loading-spinner']"),
461 ).toBeVisible();
462 await expect(
463 timeline.locator("[data-testid='loading-indicator']"),
464 ).toContainText("Loading older messages...");
Sean McCulloughb7744372025-06-19 00:01:07 +0000465});
466
467test("hides loading indicator when not loading", async ({ mount }) => {
468 const messages = createMockMessages(10);
banksean54777362025-06-19 16:38:30 +0000469 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000470
471 const timeline = await mount(SketchTimeline, {
472 props: {
473 messages,
banksean54777362025-06-19 16:38:30 +0000474 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000475 },
476 });
477
banksean54777362025-06-19 16:38:30 +0000478 // Set initial load complete so no loading indicator is shown
479 await timeline.evaluate((element: SketchTimeline) => {
480 (element as any).isInitialLoadComplete = true;
481 element.requestUpdate();
482 return element.updateComplete;
483 });
484
Sean McCulloughb7744372025-06-19 00:01:07 +0000485 // Should not show loading indicator by default
bankseane59a2e12025-06-28 01:38:19 +0000486 await expect(
487 timeline.locator("[data-testid='loading-indicator']"),
488 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000489});
490
491// Memory Management and Cleanup Tests
492
493test("handles scroll container changes properly", async ({ mount }) => {
494 const messages = createMockMessages(5);
495
496 const timeline = await mount(SketchTimeline, {
497 props: {
498 messages,
499 },
500 });
501
502 // Initialize call counters in window
503 await timeline.evaluate(() => {
504 (window as any).addListenerCalls = 0;
505 (window as any).removeListenerCalls = 0;
506 });
507
508 // Set first container
509 await timeline.evaluate((element: SketchTimeline) => {
510 const mockContainer1 = {
511 addEventListener: () => {
512 (window as any).addListenerCalls =
513 ((window as any).addListenerCalls || 0) + 1;
514 },
515 removeEventListener: () => {
516 (window as any).removeListenerCalls =
517 ((window as any).removeListenerCalls || 0) + 1;
518 },
519 isConnected: true,
520 scrollTop: 0,
521 scrollHeight: 1000,
522 clientHeight: 500,
523 };
524 (element as any).scrollContainer = { value: mockContainer1 };
525 element.requestUpdate();
526 return element.updateComplete;
527 });
528
529 // Change to second container (should clean up first)
530 await timeline.evaluate((element: SketchTimeline) => {
531 const mockContainer2 = {
532 addEventListener: () => {
533 (window as any).addListenerCalls =
534 ((window as any).addListenerCalls || 0) + 1;
535 },
536 removeEventListener: () => {
537 (window as any).removeListenerCalls =
538 ((window as any).removeListenerCalls || 0) + 1;
539 },
540 isConnected: true,
541 scrollTop: 0,
542 scrollHeight: 1000,
543 clientHeight: 500,
544 };
545 (element as any).scrollContainer = { value: mockContainer2 };
546 element.requestUpdate();
547 return element.updateComplete;
548 });
549
550 // Get the call counts
551 const addListenerCalls = await timeline.evaluate(
552 () => (window as any).addListenerCalls || 0,
553 );
554 const removeListenerCalls = await timeline.evaluate(
555 () => (window as any).removeListenerCalls || 0,
556 );
557
558 // Should have called addEventListener twice and removeEventListener once
559 expect(addListenerCalls).toBeGreaterThan(0);
560 expect(removeListenerCalls).toBeGreaterThan(0);
561});
562
563test("cancels loading operations on viewport reset", async ({ mount }) => {
564 const messages = createMockMessages(50);
banksean54777362025-06-19 16:38:30 +0000565 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000566
567 const timeline = await mount(SketchTimeline, {
568 props: {
569 messages,
banksean54777362025-06-19 16:38:30 +0000570 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000571 },
572 });
573
banksean54777362025-06-19 16:38:30 +0000574 // Set initial load complete and then loading older messages state
Sean McCulloughb7744372025-06-19 00:01:07 +0000575 await timeline.evaluate((element: SketchTimeline) => {
banksean54777362025-06-19 16:38:30 +0000576 (element as any).isInitialLoadComplete = true;
Sean McCulloughb7744372025-06-19 00:01:07 +0000577 (element as any).isLoadingOlderMessages = true;
578 (element as any).loadingAbortController = new AbortController();
579 element.requestUpdate();
580 return element.updateComplete;
581 });
582
banksean54777362025-06-19 16:38:30 +0000583 // Verify loading state - should show only the "loading older messages" indicator
bankseane59a2e12025-06-28 01:38:19 +0000584 await expect(
585 timeline.locator("[data-testid='loading-indicator']"),
586 ).toContainText("Loading older messages...");
Sean McCulloughb7744372025-06-19 00:01:07 +0000587
588 // Reset viewport (should cancel loading)
589 await timeline.evaluate((element: SketchTimeline) => {
590 element.resetViewport();
591 return element.updateComplete;
592 });
593
594 // Loading should be cancelled
595 const isLoading = await timeline.evaluate(
596 (element: SketchTimeline) => (element as any).isLoadingOlderMessages,
597 );
598 expect(isLoading).toBe(false);
599
bankseane59a2e12025-06-28 01:38:19 +0000600 await expect(
601 timeline.locator("[data-testid='loading-indicator']"),
602 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000603});
604
605// Message Filtering and Ordering Tests
606
607test("displays messages in correct order (most recent at bottom)", async ({
608 mount,
609}) => {
610 const messages = [
611 createMockMessage({
612 idx: 0,
613 content: "First message",
614 timestamp: "2023-01-01T10:00:00Z",
615 }),
616 createMockMessage({
617 idx: 1,
618 content: "Second message",
619 timestamp: "2023-01-01T11:00:00Z",
620 }),
621 createMockMessage({
622 idx: 2,
623 content: "Third message",
624 timestamp: "2023-01-01T12:00:00Z",
625 }),
626 ];
banksean54777362025-06-19 16:38:30 +0000627 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000628
629 const timeline = await mount(SketchTimeline, {
630 props: {
631 messages,
banksean54777362025-06-19 16:38:30 +0000632 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000633 },
634 });
635
banksean54777362025-06-19 16:38:30 +0000636 // Directly set the isInitialLoadComplete state to bypass the event system for testing
637 await timeline.evaluate((element: SketchTimeline) => {
638 (element as any).isInitialLoadComplete = true;
639 element.requestUpdate();
640 return element.updateComplete;
641 });
642
Sean McCulloughb7744372025-06-19 00:01:07 +0000643 const messageElements = timeline.locator("sketch-timeline-message");
644
645 // Check order
646 await expect(messageElements.nth(0)).toContainText("First message");
647 await expect(messageElements.nth(1)).toContainText("Second message");
648 await expect(messageElements.nth(2)).toContainText("Third message");
649});
650
651test("handles previousMessage prop correctly for message context", async ({
652 mount,
653}) => {
654 const messages = [
655 createMockMessage({ idx: 0, content: "First message", type: "user" }),
656 createMockMessage({ idx: 1, content: "Second message", type: "agent" }),
657 createMockMessage({ idx: 2, content: "Third message", type: "user" }),
658 ];
banksean54777362025-06-19 16:38:30 +0000659 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000660
661 const timeline = await mount(SketchTimeline, {
662 props: {
663 messages,
banksean54777362025-06-19 16:38:30 +0000664 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000665 },
666 });
667
banksean54777362025-06-19 16:38:30 +0000668 // Directly set the isInitialLoadComplete state to bypass the event system for testing
669 await timeline.evaluate((element: SketchTimeline) => {
670 (element as any).isInitialLoadComplete = true;
671 element.requestUpdate();
672 return element.updateComplete;
673 });
674
Sean McCulloughb7744372025-06-19 00:01:07 +0000675 // Check that messages have the expected structure
676 // The first message should not have a previous message context
677 // The second message should have the first as previous, etc.
678
679 const messageElements = timeline.locator("sketch-timeline-message");
680 await expect(messageElements).toHaveCount(3);
681
682 // All messages should be rendered
683 await expect(messageElements.nth(0)).toContainText("First message");
684 await expect(messageElements.nth(1)).toContainText("Second message");
685 await expect(messageElements.nth(2)).toContainText("Third message");
686});
687
688// Event Handling Tests
689
690test("handles show-commit-diff events from message components", async ({
691 mount,
692}) => {
693 const messages = [
694 createMockMessage({
695 idx: 0,
696 content: "Message with commit",
697 commits: [
698 {
699 hash: "abc123def456",
700 subject: "Test commit",
701 body: "Test commit body",
702 pushed_branch: "main",
703 },
704 ],
705 }),
706 ];
707
708 const timeline = await mount(SketchTimeline, {
709 props: {
710 messages,
711 },
712 });
713
714 // Listen for the bubbled event
715 await timeline.evaluate((element) => {
716 element.addEventListener("show-commit-diff", (event: CustomEvent) => {
717 window.testEventFired = true;
718 window.testEventDetail = event.detail;
719 });
720 });
721
722 // Simulate the event being fired from a message component
723 await timeline.evaluate((element) => {
724 const event = new CustomEvent("show-commit-diff", {
725 detail: { commitHash: "abc123def456" },
726 bubbles: true,
727 composed: true,
728 });
729 element.dispatchEvent(event);
730 });
731
732 // Check that event was handled
733 const wasEventFired = await timeline.evaluate(() => window.testEventFired);
734 const detail = await timeline.evaluate(() => window.testEventDetail);
735
736 expect(wasEventFired).toBe(true);
737 expect(detail?.commitHash).toBe("abc123def456");
738});
739
740// Edge Cases and Error Handling
741
742test("handles empty filteredMessages gracefully", async ({ mount }) => {
743 // All messages are hidden - this will still render timeline structure
744 // because component only shows welcome box when messages.length === 0
745 const messages = [
746 createMockMessage({ idx: 0, content: "Hidden 1", hide_output: true }),
747 createMockMessage({ idx: 1, content: "Hidden 2", hide_output: true }),
748 ];
banksean54777362025-06-19 16:38:30 +0000749 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000750
751 const timeline = await mount(SketchTimeline, {
752 props: {
753 messages,
banksean54777362025-06-19 16:38:30 +0000754 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000755 },
756 });
757
banksean54777362025-06-19 16:38:30 +0000758 // Directly set the isInitialLoadComplete state to bypass the event system for testing
759 await timeline.evaluate((element: SketchTimeline) => {
760 (element as any).isInitialLoadComplete = true;
761 element.requestUpdate();
762 return element.updateComplete;
763 });
764
Sean McCulloughb7744372025-06-19 00:01:07 +0000765 // Should render the timeline structure but with no visible messages
766 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(0);
767
768 // Should not show welcome box when messages array has content (even if all hidden)
bankseane59a2e12025-06-28 01:38:19 +0000769 await expect(
770 timeline.locator("[data-testid='welcome-box']"),
771 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000772
773 // Should not show loading indicator
bankseane59a2e12025-06-28 01:38:19 +0000774 await expect(
775 timeline.locator("[data-testid='loading-indicator']"),
776 ).not.toBeVisible();
Sean McCulloughb7744372025-06-19 00:01:07 +0000777
778 // Timeline container exists but may not be visible due to CSS
bankseane59a2e12025-06-28 01:38:19 +0000779 await expect(
780 timeline.locator("[data-testid='timeline-container']"),
781 ).toBeAttached();
Sean McCulloughb7744372025-06-19 00:01:07 +0000782});
783
784test("handles message array updates correctly", async ({ mount }) => {
785 const initialMessages = createMockMessages(5);
banksean54777362025-06-19 16:38:30 +0000786 const mockDataManager = new MockDataManager();
Sean McCulloughb7744372025-06-19 00:01:07 +0000787
788 const timeline = await mount(SketchTimeline, {
789 props: {
790 messages: initialMessages,
banksean54777362025-06-19 16:38:30 +0000791 dataManager: mockDataManager,
Sean McCulloughb7744372025-06-19 00:01:07 +0000792 },
793 });
794
banksean54777362025-06-19 16:38:30 +0000795 // Directly set the isInitialLoadComplete state to bypass the event system for testing
796 await timeline.evaluate((element: SketchTimeline) => {
797 (element as any).isInitialLoadComplete = true;
798 element.requestUpdate();
799 return element.updateComplete;
800 });
801
Sean McCulloughb7744372025-06-19 00:01:07 +0000802 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
803
804 // Update with more messages
805 const moreMessages = createMockMessages(10);
806 await timeline.evaluate(
807 (element: SketchTimeline, newMessages: AgentMessage[]) => {
808 element.messages = newMessages;
809 element.requestUpdate();
810 return element.updateComplete;
811 },
812 moreMessages,
813 );
814
815 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
816
817 // Update with fewer messages
818 const fewerMessages = createMockMessages(3);
819 await timeline.evaluate(
820 (element: SketchTimeline, newMessages: AgentMessage[]) => {
821 element.messages = newMessages;
822 element.requestUpdate();
823 return element.updateComplete;
824 },
825 fewerMessages,
826 );
827
828 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(3);
829});
830
831test("messageKey method generates unique keys correctly", async ({ mount }) => {
832 const timeline = await mount(SketchTimeline);
833
834 const message1 = createMockMessage({ idx: 1, tool_calls: [] });
835 const message2 = createMockMessage({ idx: 2, tool_calls: [] });
836 const message3 = createMockMessage({
837 idx: 1,
838 tool_calls: [
839 {
840 tool_call_id: "call_123",
841 name: "test",
842 input: "{}",
843 result_message: createMockMessage({ idx: 99, content: "result" }),
844 },
845 ],
846 });
847
848 const key1 = await timeline.evaluate(
849 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
850 message1,
851 );
852 const key2 = await timeline.evaluate(
853 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
854 message2,
855 );
856 const key3 = await timeline.evaluate(
857 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
858 message3,
859 );
860
861 // Keys should be unique
862 expect(key1).not.toBe(key2);
863 expect(key1).not.toBe(key3);
864 expect(key2).not.toBe(key3);
865
866 // Keys should include message index
867 expect(key1).toContain("message-1");
868 expect(key2).toContain("message-2");
869 expect(key3).toContain("message-1");
870
871 // Message with tool call should have different key than without
872 expect(key1).not.toBe(key3);
873});