blob: 9764eae4fab4df3cc6e1c9e1473e0d9b0ce4813e [file] [log] [blame]
Sean McCulloughb29f8912025-04-20 15:39:11 -07001import { test, expect } from "@sand4rt/experimental-ct-web";
2import { SketchTimelineMessage } from "./sketch-timeline-message";
Sean McCulloughd9f13372025-04-21 15:08:49 -07003import {
4 AgentMessage,
5 CodingAgentMessageType,
6 GitCommit,
7 Usage,
8} from "../types";
Sean McCullough86b56862025-04-18 13:04:03 -07009
Sean McCulloughb29f8912025-04-20 15:39:11 -070010// Helper function to create mock timeline messages
Sean McCulloughd9f13372025-04-21 15:08:49 -070011function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
Sean McCulloughb29f8912025-04-20 15:39:11 -070012 return {
13 idx: props.idx || 0,
14 type: props.type || "agent",
15 content: props.content || "Hello world",
16 timestamp: props.timestamp || "2023-05-15T12:00:00Z",
17 elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
18 end_of_turn: props.end_of_turn || false,
19 conversation_id: props.conversation_id || "conv123",
20 tool_calls: props.tool_calls || [],
21 commits: props.commits || [],
22 usage: props.usage,
23 ...props,
24 };
25}
26
27test("renders with basic message content", async ({ mount }) => {
28 const message = createMockMessage({
29 type: "agent",
30 content: "This is a test message",
31 });
32
33 const component = await mount(SketchTimelineMessage, {
34 props: {
35 message: message,
36 },
37 });
38
39 await expect(component.locator(".message-text")).toBeVisible();
40 await expect(component.locator(".message-text")).toContainText(
41 "This is a test message",
42 );
43});
44
45test.skip("renders with correct message type classes", async ({ mount }) => {
Sean McCulloughd9f13372025-04-21 15:08:49 -070046 const messageTypes: CodingAgentMessageType[] = [
47 "user",
48 "agent",
49 "error",
50 "budget",
51 "tool",
52 "commit",
53 "auto",
54 ];
Sean McCulloughb29f8912025-04-20 15:39:11 -070055
56 for (const type of messageTypes) {
57 const message = createMockMessage({ type });
58
59 const component = await mount(SketchTimelineMessage, {
60 props: {
61 message: message,
62 },
63 });
64
65 await expect(component.locator(".message")).toBeVisible();
66 await expect(component.locator(`.message.${type}`)).toBeVisible();
Sean McCullough86b56862025-04-18 13:04:03 -070067 }
Sean McCulloughb29f8912025-04-20 15:39:11 -070068});
Sean McCullough86b56862025-04-18 13:04:03 -070069
Sean McCulloughb29f8912025-04-20 15:39:11 -070070test("renders end-of-turn marker correctly", async ({ mount }) => {
71 const message = createMockMessage({
72 end_of_turn: true,
73 });
74
75 const component = await mount(SketchTimelineMessage, {
76 props: {
77 message: message,
78 },
79 });
80
81 await expect(component.locator(".message")).toBeVisible();
82 await expect(component.locator(".message.end-of-turn")).toBeVisible();
83});
84
85test("formats timestamps correctly", async ({ mount }) => {
86 const message = createMockMessage({
87 timestamp: "2023-05-15T12:00:00Z",
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000088 type: "agent",
Sean McCulloughb29f8912025-04-20 15:39:11 -070089 });
90
91 const component = await mount(SketchTimelineMessage, {
92 props: {
93 message: message,
94 },
95 });
96
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000097 // Toggle the info panel to view timestamps
98 await component.locator(".info-icon").click();
99 await expect(component.locator(".message-info-panel")).toBeVisible();
100
101 // Find the timestamp in the info panel
102 const timeInfoRow = component.locator(".info-row", { hasText: "Time:" });
103 await expect(timeInfoRow).toBeVisible();
104 await expect(timeInfoRow.locator(".info-value")).toContainText(
Sean McCulloughb29f8912025-04-20 15:39:11 -0700105 "May 15, 2023",
106 );
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000107 // For end-of-turn messages, duration is shown separately
108 const endOfTurnMessage = createMockMessage({
109 timestamp: "2023-05-15T12:00:00Z",
110 type: "agent",
111 end_of_turn: true,
112 });
113
114 const endOfTurnComponent = await mount(SketchTimelineMessage, {
115 props: {
116 message: endOfTurnMessage,
117 },
118 });
119
120 // For end-of-turn messages, duration is shown in the end-of-turn indicator
121 await expect(
122 endOfTurnComponent.locator(".end-of-turn-indicator"),
123 ).toBeVisible();
124 await expect(
125 endOfTurnComponent.locator(".end-of-turn-indicator"),
126 ).toContainText("1.5s");
Sean McCulloughb29f8912025-04-20 15:39:11 -0700127});
128
129test("renders markdown content correctly", async ({ mount }) => {
130 const markdownContent =
131 "# Heading\n\n- List item 1\n- List item 2\n\n`code block`";
132 const message = createMockMessage({
133 content: markdownContent,
134 });
135
136 const component = await mount(SketchTimelineMessage, {
137 props: {
138 message: message,
139 },
140 });
141
142 await expect(component.locator(".markdown-content")).toBeVisible();
143
144 // Check HTML content
145 const html = await component
146 .locator(".markdown-content")
147 .evaluate((element) => element.innerHTML);
148 expect(html).toContain("<h1>Heading</h1>");
149 expect(html).toContain("<ul>");
150 expect(html).toContain("<li>List item 1</li>");
151 expect(html).toContain("<code>code block</code>");
152});
153
154test("displays usage information when available", async ({ mount }) => {
155 const usage: Usage = {
156 input_tokens: 150,
157 output_tokens: 300,
158 cost_usd: 0.025,
159 cache_read_input_tokens: 50,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700160 cache_creation_input_tokens: 0,
Sean McCulloughb29f8912025-04-20 15:39:11 -0700161 };
162
163 const message = createMockMessage({
164 usage,
165 });
166
167 const component = await mount(SketchTimelineMessage, {
168 props: {
169 message: message,
170 },
171 });
172
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000173 // Toggle the info panel to view usage information
174 await component.locator(".info-icon").click();
175 await expect(component.locator(".message-info-panel")).toBeVisible();
176
177 // Find the tokens info in the info panel
178 const tokensInfoRow = component.locator(".info-row", { hasText: "Tokens:" });
179 await expect(tokensInfoRow).toBeVisible();
180 await expect(tokensInfoRow).toContainText("Input: " + "150".toLocaleString());
181 await expect(tokensInfoRow).toContainText(
182 "Cache read: " + "50".toLocaleString(),
183 );
184 // Check for output tokens
185 await expect(tokensInfoRow).toContainText(
186 "Output: " + "300".toLocaleString(),
187 );
188
189 // Check for cost
190 await expect(tokensInfoRow).toContainText("Cost: $0.03");
Sean McCulloughb29f8912025-04-20 15:39:11 -0700191});
192
193test("renders commit information correctly", async ({ mount }) => {
194 const commits: GitCommit[] = [
195 {
196 hash: "1234567890abcdef",
197 subject: "Fix bug in application",
198 body: "This fixes a major bug in the application\n\nSigned-off-by: Developer",
199 pushed_branch: "main",
200 },
201 ];
202
203 const message = createMockMessage({
204 commits,
205 });
206
207 const component = await mount(SketchTimelineMessage, {
208 props: {
209 message: message,
210 },
211 });
212
213 await expect(component.locator(".commits-container")).toBeVisible();
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000214 await expect(component.locator(".commit-notification")).toBeVisible();
215 await expect(component.locator(".commit-notification")).toContainText(
216 "1 new",
217 );
Sean McCulloughb29f8912025-04-20 15:39:11 -0700218
219 await expect(component.locator(".commit-hash")).toBeVisible();
220 await expect(component.locator(".commit-hash")).toHaveText("12345678"); // First 8 chars
221
222 await expect(component.locator(".pushed-branch")).toBeVisible();
223 await expect(component.locator(".pushed-branch")).toContainText("main");
224});
225
226test("dispatches show-commit-diff event when commit diff button is clicked", async ({
227 mount,
228}) => {
229 const commits: GitCommit[] = [
230 {
231 hash: "1234567890abcdef",
232 subject: "Fix bug in application",
233 body: "This fixes a major bug in the application",
234 pushed_branch: "main",
235 },
236 ];
237
238 const message = createMockMessage({
239 commits,
240 });
241
242 const component = await mount(SketchTimelineMessage, {
243 props: {
244 message: message,
245 },
246 });
247
248 await expect(component.locator(".commit-diff-button")).toBeVisible();
249
250 // Set up promise to wait for the event
251 const eventPromise = component.evaluate((el) => {
252 return new Promise((resolve) => {
253 el.addEventListener(
254 "show-commit-diff",
255 (event) => {
256 resolve((event as CustomEvent).detail);
257 },
258 { once: true },
259 );
Sean McCullough86b56862025-04-18 13:04:03 -0700260 });
Sean McCullough86b56862025-04-18 13:04:03 -0700261 });
262
Sean McCulloughb29f8912025-04-20 15:39:11 -0700263 // Click the diff button
264 await component.locator(".commit-diff-button").click();
Sean McCullough71941bd2025-04-18 13:31:48 -0700265
Sean McCulloughb29f8912025-04-20 15:39:11 -0700266 // Wait for the event and check its details
267 const detail = await eventPromise;
268 expect(detail["commitHash"]).toBe("1234567890abcdef");
269});
Sean McCullough71941bd2025-04-18 13:31:48 -0700270
Sean McCulloughb29f8912025-04-20 15:39:11 -0700271test.skip("handles message type icon display correctly", async ({ mount }) => {
272 // First message of a type should show icon
273 const firstMessage = createMockMessage({
274 type: "user",
275 idx: 0,
Sean McCullough86b56862025-04-18 13:04:03 -0700276 });
277
Sean McCulloughb29f8912025-04-20 15:39:11 -0700278 // Second message of same type should not show icon
279 const secondMessage = createMockMessage({
280 type: "user",
281 idx: 1,
Sean McCullough86b56862025-04-18 13:04:03 -0700282 });
283
Sean McCulloughb29f8912025-04-20 15:39:11 -0700284 // Test first message (should show icon)
285 const firstComponent = await mount(SketchTimelineMessage, {
286 props: {
287 message: firstMessage,
288 },
Sean McCullough86b56862025-04-18 13:04:03 -0700289 });
290
Sean McCulloughb29f8912025-04-20 15:39:11 -0700291 await expect(firstComponent.locator(".message-icon")).toBeVisible();
292 await expect(firstComponent.locator(".message-icon")).toHaveText("U");
Sean McCullough86b56862025-04-18 13:04:03 -0700293
Sean McCulloughb29f8912025-04-20 15:39:11 -0700294 // Test second message with previous message of same type
295 const secondComponent = await mount(SketchTimelineMessage, {
296 props: {
297 message: secondMessage,
298 previousMessage: firstMessage,
299 },
Sean McCullough86b56862025-04-18 13:04:03 -0700300 });
301
Sean McCulloughb29f8912025-04-20 15:39:11 -0700302 await expect(secondComponent.locator(".message-icon")).not.toBeVisible();
303});
Sean McCullough71941bd2025-04-18 13:31:48 -0700304
Sean McCulloughb29f8912025-04-20 15:39:11 -0700305test("formats numbers correctly", async ({ mount }) => {
306 const component = await mount(SketchTimelineMessage, {});
Sean McCullough86b56862025-04-18 13:04:03 -0700307
Sean McCulloughb29f8912025-04-20 15:39:11 -0700308 // Test accessing public method via evaluate
309 const result1 = await component.evaluate((el: SketchTimelineMessage) =>
310 el.formatNumber(1000),
311 );
312 expect(result1).toBe("1,000");
Sean McCullough86b56862025-04-18 13:04:03 -0700313
Sean McCulloughb29f8912025-04-20 15:39:11 -0700314 const result2 = await component.evaluate((el: SketchTimelineMessage) =>
315 el.formatNumber(null, "N/A"),
316 );
317 expect(result2).toBe("N/A");
Sean McCullough86b56862025-04-18 13:04:03 -0700318
Sean McCulloughb29f8912025-04-20 15:39:11 -0700319 const result3 = await component.evaluate((el: SketchTimelineMessage) =>
320 el.formatNumber(undefined, "--"),
321 );
322 expect(result3).toBe("--");
323});
Sean McCullough71941bd2025-04-18 13:31:48 -0700324
Sean McCulloughb29f8912025-04-20 15:39:11 -0700325test("formats currency values correctly", async ({ mount }) => {
326 const component = await mount(SketchTimelineMessage, {});
Sean McCullough86b56862025-04-18 13:04:03 -0700327
Sean McCulloughb29f8912025-04-20 15:39:11 -0700328 // Test with different precisions
329 const result1 = await component.evaluate((el: SketchTimelineMessage) =>
330 el.formatCurrency(10.12345, "$0.00", true),
331 );
332 expect(result1).toBe("$10.1235"); // message level (4 decimals)
Sean McCullough86b56862025-04-18 13:04:03 -0700333
Sean McCulloughb29f8912025-04-20 15:39:11 -0700334 const result2 = await component.evaluate((el: SketchTimelineMessage) =>
335 el.formatCurrency(10.12345, "$0.00", false),
336 );
337 expect(result2).toBe("$10.12"); // total level (2 decimals)
Sean McCullough71941bd2025-04-18 13:31:48 -0700338
Sean McCulloughb29f8912025-04-20 15:39:11 -0700339 const result3 = await component.evaluate((el: SketchTimelineMessage) =>
340 el.formatCurrency(null, "N/A"),
341 );
342 expect(result3).toBe("N/A");
Sean McCullough71941bd2025-04-18 13:31:48 -0700343
Sean McCulloughb29f8912025-04-20 15:39:11 -0700344 const result4 = await component.evaluate((el: SketchTimelineMessage) =>
345 el.formatCurrency(undefined, "--"),
346 );
347 expect(result4).toBe("--");
Sean McCullough86b56862025-04-18 13:04:03 -0700348});
Philip Zeyliger0d092842025-06-09 18:57:12 -0700349
350test("properly escapes HTML in code blocks", async ({ mount }) => {
351 const maliciousContent = `Here's some HTML that should be escaped:
352
353\`\`\`html
354<script>alert('XSS!');</script>
355<div onclick="alert('Click attack')">Click me</div>
356<img src="x" onerror="alert('Image attack')">
357\`\`\`
358
359The HTML above should be escaped and not executable.`;
360
361 const message = createMockMessage({
362 content: maliciousContent,
363 });
364
365 const component = await mount(SketchTimelineMessage, {
366 props: {
367 message: message,
368 },
369 });
370
371 await expect(component.locator(".markdown-content")).toBeVisible();
372
373 // Check that the code block is rendered with proper HTML escaping
374 const codeElement = component.locator(".code-block-container code");
375 await expect(codeElement).toBeVisible();
376
377 // Get the text content (not innerHTML) to verify escaping
378 const codeText = await codeElement.textContent();
379 expect(codeText).toContain("<script>alert('XSS!');</script>");
380 expect(codeText).toContain("<div onclick=\"alert('Click attack')\">");
381 expect(codeText).toContain('<img src="x" onerror="alert(\'Image attack\')">');
382
383 // Verify that the HTML is actually escaped in the DOM
384 const codeHtml = await codeElement.innerHTML();
385 expect(codeHtml).toContain("&lt;script&gt;"); // < should be escaped
386 expect(codeHtml).toContain("&lt;div"); // < should be escaped
387 expect(codeHtml).toContain("&lt;img"); // < should be escaped
388 expect(codeHtml).not.toContain("<script>"); // Actual script tags should not exist
389 expect(codeHtml).not.toContain("<div onclick"); // Actual event handlers should not exist
390});
391
392test("properly escapes JavaScript in code blocks", async ({ mount }) => {
393 const maliciousContent = `Here's some JavaScript that should be escaped:
394
395\`\`\`javascript
396function malicious() {
397 document.body.innerHTML = '<h1>Hacked!</h1>';
398 window.location = 'http://evil.com';
399}
400malicious();
401\`\`\`
402
403The JavaScript above should be escaped and not executed.`;
404
405 const message = createMockMessage({
406 content: maliciousContent,
407 });
408
409 const component = await mount(SketchTimelineMessage, {
410 props: {
411 message: message,
412 },
413 });
414
415 await expect(component.locator(".markdown-content")).toBeVisible();
416
417 // Check that the code block is rendered with proper HTML escaping
418 const codeElement = component.locator(".code-block-container code");
419 await expect(codeElement).toBeVisible();
420
421 // Get the text content to verify the JavaScript is preserved as text
422 const codeText = await codeElement.textContent();
423 expect(codeText).toContain("function malicious()");
424 expect(codeText).toContain("document.body.innerHTML");
425 expect(codeText).toContain("window.location");
426
427 // Verify that any HTML-like content is escaped
428 const codeHtml = await codeElement.innerHTML();
429 expect(codeHtml).toContain("&lt;h1&gt;Hacked!&lt;/h1&gt;"); // HTML should be escaped
430});
431
432test("mermaid diagrams still render correctly", async ({ mount }) => {
433 const diagramContent = `Here's a mermaid diagram:
434
435\`\`\`mermaid
436graph TD
437 A[Start] --> B{Decision}
438 B -->|Yes| C[Do Something]
439 B -->|No| D[Do Something Else]
440 C --> E[End]
441 D --> E
442\`\`\`
443
444The diagram above should render as a visual chart.`;
445
446 const message = createMockMessage({
447 content: diagramContent,
448 });
449
450 const component = await mount(SketchTimelineMessage, {
451 props: {
452 message: message,
453 },
454 });
455
456 await expect(component.locator(".markdown-content")).toBeVisible();
457
458 // Check that the mermaid container is present
459 const mermaidContainer = component.locator(".mermaid-container");
460 await expect(mermaidContainer).toBeVisible();
461
462 // Check that the mermaid div exists with the right content
463 const mermaidDiv = component.locator(".mermaid");
464 await expect(mermaidDiv).toBeVisible();
465
466 // Wait a bit for mermaid to potentially render
467 await new Promise((resolve) => setTimeout(resolve, 500));
468
469 // The mermaid content should either be the original code or rendered SVG
470 const renderedContent = await mermaidDiv.innerHTML();
471 // It should contain either the graph definition or SVG
472 const hasMermaidCode = renderedContent.includes("graph TD");
473 const hasSvg = renderedContent.includes("<svg");
474 expect(hasMermaidCode || hasSvg).toBe(true);
475});