2026-05-06 14:15:36 +00:00
|
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
import { MessageBubble } from "@/components/MessageBubble";
|
|
|
|
|
import type { UIMessage } from "@/lib/types";
|
|
|
|
|
|
|
|
|
|
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(<MessageBubble message={message} />);
|
|
|
|
|
const row = container.firstElementChild;
|
|
|
|
|
const pill = screen.getByText("hello");
|
|
|
|
|
|
|
|
|
|
expect(row).toHaveClass("ml-auto", "flex");
|
|
|
|
|
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
2026-05-06 14:15:36 +00:00
|
|
|
expect(screen.queryByRole("button", { name: "Copy reply" })).not.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(<MessageBubble message={message} />);
|
|
|
|
|
|
|
|
|
|
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("does not show copy actions for streaming placeholders", () => {
|
|
|
|
|
const message: UIMessage = {
|
|
|
|
|
id: "a-streaming",
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: "",
|
|
|
|
|
isStreaming: true,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
render(<MessageBubble message={message} />);
|
|
|
|
|
|
|
|
|
|
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
|
2026-04-18 18:51:53 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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(<MessageBubble message={message} />);
|
|
|
|
|
const toggle = screen.getByRole("button", { name: /used 2 tools/i });
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('weather("get")')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('search "hk weather"')).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(toggle);
|
|
|
|
|
expect(screen.queryByText('weather("get")')).not.toBeInTheDocument();
|
|
|
|
|
});
|
2026-04-24 19:17:58 +00:00
|
|
|
|
|
|
|
|
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(<MessageBubble message={message} />);
|
|
|
|
|
|
|
|
|
|
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(container.querySelector("video[controls]")).toBeInTheDocument();
|
|
|
|
|
});
|
2026-05-08 09:40:15 +00:00
|
|
|
|
2026-05-13 06:27:53 +00:00
|
|
|
it("surfaces reasoning content above the assistant answer when provided", () => {
|
|
|
|
|
const message: UIMessage = {
|
|
|
|
|
id: "a-reasoning",
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: "The answer is 42.",
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
reasoning: ["Step 1: parse intent.", "Step 2: compute."],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
render(<MessageBubble message={message} />);
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText("Thinking")).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText(/Step 2: compute\./)).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText("The answer is 42.")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("collapses the reasoning section when toggled", () => {
|
|
|
|
|
const message: UIMessage = {
|
|
|
|
|
id: "a-reasoning-collapse",
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: "done",
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
reasoning: ["hidden after toggle"],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
render(<MessageBubble message={message} />);
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText("hidden after toggle")).toBeInTheDocument();
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
|
|
|
|
|
expect(screen.queryByText("hidden after toggle")).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 09:40:15 +00:00
|
|
|
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(<MessageBubble message={message} />);
|
|
|
|
|
|
|
|
|
|
const imageButton = screen.getByRole("button", { name: /view image/i });
|
2026-05-08 15:39:37 +00:00
|
|
|
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");
|
2026-05-08 09:40:15 +00:00
|
|
|
});
|
2026-04-18 18:51:53 +00:00
|
|
|
});
|