blob: 83844537653f71101ab69ae6efc9b2763af3b9fe [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
5// Helper function to create mock timeline messages
6function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
7 return {
8 idx: props.idx || 0,
9 type: props.type || "agent",
10 content: props.content || "Hello world",
11 timestamp: props.timestamp || "2023-05-15T12:00:00Z",
12 elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
13 end_of_turn: props.end_of_turn || false,
14 conversation_id: props.conversation_id || "conv123",
15 tool_calls: props.tool_calls || [],
16 commits: props.commits || [],
17 usage: props.usage,
18 hide_output: props.hide_output || false,
19 ...props,
20 };
21}
22
23// Extend window interface for test globals
24declare global {
25 interface Window {
26 scrollCalled?: boolean;
27 testEventFired?: boolean;
28 testEventDetail?: any;
29 }
30}
31
32// Helper function to create an array of mock messages
33function createMockMessages(count: number): AgentMessage[] {
34 return Array.from({ length: count }, (_, i) =>
35 createMockMessage({
36 idx: i,
37 content: `Message ${i + 1}`,
38 type: i % 3 === 0 ? "user" : "agent",
39 timestamp: new Date(Date.now() - (count - i) * 60000).toISOString(),
40 }),
41 );
42}
43
44test("renders empty state when no messages", async ({ mount }) => {
45 const timeline = await mount(SketchTimeline, {
46 props: {
47 messages: [],
48 },
49 });
50
51 await expect(timeline.locator(".welcome-box")).toBeVisible();
52 await expect(timeline.locator(".welcome-box-title")).toContainText(
53 "How to use Sketch",
54 );
55});
56
57test("renders messages when provided", async ({ mount }) => {
58 const messages = createMockMessages(5);
59
60 const timeline = await mount(SketchTimeline, {
61 props: {
62 messages,
63 },
64 });
65
66 await expect(timeline.locator(".timeline-container")).toBeVisible();
67 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
68});
69
70test("shows thinking indicator when agent is active", async ({ mount }) => {
71 const messages = createMockMessages(3);
72
73 const timeline = await mount(SketchTimeline, {
74 props: {
75 messages,
76 llmCalls: 1,
77 toolCalls: ["thinking"],
78 },
79 });
80
81 await expect(timeline.locator(".thinking-indicator")).toBeVisible();
82 await expect(timeline.locator(".thinking-bubble")).toBeVisible();
83 await expect(timeline.locator(".thinking-dots .dot")).toHaveCount(3);
84});
85
86test("filters out messages with hide_output flag", async ({ mount }) => {
87 const messages = [
88 createMockMessage({ idx: 0, content: "Visible message 1" }),
89 createMockMessage({ idx: 1, content: "Hidden message", hide_output: true }),
90 createMockMessage({ idx: 2, content: "Visible message 2" }),
91 ];
92
93 const timeline = await mount(SketchTimeline, {
94 props: {
95 messages,
96 },
97 });
98
99 // Should only show 2 visible messages
100 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(2);
101
102 // Verify the hidden message is not rendered by checking each visible message individually
103 const visibleMessages = timeline.locator("sketch-timeline-message");
104 await expect(visibleMessages.nth(0)).toContainText("Visible message 1");
105 await expect(visibleMessages.nth(1)).toContainText("Visible message 2");
106
107 // Check that no message contains the hidden text
108 const firstMessageText = await visibleMessages.nth(0).textContent();
109 const secondMessageText = await visibleMessages.nth(1).textContent();
110 expect(firstMessageText).not.toContain("Hidden message");
111 expect(secondMessageText).not.toContain("Hidden message");
112});
113
114// Viewport Management Tests
115
116test("limits initial message count based on initialMessageCount property", async ({
117 mount,
118}) => {
119 const messages = createMockMessages(50);
120
121 const timeline = await mount(SketchTimeline, {
122 props: {
123 messages,
124 initialMessageCount: 10,
125 },
126 });
127
128 // Should only render the most recent 10 messages initially
129 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
130
131 // Should show the most recent messages (41-50)
132 await expect(
133 timeline.locator("sketch-timeline-message").first(),
134 ).toContainText("Message 41");
135 await expect(
136 timeline.locator("sketch-timeline-message").last(),
137 ).toContainText("Message 50");
138});
139
140test("handles viewport expansion correctly", async ({ mount }) => {
141 const messages = createMockMessages(50);
142
143 const timeline = await mount(SketchTimeline, {
144 props: {
145 messages,
146 initialMessageCount: 10,
147 loadChunkSize: 5,
148 },
149 });
150
151 // Initially shows 10 messages
152 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
153
154 // Simulate expanding viewport by setting visibleMessageStartIndex
155 await timeline.evaluate((element: SketchTimeline) => {
156 (element as any).visibleMessageStartIndex = 5;
157 element.requestUpdate();
158 return element.updateComplete;
159 });
160
161 // Should now show 15 messages (10 initial + 5 chunk)
162 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(15);
163
164 // Should show messages 36-50
165 await expect(
166 timeline.locator("sketch-timeline-message").first(),
167 ).toContainText("Message 36");
168 await expect(
169 timeline.locator("sketch-timeline-message").last(),
170 ).toContainText("Message 50");
171});
172
173test("resetViewport method resets to most recent messages", async ({
174 mount,
175}) => {
176 const messages = createMockMessages(50);
177
178 const timeline = await mount(SketchTimeline, {
179 props: {
180 messages,
181 initialMessageCount: 10,
182 },
183 });
184
185 // Expand viewport
186 await timeline.evaluate((element: SketchTimeline) => {
187 (element as any).visibleMessageStartIndex = 10;
188 element.requestUpdate();
189 return element.updateComplete;
190 });
191
192 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(20);
193
194 // Reset viewport
195 await timeline.evaluate((element: SketchTimeline) => {
196 element.resetViewport();
197 return element.updateComplete;
198 });
199
200 // Should be back to initial count
201 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
202 await expect(
203 timeline.locator("sketch-timeline-message").first(),
204 ).toContainText("Message 41");
205});
206
207// Scroll State Management Tests
208
209test("shows jump-to-latest button when not pinned to latest", async ({
210 mount,
211}) => {
212 const messages = createMockMessages(10);
213
214 const timeline = await mount(SketchTimeline, {
215 props: {
216 messages,
217 },
218 });
219
220 // Initially should be pinned to latest (button hidden)
221 await expect(timeline.locator("#jump-to-latest.floating")).not.toBeVisible();
222
223 // Simulate floating state
224 await timeline.evaluate((element: SketchTimeline) => {
225 (element as any).scrollingState = "floating";
226 element.requestUpdate();
227 return element.updateComplete;
228 });
229
230 // Button should now be visible
231 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible();
232});
233
234test("jump-to-latest button calls scroll method", async ({ mount }) => {
235 const messages = createMockMessages(10);
236
237 const timeline = await mount(SketchTimeline, {
238 props: {
239 messages,
240 },
241 });
242
243 // Initialize the scroll tracking flag and set to floating state to show button
244 await timeline.evaluate((element: SketchTimeline) => {
245 // Initialize tracking flag
246 (window as any).scrollCalled = false;
247
248 // Set floating state
249 (element as any).scrollingState = "floating";
250
251 // Mock the scroll method
252 (element as any).scrollToBottomWithRetry = async function () {
253 (window as any).scrollCalled = true;
254 return Promise.resolve();
255 };
256
257 element.requestUpdate();
258 return element.updateComplete;
259 });
260
261 // Verify button is visible before clicking
262 await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible();
263
264 // Click the jump to latest button
265 await timeline.locator("#jump-to-latest").click();
266
267 // Check if scroll was called
268 const wasScrollCalled = await timeline.evaluate(
269 () => (window as any).scrollCalled,
270 );
271 expect(wasScrollCalled).toBe(true);
272});
273
274// Loading State Tests
275
276test("shows loading indicator when loading older messages", async ({
277 mount,
278}) => {
279 const messages = createMockMessages(10);
280
281 const timeline = await mount(SketchTimeline, {
282 props: {
283 messages,
284 },
285 });
286
287 // Simulate loading state
288 await timeline.evaluate((element: SketchTimeline) => {
289 (element as any).isLoadingOlderMessages = true;
290 element.requestUpdate();
291 return element.updateComplete;
292 });
293
294 await expect(timeline.locator(".loading-indicator")).toBeVisible();
295 await expect(timeline.locator(".loading-spinner")).toBeVisible();
296 await expect(timeline.locator(".loading-indicator")).toContainText(
297 "Loading older messages...",
298 );
299});
300
301test("hides loading indicator when not loading", async ({ mount }) => {
302 const messages = createMockMessages(10);
303
304 const timeline = await mount(SketchTimeline, {
305 props: {
306 messages,
307 },
308 });
309
310 // Should not show loading indicator by default
311 await expect(timeline.locator(".loading-indicator")).not.toBeVisible();
312});
313
314// Memory Management and Cleanup Tests
315
316test("handles scroll container changes properly", async ({ mount }) => {
317 const messages = createMockMessages(5);
318
319 const timeline = await mount(SketchTimeline, {
320 props: {
321 messages,
322 },
323 });
324
325 // Initialize call counters in window
326 await timeline.evaluate(() => {
327 (window as any).addListenerCalls = 0;
328 (window as any).removeListenerCalls = 0;
329 });
330
331 // Set first container
332 await timeline.evaluate((element: SketchTimeline) => {
333 const mockContainer1 = {
334 addEventListener: () => {
335 (window as any).addListenerCalls =
336 ((window as any).addListenerCalls || 0) + 1;
337 },
338 removeEventListener: () => {
339 (window as any).removeListenerCalls =
340 ((window as any).removeListenerCalls || 0) + 1;
341 },
342 isConnected: true,
343 scrollTop: 0,
344 scrollHeight: 1000,
345 clientHeight: 500,
346 };
347 (element as any).scrollContainer = { value: mockContainer1 };
348 element.requestUpdate();
349 return element.updateComplete;
350 });
351
352 // Change to second container (should clean up first)
353 await timeline.evaluate((element: SketchTimeline) => {
354 const mockContainer2 = {
355 addEventListener: () => {
356 (window as any).addListenerCalls =
357 ((window as any).addListenerCalls || 0) + 1;
358 },
359 removeEventListener: () => {
360 (window as any).removeListenerCalls =
361 ((window as any).removeListenerCalls || 0) + 1;
362 },
363 isConnected: true,
364 scrollTop: 0,
365 scrollHeight: 1000,
366 clientHeight: 500,
367 };
368 (element as any).scrollContainer = { value: mockContainer2 };
369 element.requestUpdate();
370 return element.updateComplete;
371 });
372
373 // Get the call counts
374 const addListenerCalls = await timeline.evaluate(
375 () => (window as any).addListenerCalls || 0,
376 );
377 const removeListenerCalls = await timeline.evaluate(
378 () => (window as any).removeListenerCalls || 0,
379 );
380
381 // Should have called addEventListener twice and removeEventListener once
382 expect(addListenerCalls).toBeGreaterThan(0);
383 expect(removeListenerCalls).toBeGreaterThan(0);
384});
385
386test("cancels loading operations on viewport reset", async ({ mount }) => {
387 const messages = createMockMessages(50);
388
389 const timeline = await mount(SketchTimeline, {
390 props: {
391 messages,
392 },
393 });
394
395 // Set loading state
396 await timeline.evaluate((element: SketchTimeline) => {
397 (element as any).isLoadingOlderMessages = true;
398 (element as any).loadingAbortController = new AbortController();
399 element.requestUpdate();
400 return element.updateComplete;
401 });
402
403 // Verify loading state
404 await expect(timeline.locator(".loading-indicator")).toBeVisible();
405
406 // Reset viewport (should cancel loading)
407 await timeline.evaluate((element: SketchTimeline) => {
408 element.resetViewport();
409 return element.updateComplete;
410 });
411
412 // Loading should be cancelled
413 const isLoading = await timeline.evaluate(
414 (element: SketchTimeline) => (element as any).isLoadingOlderMessages,
415 );
416 expect(isLoading).toBe(false);
417
418 await expect(timeline.locator(".loading-indicator")).not.toBeVisible();
419});
420
421// Message Filtering and Ordering Tests
422
423test("displays messages in correct order (most recent at bottom)", async ({
424 mount,
425}) => {
426 const messages = [
427 createMockMessage({
428 idx: 0,
429 content: "First message",
430 timestamp: "2023-01-01T10:00:00Z",
431 }),
432 createMockMessage({
433 idx: 1,
434 content: "Second message",
435 timestamp: "2023-01-01T11:00:00Z",
436 }),
437 createMockMessage({
438 idx: 2,
439 content: "Third message",
440 timestamp: "2023-01-01T12:00:00Z",
441 }),
442 ];
443
444 const timeline = await mount(SketchTimeline, {
445 props: {
446 messages,
447 },
448 });
449
450 const messageElements = timeline.locator("sketch-timeline-message");
451
452 // Check order
453 await expect(messageElements.nth(0)).toContainText("First message");
454 await expect(messageElements.nth(1)).toContainText("Second message");
455 await expect(messageElements.nth(2)).toContainText("Third message");
456});
457
458test("handles previousMessage prop correctly for message context", async ({
459 mount,
460}) => {
461 const messages = [
462 createMockMessage({ idx: 0, content: "First message", type: "user" }),
463 createMockMessage({ idx: 1, content: "Second message", type: "agent" }),
464 createMockMessage({ idx: 2, content: "Third message", type: "user" }),
465 ];
466
467 const timeline = await mount(SketchTimeline, {
468 props: {
469 messages,
470 },
471 });
472
473 // Check that messages have the expected structure
474 // The first message should not have a previous message context
475 // The second message should have the first as previous, etc.
476
477 const messageElements = timeline.locator("sketch-timeline-message");
478 await expect(messageElements).toHaveCount(3);
479
480 // All messages should be rendered
481 await expect(messageElements.nth(0)).toContainText("First message");
482 await expect(messageElements.nth(1)).toContainText("Second message");
483 await expect(messageElements.nth(2)).toContainText("Third message");
484});
485
486// Event Handling Tests
487
488test("handles show-commit-diff events from message components", async ({
489 mount,
490}) => {
491 const messages = [
492 createMockMessage({
493 idx: 0,
494 content: "Message with commit",
495 commits: [
496 {
497 hash: "abc123def456",
498 subject: "Test commit",
499 body: "Test commit body",
500 pushed_branch: "main",
501 },
502 ],
503 }),
504 ];
505
506 const timeline = await mount(SketchTimeline, {
507 props: {
508 messages,
509 },
510 });
511
512 // Listen for the bubbled event
513 await timeline.evaluate((element) => {
514 element.addEventListener("show-commit-diff", (event: CustomEvent) => {
515 window.testEventFired = true;
516 window.testEventDetail = event.detail;
517 });
518 });
519
520 // Simulate the event being fired from a message component
521 await timeline.evaluate((element) => {
522 const event = new CustomEvent("show-commit-diff", {
523 detail: { commitHash: "abc123def456" },
524 bubbles: true,
525 composed: true,
526 });
527 element.dispatchEvent(event);
528 });
529
530 // Check that event was handled
531 const wasEventFired = await timeline.evaluate(() => window.testEventFired);
532 const detail = await timeline.evaluate(() => window.testEventDetail);
533
534 expect(wasEventFired).toBe(true);
535 expect(detail?.commitHash).toBe("abc123def456");
536});
537
538// Edge Cases and Error Handling
539
540test("handles empty filteredMessages gracefully", async ({ mount }) => {
541 // All messages are hidden - this will still render timeline structure
542 // because component only shows welcome box when messages.length === 0
543 const messages = [
544 createMockMessage({ idx: 0, content: "Hidden 1", hide_output: true }),
545 createMockMessage({ idx: 1, content: "Hidden 2", hide_output: true }),
546 ];
547
548 const timeline = await mount(SketchTimeline, {
549 props: {
550 messages,
551 },
552 });
553
554 // Should render the timeline structure but with no visible messages
555 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(0);
556
557 // Should not show welcome box when messages array has content (even if all hidden)
558 await expect(timeline.locator(".welcome-box")).not.toBeVisible();
559
560 // Should not show loading indicator
561 await expect(timeline.locator(".loading-indicator")).not.toBeVisible();
562
563 // Timeline container exists but may not be visible due to CSS
564 await expect(timeline.locator(".timeline-container")).toBeAttached();
565});
566
567test("handles message array updates correctly", async ({ mount }) => {
568 const initialMessages = createMockMessages(5);
569
570 const timeline = await mount(SketchTimeline, {
571 props: {
572 messages: initialMessages,
573 },
574 });
575
576 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
577
578 // Update with more messages
579 const moreMessages = createMockMessages(10);
580 await timeline.evaluate(
581 (element: SketchTimeline, newMessages: AgentMessage[]) => {
582 element.messages = newMessages;
583 element.requestUpdate();
584 return element.updateComplete;
585 },
586 moreMessages,
587 );
588
589 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
590
591 // Update with fewer messages
592 const fewerMessages = createMockMessages(3);
593 await timeline.evaluate(
594 (element: SketchTimeline, newMessages: AgentMessage[]) => {
595 element.messages = newMessages;
596 element.requestUpdate();
597 return element.updateComplete;
598 },
599 fewerMessages,
600 );
601
602 await expect(timeline.locator("sketch-timeline-message")).toHaveCount(3);
603});
604
605test("messageKey method generates unique keys correctly", async ({ mount }) => {
606 const timeline = await mount(SketchTimeline);
607
608 const message1 = createMockMessage({ idx: 1, tool_calls: [] });
609 const message2 = createMockMessage({ idx: 2, tool_calls: [] });
610 const message3 = createMockMessage({
611 idx: 1,
612 tool_calls: [
613 {
614 tool_call_id: "call_123",
615 name: "test",
616 input: "{}",
617 result_message: createMockMessage({ idx: 99, content: "result" }),
618 },
619 ],
620 });
621
622 const key1 = await timeline.evaluate(
623 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
624 message1,
625 );
626 const key2 = await timeline.evaluate(
627 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
628 message2,
629 );
630 const key3 = await timeline.evaluate(
631 (element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
632 message3,
633 );
634
635 // Keys should be unique
636 expect(key1).not.toBe(key2);
637 expect(key1).not.toBe(key3);
638 expect(key2).not.toBe(key3);
639
640 // Keys should include message index
641 expect(key1).toContain("message-1");
642 expect(key2).toContain("message-2");
643 expect(key3).toContain("message-1");
644
645 // Message with tool call should have different key than without
646 expect(key1).not.toBe(key3);
647});