| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 1 | import { html, fixture, expect, oneEvent } from "@open-wc/testing"; |
| 2 | import "./sketch-timeline-message"; |
| 3 | import type { SketchTimelineMessage } from "./sketch-timeline-message"; |
| 4 | import { TimelineMessage, ToolCall, GitCommit, Usage } from "../types"; |
| 5 | |
| 6 | describe("SketchTimelineMessage", () => { |
| 7 | // Helper function to create mock timeline messages |
| 8 | function createMockMessage(props: Partial<TimelineMessage> = {}): TimelineMessage { |
| 9 | return { |
| 10 | idx: props.idx || 0, |
| 11 | type: props.type || "agent", |
| 12 | content: props.content || "Hello world", |
| 13 | timestamp: props.timestamp || "2023-05-15T12:00:00Z", |
| 14 | elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds |
| 15 | end_of_turn: props.end_of_turn || false, |
| 16 | conversation_id: props.conversation_id || "conv123", |
| 17 | tool_calls: props.tool_calls || [], |
| 18 | commits: props.commits || [], |
| 19 | usage: props.usage, |
| 20 | ...props |
| 21 | }; |
| 22 | } |
| 23 | |
| 24 | it("renders with basic message content", async () => { |
| 25 | const message = createMockMessage({ |
| 26 | type: "agent", |
| 27 | content: "This is a test message" |
| 28 | }); |
| 29 | |
| 30 | const el: SketchTimelineMessage = await fixture(html` |
| 31 | <sketch-timeline-message |
| 32 | .message=${message} |
| 33 | ></sketch-timeline-message> |
| 34 | `); |
| 35 | |
| 36 | const messageContent = el.shadowRoot!.querySelector(".message-text"); |
| 37 | expect(messageContent).to.exist; |
| 38 | expect(messageContent!.textContent!.trim()).to.include("This is a test message"); |
| 39 | }); |
| 40 | |
| 41 | it("renders with correct message type classes", async () => { |
| 42 | const messageTypes = ["user", "agent", "tool", "error"]; |
| 43 | |
| 44 | for (const type of messageTypes) { |
| 45 | const message = createMockMessage({ type }); |
| 46 | |
| 47 | const el: SketchTimelineMessage = await fixture(html` |
| 48 | <sketch-timeline-message |
| 49 | .message=${message} |
| 50 | ></sketch-timeline-message> |
| 51 | `); |
| 52 | |
| 53 | const messageElement = el.shadowRoot!.querySelector(".message"); |
| 54 | expect(messageElement).to.exist; |
| 55 | expect(messageElement!.classList.contains(type)).to.be.true; |
| 56 | } |
| 57 | }); |
| 58 | |
| 59 | it("renders end-of-turn marker correctly", async () => { |
| 60 | const message = createMockMessage({ |
| 61 | end_of_turn: true |
| 62 | }); |
| 63 | |
| 64 | const el: SketchTimelineMessage = await fixture(html` |
| 65 | <sketch-timeline-message |
| 66 | .message=${message} |
| 67 | ></sketch-timeline-message> |
| 68 | `); |
| 69 | |
| 70 | const messageElement = el.shadowRoot!.querySelector(".message"); |
| 71 | expect(messageElement).to.exist; |
| 72 | expect(messageElement!.classList.contains("end-of-turn")).to.be.true; |
| 73 | }); |
| 74 | |
| 75 | it("formats timestamps correctly", async () => { |
| 76 | const message = createMockMessage({ |
| 77 | timestamp: "2023-05-15T12:00:00Z" |
| 78 | }); |
| 79 | |
| 80 | const el: SketchTimelineMessage = await fixture(html` |
| 81 | <sketch-timeline-message |
| 82 | .message=${message} |
| 83 | ></sketch-timeline-message> |
| 84 | `); |
| 85 | |
| 86 | const timestamp = el.shadowRoot!.querySelector(".message-timestamp"); |
| 87 | expect(timestamp).to.exist; |
| 88 | // Should include a formatted date like "May 15, 2023" |
| 89 | expect(timestamp!.textContent).to.include("May 15, 2023"); |
| 90 | // Should include elapsed time |
| 91 | expect(timestamp!.textContent).to.include("(1.50s)"); |
| 92 | }); |
| 93 | |
| 94 | it("renders markdown content correctly", async () => { |
| 95 | const markdownContent = "# Heading\n\n- List item 1\n- List item 2\n\n`code block`"; |
| 96 | const message = createMockMessage({ |
| 97 | content: markdownContent |
| 98 | }); |
| 99 | |
| 100 | const el: SketchTimelineMessage = await fixture(html` |
| 101 | <sketch-timeline-message |
| 102 | .message=${message} |
| 103 | ></sketch-timeline-message> |
| 104 | `); |
| 105 | |
| 106 | const contentElement = el.shadowRoot!.querySelector(".markdown-content"); |
| 107 | expect(contentElement).to.exist; |
| 108 | expect(contentElement!.innerHTML).to.include("<h1>Heading</h1>"); |
| 109 | expect(contentElement!.innerHTML).to.include("<ul>"); |
| 110 | expect(contentElement!.innerHTML).to.include("<li>List item 1</li>"); |
| 111 | expect(contentElement!.innerHTML).to.include("<code>code block</code>"); |
| 112 | }); |
| 113 | |
| 114 | it("displays usage information when available", async () => { |
| 115 | const usage: Usage = { |
| 116 | input_tokens: 150, |
| 117 | output_tokens: 300, |
| 118 | cost_usd: 0.025, |
| 119 | cache_read_input_tokens: 50 |
| 120 | }; |
| 121 | |
| 122 | const message = createMockMessage({ |
| 123 | usage |
| 124 | }); |
| 125 | |
| 126 | const el: SketchTimelineMessage = await fixture(html` |
| 127 | <sketch-timeline-message |
| 128 | .message=${message} |
| 129 | ></sketch-timeline-message> |
| 130 | `); |
| 131 | |
| 132 | const usageElement = el.shadowRoot!.querySelector(".message-usage"); |
| 133 | expect(usageElement).to.exist; |
| 134 | expect(usageElement!.textContent).to.include("In: 150"); |
| 135 | expect(usageElement!.textContent).to.include("Out: 300"); |
| 136 | expect(usageElement!.textContent).to.include("Cache: 50"); |
| 137 | expect(usageElement!.textContent).to.include("$0.03"); |
| 138 | }); |
| 139 | |
| 140 | it("renders commit information correctly", async () => { |
| 141 | const commits: GitCommit[] = [ |
| 142 | { |
| 143 | hash: "1234567890abcdef", |
| 144 | subject: "Fix bug in application", |
| 145 | body: "This fixes a major bug in the application\n\nSigned-off-by: Developer", |
| 146 | pushed_branch: "main" |
| 147 | } |
| 148 | ]; |
| 149 | |
| 150 | const message = createMockMessage({ |
| 151 | commits |
| 152 | }); |
| 153 | |
| 154 | const el: SketchTimelineMessage = await fixture(html` |
| 155 | <sketch-timeline-message |
| 156 | .message=${message} |
| 157 | ></sketch-timeline-message> |
| 158 | `); |
| 159 | |
| 160 | const commitsContainer = el.shadowRoot!.querySelector(".commits-container"); |
| 161 | expect(commitsContainer).to.exist; |
| 162 | |
| 163 | const commitHeader = commitsContainer!.querySelector(".commits-header"); |
| 164 | expect(commitHeader).to.exist; |
| 165 | expect(commitHeader!.textContent).to.include("1 new commit"); |
| 166 | |
| 167 | const commitHash = commitsContainer!.querySelector(".commit-hash"); |
| 168 | expect(commitHash).to.exist; |
| 169 | expect(commitHash!.textContent).to.equal("12345678"); // First 8 chars |
| 170 | |
| 171 | const pushedBranch = commitsContainer!.querySelector(".pushed-branch"); |
| 172 | expect(pushedBranch).to.exist; |
| 173 | expect(pushedBranch!.textContent).to.include("main"); |
| 174 | }); |
| 175 | |
| 176 | it("dispatches show-commit-diff event when commit diff button is clicked", async () => { |
| 177 | const commits: GitCommit[] = [ |
| 178 | { |
| 179 | hash: "1234567890abcdef", |
| 180 | subject: "Fix bug in application", |
| 181 | body: "This fixes a major bug in the application", |
| 182 | pushed_branch: "main" |
| 183 | } |
| 184 | ]; |
| 185 | |
| 186 | const message = createMockMessage({ |
| 187 | commits |
| 188 | }); |
| 189 | |
| 190 | const el: SketchTimelineMessage = await fixture(html` |
| 191 | <sketch-timeline-message |
| 192 | .message=${message} |
| 193 | ></sketch-timeline-message> |
| 194 | `); |
| 195 | |
| 196 | const diffButton = el.shadowRoot!.querySelector(".commit-diff-button") as HTMLButtonElement; |
| 197 | expect(diffButton).to.exist; |
| 198 | |
| 199 | // Set up listener for the event |
| 200 | setTimeout(() => diffButton!.click()); |
| 201 | const { detail } = await oneEvent(el, "show-commit-diff"); |
| 202 | |
| 203 | expect(detail).to.exist; |
| 204 | expect(detail.commitHash).to.equal("1234567890abcdef"); |
| 205 | }); |
| 206 | |
| 207 | it("handles message type icon display correctly", async () => { |
| 208 | // First message of a type should show icon |
| 209 | const firstMessage = createMockMessage({ |
| 210 | type: "user", |
| 211 | idx: 0 |
| 212 | }); |
| 213 | |
| 214 | // Second message of same type should not show icon |
| 215 | const secondMessage = createMockMessage({ |
| 216 | type: "user", |
| 217 | idx: 1 |
| 218 | }); |
| 219 | |
| 220 | // Test first message (should show icon) |
| 221 | const firstEl: SketchTimelineMessage = await fixture(html` |
| 222 | <sketch-timeline-message |
| 223 | .message=${firstMessage} |
| 224 | ></sketch-timeline-message> |
| 225 | `); |
| 226 | |
| 227 | const firstIcon = firstEl.shadowRoot!.querySelector(".message-icon"); |
| 228 | expect(firstIcon).to.exist; |
| 229 | expect(firstIcon!.textContent!.trim()).to.equal("U"); |
| 230 | |
| 231 | // Test second message with previous message of same type |
| 232 | const secondEl: SketchTimelineMessage = await fixture(html` |
| 233 | <sketch-timeline-message |
| 234 | .message=${secondMessage} |
| 235 | .previousMessage=${firstMessage} |
| 236 | ></sketch-timeline-message> |
| 237 | `); |
| 238 | |
| 239 | const secondIcon = secondEl.shadowRoot!.querySelector(".message-icon"); |
| 240 | expect(secondIcon).to.not.exist; |
| 241 | }); |
| 242 | |
| 243 | it("formats numbers correctly", async () => { |
| 244 | const el: SketchTimelineMessage = await fixture(html` |
| 245 | <sketch-timeline-message></sketch-timeline-message> |
| 246 | `); |
| 247 | |
| 248 | // Test accessing private method via the component instance |
| 249 | expect(el.formatNumber(1000)).to.equal("1,000"); |
| 250 | expect(el.formatNumber(null, "N/A")).to.equal("N/A"); |
| 251 | expect(el.formatNumber(undefined, "--")).to.equal("--"); |
| 252 | }); |
| 253 | |
| 254 | it("formats currency values correctly", async () => { |
| 255 | const el: SketchTimelineMessage = await fixture(html` |
| 256 | <sketch-timeline-message></sketch-timeline-message> |
| 257 | `); |
| 258 | |
| 259 | // Test with different precisions |
| 260 | expect(el.formatCurrency(10.12345, "$0.00", true)).to.equal("$10.1235"); // message level (4 decimals) |
| 261 | expect(el.formatCurrency(10.12345, "$0.00", false)).to.equal("$10.12"); // total level (2 decimals) |
| 262 | expect(el.formatCurrency(null, "N/A")).to.equal("N/A"); |
| 263 | expect(el.formatCurrency(undefined, "--")).to.equal("--"); |
| 264 | }); |
| 265 | }); |