import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { MessageBubble } from "@/components/MessageBubble"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; const CLI_APPS: CliAppInfo[] = [ { name: "zoom", display_name: "Zoom", category: "productivity", description: "Meetings", requires: "", source: "harness", entry_point: "cli-anything-zoom", install_supported: true, installed: true, available: true, status: "installed", logo_url: "https://example.invalid/zoom.svg", brand_color: "#0B5CFF", skill_installed: true, }, { name: "krita", display_name: "Krita", category: "image", description: "Painting", requires: "", source: "harness", entry_point: "cli-anything-krita", install_supported: true, installed: false, available: false, status: "not_installed", logo_url: null, brand_color: "#3BABFF", skill_installed: false, }, ]; const MCP_PRESETS: McpPresetInfo[] = [ { name: "browserbase", display_name: "Browserbase", category: "browser", description: "Cloud browser automation", docs_url: "https://docs.browserbase.com", transport: "streamableHttp", requires: "Browserbase API key", note: "", install_supported: true, installed: true, configured: true, available: true, status: "configured", logo_url: "https://example.invalid/browserbase.svg", brand_color: "#111827", required_fields: [], connection_summary: "https://mcp.browserbase.com/mcp", }, ]; describe("MessageBubble", () => { it("renders user messages as right-aligned pills", () => { const message: UIMessage = { id: "u1", role: "user", content: "hello", createdAt: Date.now(), }; const { container } = render(); const row = container.firstElementChild; const pill = screen.getByText("hello"); expect(row).toHaveClass("ml-auto", "flex"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); }); it("renders installed CLI app mentions inside sent user messages", () => { const message: UIMessage = { id: "u-cli", role: "user", content: "Hi nano, please use @zoom to book a meeting, not @krita", createdAt: Date.now(), }; render(); const token = screen.getByTestId("message-cli-mention-zoom"); expect(token).toHaveTextContent("@zoom"); expect(token).toHaveAttribute("title", "CLI app: Zoom"); expect(token.className).not.toContain("rounded"); expect(token.className).not.toContain("px-"); expect(token.getAttribute("style")).toContain("color: #0B5CFF"); expect(token.getAttribute("style")).toContain("text-shadow"); expect(screen.getByTestId("message-cli-mention-logo-zoom")).toBeInTheDocument(); expect(screen.queryByTestId("message-cli-mention-krita")).not.toBeInTheDocument(); expect(screen.getByText(/not @krita/)).toBeInTheDocument(); }); it("renders structured CLI app attachments even without the installed catalog", () => { const message: UIMessage = { id: "u-cli-attached", role: "user", content: "Please use @drawio for the diagram", createdAt: Date.now(), cliApps: [{ name: "drawio", display_name: "Draw.io", category: "diagram", entry_point: "cli-anything-drawio", logo_url: "https://example.invalid/drawio.svg", brand_color: "#F08705", }], }; render(); const token = screen.getByTestId("message-cli-mention-drawio"); expect(token).toHaveTextContent("@drawio"); expect(token.className).not.toContain("rounded"); expect(token.className).not.toContain("px-"); expect(token.getAttribute("style")).toContain("color: #F08705"); expect(screen.getByTestId("message-cli-mention-logo-drawio")).toBeInTheDocument(); }); it("renders MCP preset mentions inside sent user messages", () => { const message: UIMessage = { id: "u-mcp", role: "user", content: "Use @browserbase to inspect the checkout flow", createdAt: Date.now(), }; render(); const token = screen.getByTestId("message-mcp-mention-browserbase"); expect(token).toHaveTextContent("@browserbase"); expect(token).toHaveAttribute("title", "MCP server: Browserbase"); expect(token.getAttribute("style")).toContain("color: #111827"); expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument(); }); it("copies completed assistant replies from the action row", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, }); const message: UIMessage = { id: "a-copy", role: "assistant", content: "I can help with the next step.", createdAt: Date.now(), }; render(); fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); expect(writeText).toHaveBeenCalledWith("I can help with the next step."); await waitFor(() => expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), ); }); it("copies completed assistant replies with the textarea fallback", async () => { Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined, }); const execCommand = vi.fn().mockReturnValue(true); Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand, }); const message: UIMessage = { id: "a-copy-fallback", role: "assistant", content: "Fallback copy reply.", createdAt: Date.now(), }; try { render(); fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); await waitFor(() => expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), ); } finally { Reflect.deleteProperty(navigator, "clipboard"); Reflect.deleteProperty(document, "execCommand"); } }); it("falls back when the Clipboard API rejects assistant reply copy", async () => { const writeText = vi.fn().mockRejectedValue(new Error("not allowed")); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, }); const execCommand = vi.fn().mockReturnValue(true); Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand, }); const message: UIMessage = { id: "a-copy-reject", role: "assistant", content: "Rejected clipboard copy.", createdAt: Date.now(), }; try { render(); fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); expect(writeText).toHaveBeenCalledWith("Rejected clipboard copy."); await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); await waitFor(() => expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), ); } finally { Reflect.deleteProperty(navigator, "clipboard"); Reflect.deleteProperty(document, "execCommand"); } }); it("does not show copy actions for streaming placeholders", () => { const message: UIMessage = { id: "a-streaming", role: "assistant", content: "", isStreaming: true, createdAt: Date.now(), }; render(); expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); }); it("does not show copy when showAssistantCopyAction is false", () => { const message: UIMessage = { id: "a-mid", role: "assistant", content: "Mid-turn snippet.", createdAt: Date.now(), }; render(); expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); }); it("renders trace messages as collapsible tool groups", () => { const message: UIMessage = { id: "t1", role: "tool", kind: "trace", content: 'search "hk weather"', traces: ['weather("get")', 'search "hk weather"'], createdAt: Date.now(), }; render(); const toggle = screen.getByRole("button", { name: /used 2 tools/i }); expect(screen.queryByText('weather("get")')).not.toBeInTheDocument(); expect(screen.queryByText('search "hk weather"')).not.toBeInTheDocument(); fireEvent.click(toggle); expect(screen.getByText('weather("get")')).toBeInTheDocument(); expect(screen.getByText('search "hk weather"')).toBeInTheDocument(); }); it("renders video media as an inline player", () => { const message: UIMessage = { id: "a1", role: "assistant", content: "here is the clip", createdAt: Date.now(), media: [ { kind: "video", url: "/api/media/sig/payload", name: "demo.mp4", }, ], }; const { container } = render(); expect(screen.getByText("here is the clip")).toBeInTheDocument(); const video = screen.getByLabelText(/video attachment/i); expect(video.tagName).toBe("VIDEO"); expect(video).toHaveAttribute("src", "/api/media/sig/payload"); expect(video).toHaveAttribute("preload", "auto"); expect(container.querySelector("video[controls]")).toBeInTheDocument(); expect(screen.queryByText("Preview")).not.toBeInTheDocument(); expect(screen.queryByText("Code")).not.toBeInTheDocument(); }); it("auto-expands the reasoning trace while streaming with a shimmer header", () => { const message: UIMessage = { id: "a-reasoning-streaming", role: "assistant", content: "", createdAt: Date.now(), reasoning: "Step 1: parse intent. Step 2: compute.", reasoningStreaming: true, }; const { container } = render(); expect(screen.getByText("Thinking…")).toBeInTheDocument(); expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument(); expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument(); expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen"); expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…"); expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2"); }); it("collapses the reasoning section by default once streaming ends", () => { const message: UIMessage = { id: "a-reasoning-done", role: "assistant", content: "The answer is 42.", createdAt: Date.now(), reasoning: "hidden until expanded", reasoningStreaming: false, }; render(); expect(screen.getByText("Thinking")).toBeInTheDocument(); expect(screen.getByText("The answer is 42.")).toBeInTheDocument(); expect(screen.queryByText("hidden until expanded")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /thinking/i }).parentElement).toHaveClass("mb-2"); fireEvent.click(screen.getByRole("button", { name: /thinking/i })); expect(screen.getByText("hidden until expanded")).toBeInTheDocument(); }); it("renders reasoning body as markdown so headings are not left as raw ###", async () => { await import("@/components/MarkdownTextRenderer"); const message: UIMessage = { id: "a-reasoning-md", role: "assistant", content: "", createdAt: Date.now(), reasoning: "### Section title\n\nBody line.", reasoningStreaming: false, }; const { container } = render(); fireEvent.click(screen.getByRole("button", { name: /thinking/i })); await waitFor(() => { expect(container.querySelector("h3")?.textContent).toBe("Section title"); }); expect(container.textContent).not.toContain("###"); expect(screen.getByText("Body line.")).toBeInTheDocument(); }); it("renders inline file paths as compact file references", async () => { await import("@/components/MarkdownTextRenderer"); const message: UIMessage = { id: "a-file-path", role: "assistant", content: "改动在 `webui/src/components/MarkdownTextRenderer.tsx` 和 `/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html`。", createdAt: Date.now(), }; try { render(); const references = await screen.findAllByTestId("inline-file-path"); expect(references).toHaveLength(2); expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]"); expect(references[0].parentElement).toHaveClass("align-baseline"); expect(references[0].parentElement).toHaveClass("leading-[inherit]"); expect(references[0]).toHaveClass("items-baseline"); expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx"); expect(references[0]).not.toHaveTextContent("webui/src/components"); expect(screen.getByText("index.html")).toBeInTheDocument(); expect(references[1]).not.toHaveTextContent("/Users/renxubin"); expect(references[1]).not.toHaveAttribute("title"); expect(references[1]).toHaveAttribute( "aria-label", "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html", ); vi.useFakeTimers(); fireEvent.pointerMove(references[1].parentElement!); await act(async () => { vi.advanceTimersByTime(500); }); const tooltip = screen.getByRole("tooltip"); expect(tooltip).toHaveTextContent( "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html", ); } finally { vi.useRealTimers(); } }); it("renders assistant image media as a larger generated result", () => { const message: UIMessage = { id: "a-image", role: "assistant", content: "done", createdAt: Date.now(), media: [ { kind: "image", url: "/api/media/sig/image", name: "generated.png", }, ], }; const { container } = render(); const imageButton = screen.getByRole("button", { name: /view image/i }); expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-[20px]"); expect(imageButton).not.toHaveAttribute("title"); expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain"); }); it("renders mislabeled html assistant media as a file attachment", () => { const message: UIMessage = { id: "a-html", role: "assistant", content: "file ready", createdAt: Date.now(), media: [ { kind: "image", url: "/api/media/sig/html", name: "index.html", }, ], }; const { container } = render(); expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html"); expect(container.querySelector("img")).not.toBeInTheDocument(); }); it("renders assistant svg media as an image preview", () => { const message: UIMessage = { id: "a-svg", role: "assistant", content: "chart ready", createdAt: Date.now(), media: [ { kind: "file", url: "/api/media/sig/svg", name: "growth.svg", }, ], }; const { container } = render(); expect(screen.getByRole("button", { name: /view image: growth.svg/i })).toBeInTheDocument(); expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument(); expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument(); }); });