Message.test.tsx (12009B)
1 import { describe, it, expect, vi } from "vitest"; 2 import { render, screen, fireEvent } from "@testing-library/react"; 3 import { Message } from "./Message"; 4 import type { Message as MessageType } from "../../store/chat"; 5 6 const userMsg: MessageType = { 7 id: "m1", 8 role: "user", 9 content: "Hello world", 10 toolCalls: [], 11 timestamp: Date.now(), 12 }; 13 14 const assistantMsg: MessageType = { 15 id: "m2", 16 role: "assistant", 17 content: "Hi there!", 18 toolCalls: [], 19 timestamp: Date.now(), 20 }; 21 22 describe("Message", () => { 23 it("renders user message with right alignment", () => { 24 const { container } = render(<Message message={userMsg} />); 25 expect(screen.getByText("Hello world")).toBeInTheDocument(); 26 expect(container.querySelector(".justify-end")).toBeInTheDocument(); 27 }); 28 29 it("renders assistant message with left alignment", () => { 30 const { container } = render(<Message message={assistantMsg} />); 31 expect(screen.getByText("Hi there!")).toBeInTheDocument(); 32 expect(container.querySelector(".justify-start")).toBeInTheDocument(); 33 }); 34 35 it("renders model name when provided", () => { 36 const msg = { ...assistantMsg, model: "gpt-4" }; 37 render(<Message message={msg} />); 38 expect(screen.getByText("gpt-4")).toBeInTheDocument(); 39 }); 40 41 it("does not render model name when not provided", () => { 42 render(<Message message={assistantMsg} />); 43 expect(screen.queryByText("gpt-4")).not.toBeInTheDocument(); 44 }); 45 46 it("renders user message content as paragraph", () => { 47 render(<Message message={userMsg} />); 48 const p = screen.getByText("Hello world"); 49 expect(p.tagName).toBe("P"); 50 }); 51 52 it("does not render content when content is empty", () => { 53 const msg = { ...assistantMsg, content: "" }; 54 render(<Message message={msg} />); 55 // Empty content should not render the content div 56 expect(screen.queryByText("Hi there!")).not.toBeInTheDocument(); 57 }); 58 }); 59 60 describe("Message edit", () => { 61 it("shows edit button on hover for user messages", () => { 62 render(<Message message={userMsg} onEdit={vi.fn()} onRegenerate={vi.fn()} />); 63 expect(screen.getByLabelText("Edit message")).toBeInTheDocument(); 64 }); 65 66 it("does not show edit button for assistant messages", () => { 67 render(<Message message={assistantMsg} onEdit={vi.fn()} onRegenerate={vi.fn()} />); 68 expect(screen.queryByLabelText("Edit message")).not.toBeInTheDocument(); 69 }); 70 71 it("clicking edit shows inline editor with current content", () => { 72 render(<Message message={userMsg} onEdit={vi.fn()} onRegenerate={vi.fn()} />); 73 fireEvent.click(screen.getByLabelText("Edit message")); 74 const textarea = screen.getByDisplayValue("Hello world") as HTMLTextAreaElement; 75 expect(textarea).toBeInTheDocument(); 76 }); 77 78 it("saving edit calls onEdit with new content", () => { 79 const onEdit = vi.fn(); 80 render(<Message message={userMsg} onEdit={onEdit} onRegenerate={vi.fn()} />); 81 fireEvent.click(screen.getByLabelText("Edit message")); 82 const textarea = screen.getByDisplayValue("Hello world"); 83 fireEvent.change(textarea, { target: { value: "Updated message" } }); 84 fireEvent.click(screen.getByLabelText("Save edit")); 85 expect(onEdit).toHaveBeenCalledWith("m1", "Updated message"); 86 }); 87 88 it("pressing Escape cancels edit", () => { 89 render(<Message message={userMsg} onEdit={vi.fn()} onRegenerate={vi.fn()} />); 90 fireEvent.click(screen.getByLabelText("Edit message")); 91 fireEvent.keyDown(screen.getByDisplayValue("Hello world"), { key: "Escape" }); 92 expect(screen.getByText("Hello world")).toBeInTheDocument(); 93 expect(screen.queryByDisplayValue("Hello world")).not.toBeInTheDocument(); 94 }); 95 96 it("pressing Ctrl+Enter saves edit", () => { 97 const onEdit = vi.fn(); 98 render(<Message message={userMsg} onEdit={onEdit} />); 99 fireEvent.click(screen.getByLabelText("Edit message")); 100 fireEvent.keyDown(screen.getByDisplayValue("Hello world"), { 101 key: "Enter", 102 ctrlKey: true, 103 }); 104 expect(onEdit).toHaveBeenCalledWith("m1", "Hello world"); 105 }); 106 107 it("pressing Meta+Enter saves edit", () => { 108 const onEdit = vi.fn(); 109 render(<Message message={userMsg} onEdit={onEdit} />); 110 fireEvent.click(screen.getByLabelText("Edit message")); 111 fireEvent.keyDown(screen.getByDisplayValue("Hello world"), { 112 key: "Enter", 113 metaKey: true, 114 }); 115 expect(onEdit).toHaveBeenCalledWith("m1", "Hello world"); 116 }); 117 118 it("Cancel button resets content and exits edit mode", () => { 119 render(<Message message={userMsg} onEdit={vi.fn()} />); 120 fireEvent.click(screen.getByLabelText("Edit message")); 121 const textarea = screen.getByDisplayValue("Hello world"); 122 fireEvent.change(textarea, { target: { value: "Changed" } }); 123 fireEvent.click(screen.getByText("Cancel")); 124 // Should show original content again 125 expect(screen.getByText("Hello world")).toBeInTheDocument(); 126 expect(screen.queryByDisplayValue("Changed")).not.toBeInTheDocument(); 127 }); 128 129 it("does not call onEdit when content is empty/whitespace", () => { 130 const onEdit = vi.fn(); 131 render(<Message message={userMsg} onEdit={onEdit} />); 132 fireEvent.click(screen.getByLabelText("Edit message")); 133 const textarea = screen.getByDisplayValue("Hello world"); 134 fireEvent.change(textarea, { target: { value: " " } }); 135 fireEvent.click(screen.getByLabelText("Save edit")); 136 expect(onEdit).not.toHaveBeenCalled(); 137 }); 138 139 it("does not render edit button when onEdit is not provided", () => { 140 render(<Message message={userMsg} />); 141 expect(screen.queryByLabelText("Edit message")).not.toBeInTheDocument(); 142 }); 143 }); 144 145 describe("Message regenerate", () => { 146 it("shows regenerate button on hover for assistant messages", () => { 147 render(<Message message={assistantMsg} onEdit={vi.fn()} onRegenerate={vi.fn()} />); 148 expect(screen.getByLabelText("Regenerate response")).toBeInTheDocument(); 149 }); 150 151 it("does not show regenerate button for user messages", () => { 152 render(<Message message={userMsg} onEdit={vi.fn()} onRegenerate={vi.fn()} />); 153 expect(screen.queryByLabelText("Regenerate response")).not.toBeInTheDocument(); 154 }); 155 156 it("clicking regenerate calls onRegenerate with message id", () => { 157 const onRegenerate = vi.fn(); 158 render(<Message message={assistantMsg} onEdit={vi.fn()} onRegenerate={onRegenerate} />); 159 fireEvent.click(screen.getByLabelText("Regenerate response")); 160 expect(onRegenerate).toHaveBeenCalledWith("m2"); 161 }); 162 163 it("does not render regenerate button when onRegenerate is not provided", () => { 164 render(<Message message={assistantMsg} onEdit={vi.fn()} />); 165 expect(screen.queryByLabelText("Regenerate response")).not.toBeInTheDocument(); 166 }); 167 }); 168 169 describe("Message original link", () => { 170 it("shows 'View original' link when forkId is present", () => { 171 const editedMsg = { ...userMsg, forkId: "fork-1", originalContent: "Original text" }; 172 render(<Message message={editedMsg} onEdit={vi.fn()} />); 173 expect(screen.getByText("View original")).toBeInTheDocument(); 174 }); 175 176 it("does not show 'View original' link when forkId is absent", () => { 177 render(<Message message={userMsg} onEdit={vi.fn()} />); 178 expect(screen.queryByText("View original")).not.toBeInTheDocument(); 179 }); 180 181 it("does not show 'View original' when originalContent is absent", () => { 182 const msg = { ...userMsg, forkId: "fork-1" }; 183 render(<Message message={msg} onEdit={vi.fn()} />); 184 expect(screen.queryByText("View original")).not.toBeInTheDocument(); 185 }); 186 187 it("clicking 'View original' alerts with original content", () => { 188 const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); 189 const editedMsg = { ...userMsg, forkId: "fork-1", originalContent: "Original text" }; 190 render(<Message message={editedMsg} onEdit={vi.fn()} />); 191 fireEvent.click(screen.getByText("View original")); 192 expect(alertSpy).toHaveBeenCalledWith("Original text"); 193 alertSpy.mockRestore(); 194 }); 195 }); 196 197 describe("Message tool calls", () => { 198 it("renders tool calls when present", () => { 199 const msgWithTools: MessageType = { 200 ...assistantMsg, 201 toolCalls: [ 202 { id: "tc1", name: "search", args: "{}", result: null, status: "pending" }, 203 ], 204 }; 205 const { container } = render(<Message message={msgWithTools} />); 206 const nameSpan = container.querySelector(".font-mono"); 207 expect(nameSpan?.textContent).toBe("search"); 208 }); 209 210 it("renders multiple tool calls", () => { 211 const msgWithTools: MessageType = { 212 ...assistantMsg, 213 toolCalls: [ 214 { id: "tc1", name: "search", args: "{}", result: null, status: "pending" }, 215 { id: "tc2", name: "calculate", args: "{}", result: null, status: "pending" }, 216 ], 217 }; 218 const { container } = render(<Message message={msgWithTools} />); 219 const nameSpans = container.querySelectorAll(".font-mono"); 220 expect(nameSpans[0]?.textContent).toBe("search"); 221 expect(nameSpans[1]?.textContent).toBe("calculate"); 222 }); 223 }); 224 225 describe("Message styling", () => { 226 it("user message has bg-gray-800", () => { 227 const { container } = render(<Message message={userMsg} />); 228 const bubble = container.querySelector(".bg-gray-800"); 229 expect(bubble).toBeInTheDocument(); 230 }); 231 232 it("assistant message has bg-gray-900", () => { 233 const { container } = render(<Message message={assistantMsg} />); 234 const bubble = container.querySelector(".bg-gray-900"); 235 expect(bubble).toBeInTheDocument(); 236 }); 237 }); 238 239 describe("Message image attachments", () => { 240 it("renders image preview when message has image attachments", () => { 241 const msgWithImage: MessageType = { 242 ...userMsg, 243 attachments: [ 244 { id: "att-1", filename: "photo.png", content_type: "image/png", created_at: Date.now() }, 245 ], 246 }; 247 render(<Message message={msgWithImage} />); 248 const img = screen.getByRole("img", { name: "photo.png" }); 249 expect(img).toBeInTheDocument(); 250 expect(img).toHaveAttribute("src", "/api/attachments/att-1"); 251 }); 252 253 it("wraps image preview in a link that opens in new tab", () => { 254 const msgWithImage: MessageType = { 255 ...userMsg, 256 attachments: [ 257 { id: "att-1", filename: "photo.png", content_type: "image/png", created_at: Date.now() }, 258 ], 259 }; 260 render(<Message message={msgWithImage} />); 261 const link = screen.getByRole("link"); 262 expect(link).toHaveAttribute("href", "/api/attachments/att-1"); 263 expect(link).toHaveAttribute("target", "_blank"); 264 }); 265 266 it("renders multiple image previews", () => { 267 const msgWithImages: MessageType = { 268 ...userMsg, 269 attachments: [ 270 { id: "att-1", filename: "a.png", content_type: "image/png", created_at: Date.now() }, 271 { id: "att-2", filename: "b.jpg", content_type: "image/jpeg", created_at: Date.now() }, 272 ], 273 }; 274 render(<Message message={msgWithImages} />); 275 const images = screen.getAllByRole("img"); 276 expect(images).toHaveLength(2); 277 }); 278 279 it("does not render image for non-image attachments", () => { 280 const msgWithFile: MessageType = { 281 ...userMsg, 282 attachments: [ 283 { id: "att-1", filename: "doc.pdf", content_type: "application/pdf", created_at: Date.now() }, 284 ], 285 }; 286 render(<Message message={msgWithFile} />); 287 expect(screen.queryByRole("img")).not.toBeInTheDocument(); 288 }); 289 290 it("does not render attachments on assistant messages", () => { 291 const msgWithImage: MessageType = { 292 ...assistantMsg, 293 attachments: [ 294 { id: "att-1", filename: "photo.png", content_type: "image/png", created_at: Date.now() }, 295 ], 296 }; 297 render(<Message message={msgWithImage} />); 298 expect(screen.queryByRole("img")).not.toBeInTheDocument(); 299 }); 300 301 it("renders no attachment elements when attachments is empty", () => { 302 render(<Message message={userMsg} />); 303 expect(screen.queryByRole("img")).not.toBeInTheDocument(); 304 expect(screen.queryByRole("link")).not.toBeInTheDocument(); 305 }); 306 });