import { test, describe, expect, afterEach, vi } from "vitest"; import { cleanup, render, fireEvent } from "@self/tootils/render"; import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests"; import Chatbot from "./Index.svelte"; import { group_messages, normalise_messages, is_last_bot_message, all_text, get_thought_content } from "./shared/utils"; import type { NormalisedMessage, Message, ThoughtNode } from "./types"; function text_msg( role: "user" | "assistant", text: string, index: number, opts?: { options?: { value: string; label?: string }[]; metadata?: any } ): Message { return { role, content: [{ type: "text", text }], index, metadata: opts?.metadata ?? { title: null }, ...(opts?.options ? { options: opts.options } : {}) }; } function file_msg( role: "user" | "assistant", url: string, mime_type: string, index: number ): Message { return { role, content: [ { type: "file", file: { path: url, url, orig_name: url.split("/").pop() || "file", size: 1024, mime_type, is_stream: false }, alt_text: null } ], index, metadata: { title: null } }; } const default_props = { label: "Chatbot", show_label: true, value: null as Message[] | null, latex_delimiters: [{ left: "$$", right: "$$", display: true }], likeable: false, feedback_options: [] as string[], feedback_value: null as (string | null)[] | null, like_user_message: false, _retryable: false, _undoable: false, _selectable: false, editable: null as "user" | "all" | null, layout: "bubble" as const, render_markdown: true, sanitize_html: true, line_breaks: true, allow_tags: false as string[] | boolean, group_consecutive_messages: true, autoscroll: false, placeholder: null as string | null, examples: null as any, avatar_images: [null, null] as [null, null], buttons: null as | (string | { value: string; id: number; icon: null })[] | null, allow_file_downloads: true, watermark: null as string | null, rtl: false, height: undefined as number | string | undefined }; run_shared_prop_tests({ component: Chatbot, name: "Chatbot", base_props: { ...default_props }, has_label: false, has_validation_error: false }); describe("Chatbot", () => { afterEach(() => cleanup()); test("renders user and bot messages", async () => { const { getAllByTestId } = await render(Chatbot, { ...default_props, value: [ text_msg("user", "Hello", 0), text_msg("assistant", "Hi there", 1) ] }); expect(getAllByTestId("user").length).toBe(1); expect(getAllByTestId("bot").length).toBe(1); }); test("empty string messages are visible", async () => { const { getAllByTestId } = await render(Chatbot, { ...default_props, value: [text_msg("user", "", 0), text_msg("assistant", "", 1)] }); expect(getAllByTestId("user").length).toBe(1); expect(getAllByTestId("bot").length).toBe(1); }); test("renders multiple messages", async () => { const { getAllByTestId } = await render(Chatbot, { ...default_props, value: [ text_msg("user", "msg 1", 0), text_msg("assistant", "reply 1", 1), text_msg("user", "msg 2", 2), text_msg("assistant", "reply 2", 3) ] }); expect(getAllByTestId("user").length).toBe(2); expect(getAllByTestId("bot").length).toBe(2); }); test("renders with null value showing no messages", async () => { const { queryAllByTestId } = await render(Chatbot, { ...default_props, value: null }); expect(queryAllByTestId("user").length).toBe(0); expect(queryAllByTestId("bot").length).toBe(0); }); test("conversation log has correct accessibility attributes", async () => { const { getByRole } = await render(Chatbot, { ...default_props, value: [text_msg("user", "Hello", 0)] }); const log = getByRole("log"); expect(log).toBeTruthy(); expect(log.getAttribute("aria-label")).toBe("chatbot conversation"); expect(log.getAttribute("aria-live")).toBe("polite"); }); }); describe("Props: likeable / feedback_options", () => { afterEach(() => cleanup()); test("likeable=true shows like/dislike buttons on bot messages", async () => { const { getByLabelText } = await render(Chatbot, { ...default_props, likeable: true, feedback_options: ["Like", "Dislike"], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getByLabelText("chatbot.like")).toBeTruthy(); expect(getByLabelText("chatbot.dislike")).toBeTruthy(); }); test("likeable=false hides like/dislike buttons", async () => { const { queryByLabelText } = await render(Chatbot, { ...default_props, likeable: false, feedback_options: ["Like", "Dislike"], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(queryByLabelText("chatbot.like")).toBeNull(); expect(queryByLabelText("chatbot.dislike")).toBeNull(); }); test("like_user_message=true shows like buttons on user messages", async () => { const { getAllByLabelText } = await render(Chatbot, { ...default_props, likeable: true, like_user_message: true, feedback_options: ["Like", "Dislike"], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getAllByLabelText("chatbot.like").length).toBe(2); }); test("like_user_message=false hides like buttons on user messages", async () => { const { getAllByLabelText } = await render(Chatbot, { ...default_props, likeable: true, like_user_message: false, feedback_options: ["Like", "Dislike"], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getAllByLabelText("chatbot.like").length).toBe(1); }); }); describe("Props: buttons", () => { afterEach(() => cleanup()); test("copy button renders on text messages when enabled", async () => { const { getAllByLabelText } = await render(Chatbot, { ...default_props, buttons: null, value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getAllByLabelText("chatbot.copy_message").length).toEqual(2); }); test("copy_all button renders when enabled", async () => { const { getByLabelText } = await render(Chatbot, { ...default_props, buttons: ["copy_all"], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getByLabelText("Copy conversation")).toBeTruthy(); }); test("clear button renders when messages exist", async () => { const { getByLabelText } = await render(Chatbot, { ...default_props, value: [text_msg("user", "Hi", 0)] }); expect(getByLabelText("chatbot.clear")).toBeTruthy(); }); test("clear button not rendered when no messages", async () => { const { queryByLabelText } = await render(Chatbot, { ...default_props, value: null }); expect(queryByLabelText("chatbot.clear")).toBeNull(); }); test("retry button shows on last bot message when _retryable=true", async () => { const { getByLabelText, getAllByLabelText } = await render(Chatbot, { ...default_props, _retryable: true, value: [ text_msg("user", "Hi", 0), text_msg("assistant", "Hi", 1), text_msg("user", "Hello", 2), text_msg("assistant", "Hello", 3) ] }); expect(getByLabelText("chatbot.retry")).toBeTruthy(); expect(getAllByLabelText("chatbot.retry").length).toEqual(1); }); test("undo button shows on last bot message when _undoable=true", async () => { const { getByLabelText } = await render(Chatbot, { ...default_props, _undoable: true, value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getByLabelText("chatbot.undo")).toBeTruthy(); }); test("retry/undo buttons not shown on non-last bot messages", async () => { const { getAllByLabelText, queryAllByLabelText } = await render(Chatbot, { ...default_props, _retryable: true, _undoable: true, value: [ text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1), text_msg("user", "Thanks", 2), text_msg("assistant", "You're welcome", 3) ] }); expect(getAllByLabelText("chatbot.retry").length).toBe(1); expect(getAllByLabelText("chatbot.undo").length).toBe(1); }); test("custom button dispatches custom_button_click", async () => { const { listen, getByLabelText } = await render(Chatbot, { ...default_props, buttons: [{ value: "Analyze", id: 3, icon: null }], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); const custom = listen("custom_button_click"); await fireEvent.click(getByLabelText("Analyze")); expect(custom).toHaveBeenCalledTimes(1); expect(custom).toHaveBeenCalledWith({ id: 3 }); }); }); describe("Props: editable", () => { afterEach(() => cleanup()); test("editable='user' shows edit button on user messages", async () => { const { getByLabelText } = await render(Chatbot, { ...default_props, editable: "user", value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getByLabelText("chatbot.edit")).toBeTruthy(); }); test("editable=null hides edit button", async () => { const { queryByLabelText } = await render(Chatbot, { ...default_props, editable: null, value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(queryByLabelText("chatbot.edit")).toBeNull(); }); test("editable='all' shows edit button on both user and bot messages", async () => { const { getAllByLabelText } = await render(Chatbot, { ...default_props, editable: "all", value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); expect(getAllByLabelText("chatbot.edit").length).toBe(2); }); }); describe("Props: placeholder / examples", () => { afterEach(() => cleanup()); test("placeholder shown when no messages", async () => { const { getByText } = await render(Chatbot, { ...default_props, value: null, placeholder: "Ask me anything..." }); expect(getByText("Ask me anything...")).toBeTruthy(); }); test("placeholder not shown when messages exist", async () => { const { queryByText } = await render(Chatbot, { ...default_props, value: [text_msg("user", "Hi", 0)], placeholder: "Ask me anything..." }); expect(queryByText("Ask me anything...")).toBeNull(); }); test("examples render when no messages", async () => { const { getByText } = await render(Chatbot, { ...default_props, value: null, examples: [ { text: "Tell me a joke", display_text: "Tell me a joke" }, { text: "Write code", display_text: "Write code" } ] }); expect(getByText("Tell me a joke")).toBeTruthy(); expect(getByText("Write code")).toBeTruthy(); }); test("clicking example dispatches example_select", async () => { const { listen, getByText } = await render(Chatbot, { ...default_props, value: null, examples: [{ text: "Tell me a joke", display_text: "Tell me a joke" }] }); const example_select = listen("example_select"); await fireEvent.click(getByText("Tell me a joke")); expect(example_select).toHaveBeenCalledTimes(1); }); }); describe("Props: allow_tags", () => { afterEach(() => cleanup()); test("custom HTML tags preserved when in allow_tags", async () => { const { getAllByTestId } = await render(Chatbot, { ...default_props, allow_tags: ["thinking"], value: [text_msg("assistant", "deep thought", 0)] }); const bot = getAllByTestId("bot")[0]; expect(bot.textContent).toContain("deep thought"); }); }); describe("Props: options", () => { afterEach(() => cleanup()); test("bot message options render as clickable buttons", async () => { const { getByRole } = await render(Chatbot, { ...default_props, value: [ text_msg("assistant", "Choose one:", 0, { options: [ { value: "opt_a", label: "Option A" }, { value: "opt_b", label: "Option B" } ] }) ] }); expect(getByRole("button", { name: "Option A" })).toBeTruthy(); expect(getByRole("button", { name: "Option B" })).toBeTruthy(); }); test("clicking option dispatches option_select", async () => { const { listen, getByRole } = await render(Chatbot, { ...default_props, value: [ text_msg("assistant", "Choose:", 0, { options: [{ value: "yes", label: "Yes" }] }) ] }); const option_select = listen("option_select"); await fireEvent.click(getByRole("button", { name: "Yes" })); expect(option_select).toHaveBeenCalledTimes(1); expect(option_select).toHaveBeenCalledWith( expect.objectContaining({ value: "yes", index: 0 }) ); }); test("option without label uses value as display text", async () => { const { getByRole } = await render(Chatbot, { ...default_props, value: [ text_msg("assistant", "Choose:", 0, { options: [{ value: "raw_value" }] }) ] }); expect(getByRole("button", { name: "raw_value" })).toBeTruthy(); }); }); describe("Events: change", () => { afterEach(() => cleanup()); test("set_data triggers change event", async () => { const { listen, set_data } = await render(Chatbot, { ...default_props, value: null }); const change = listen("change"); await set_data({ value: [text_msg("user", "Hello", 0)] }); expect(change).toHaveBeenCalled(); }); test("no spurious change on mount with null value", async () => { const { listen } = await render(Chatbot, { ...default_props, value: null }); const change = listen("change", { retrospective: true }); expect(change).not.toHaveBeenCalled(); }); }); describe("Events: like", () => { afterEach(() => cleanup()); test("clicking like dispatches like event with liked=true", async () => { const { listen, getByLabelText } = await render(Chatbot, { ...default_props, likeable: true, feedback_options: ["Like", "Dislike"], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); const like = listen("like"); await fireEvent.click(getByLabelText("chatbot.like")); expect(like).toHaveBeenCalledTimes(1); expect(like).toHaveBeenCalledWith(expect.objectContaining({ liked: true })); }); test("clicking dislike dispatches like event with liked=false", async () => { const { listen, getByLabelText } = await render(Chatbot, { ...default_props, likeable: true, feedback_options: ["Like", "Dislike"], value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); const like = listen("like"); await fireEvent.click(getByLabelText("chatbot.dislike")); expect(like).toHaveBeenCalledTimes(1); expect(like).toHaveBeenCalledWith( expect.objectContaining({ liked: false }) ); }); }); describe("Events: retry / undo", () => { afterEach(() => cleanup()); test("clicking retry dispatches retry event", async () => { const { listen, getByLabelText } = await render(Chatbot, { ...default_props, _retryable: true, value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); const retry = listen("retry"); await fireEvent.click(getByLabelText("chatbot.retry")); expect(retry).toHaveBeenCalledTimes(1); expect(retry).toHaveBeenCalledWith(expect.objectContaining({ index: 0 })); }); test("clicking undo dispatches undo event", async () => { const { listen, getByLabelText } = await render(Chatbot, { ...default_props, _undoable: true, value: [text_msg("user", "Hi", 0), text_msg("assistant", "Hello", 1)] }); const undo = listen("undo"); await fireEvent.click(getByLabelText("chatbot.undo")); expect(undo).toHaveBeenCalledTimes(1); expect(undo).toHaveBeenCalledWith(expect.objectContaining({ index: 0 })); }); }); describe("Events: clear", () => { afterEach(() => cleanup()); test("clear button dispatches clear event", async () => { const { listen, getByLabelText } = await render(Chatbot, { ...default_props, value: [text_msg("user", "Hi", 0)] }); const clear = listen("clear"); await fireEvent.click(getByLabelText("chatbot.clear")); expect(clear).toHaveBeenCalledTimes(1); }); }); describe("Events: copy", () => { afterEach(() => cleanup()); test("copy all copies full conversation to clipboard", async () => { // Mock clipboard API — browser clipboard requires user gesture // and secure context, neither of which is available in test const clipboard_mock = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { value: { writeText: clipboard_mock }, configurable: true, writable: true }); const { getByLabelText } = await render(Chatbot, { ...default_props, buttons: ["copy_all"], value: [ text_msg("user", "user msg", 0), text_msg("assistant", "bot msg", 1) ] }); await fireEvent.click(getByLabelText("Copy conversation")); expect(clipboard_mock).toHaveBeenCalledWith( expect.stringContaining("user: user msg") ); expect(clipboard_mock).toHaveBeenCalledWith( expect.stringContaining("assistant: bot msg") ); }); }); describe("File messages", () => { afterEach(() => cleanup()); test("generic file renders download link with correct href", async () => { const { getAllByTestId } = await render(Chatbot, { ...default_props, value: [file_msg("user", "https://example.com/data.csv", "text/csv", 0)] }); const links = getAllByTestId("chatbot-file") as HTMLAnchorElement[]; expect(links.length).toBe(1); expect(links[0].href).toContain("data.csv"); }); test("generic file shows file name and extension", async () => { const { getByText } = await render(Chatbot, { ...default_props, value: [file_msg("user", "https://example.com/report.pdf", "text/pdf", 0)] }); expect(getByText("report.pdf")).toBeTruthy(); expect(getByText("PDF")).toBeTruthy(); }); test("image file is normalised to image component message", () => { const messages: Message[] = [ file_msg("assistant", "https://example.com/photo.jpg", "image/jpeg", 0) ]; const normalised = normalise_messages(messages, ""); expect(normalised).not.toBeNull(); expect(normalised![0].type).toBe("component"); if (normalised![0].type === "component") { expect(normalised![0].content.component).toBe("image"); } }); test("video file is normalised to video component message", () => { const messages: Message[] = [ file_msg("assistant", "https://example.com/clip.mp4", "video/mp4", 0) ]; const normalised = normalise_messages(messages, ""); expect(normalised).not.toBeNull(); expect(normalised![0].type).toBe("component"); if (normalised![0].type === "component") { expect(normalised![0].content.component).toBe("video"); } }); test("audio file is normalised to audio component message", () => { const messages: Message[] = [ file_msg("assistant", "https://example.com/song.wav", "audio/wav", 0) ]; const normalised = normalise_messages(messages, ""); expect(normalised).not.toBeNull(); expect(normalised![0].type).toBe("component"); if (normalised![0].type === "component") { expect(normalised![0].content.component).toBe("audio"); } }); test("file without mime_type and 3D model extension normalised to model3d", () => { const messages: Message[] = [ { role: "assistant", content: [ { type: "file", file: { path: "https://example.com/scene.glb", url: "https://example.com/scene.glb", orig_name: "scene.glb", size: 4096, mime_type: null, is_stream: false } as any, alt_text: null } ], index: 0, metadata: { title: null } } ]; const normalised = normalise_messages(messages, ""); expect(normalised![0].type).toBe("component"); if (normalised![0].type === "component") { expect(normalised![0].content.component).toBe("model3d"); } }); test("unknown mime_type file normalised to 'file' component with array value", () => { const messages: Message[] = [ file_msg("user", "https://example.com/data.csv", "text/csv", 0) ]; const normalised = normalise_messages(messages, ""); expect(normalised![0].type).toBe("component"); if (normalised![0].type === "component") { expect(normalised![0].content.component).toBe("file"); expect(Array.isArray(normalised![0].content.value)).toBe(true); } }); }); describe("get_data / set_data", () => { afterEach(() => cleanup()); test("get_data returns null when no value", async () => { const { get_data } = await render(Chatbot, { ...default_props, value: null }); const data = await get_data(); expect(data.value).toBeNull(); }); test("set_data updates displayed messages", async () => { const { set_data, getAllByTestId } = await render(Chatbot, { ...default_props, value: null }); await set_data({ value: [text_msg("user", "Hello", 0), text_msg("assistant", "World", 1)] }); expect(getAllByTestId("user").length).toBe(1); expect(getAllByTestId("bot").length).toBe(1); }); test("get_data reflects set_data (round-trip)", async () => { const msgs = [ text_msg("user", "Hello", 0), text_msg("assistant", "World", 1) ]; const { get_data, set_data } = await render(Chatbot, { ...default_props, value: null }); await set_data({ value: msgs }); const data = await get_data(); expect(data.value).toEqual(msgs); }); }); describe("group_messages utility", () => { const msgs: NormalisedMessage[] = [ { role: "user", content: "Hello", type: "text", index: 0, metadata: { title: null } }, { role: "assistant", content: "Hi", type: "text", index: 1, metadata: { title: null } }, { role: "assistant", content: "How can I help?", type: "text", index: 2, metadata: { title: null } }, { role: "user", content: "Thanks", type: "text", index: 3, metadata: { title: null } } ]; test("groups consecutive same-role messages when enabled", () => { const grouped = group_messages(msgs, true); expect(grouped.length).toBe(3); expect(grouped[0].length).toBe(1); // user expect(grouped[1].length).toBe(2); // 2 consecutive assistant expect(grouped[2].length).toBe(1); // user }); test("keeps each message separate when disabled", () => { const grouped = group_messages(msgs, false); expect(grouped.length).toBe(4); grouped.forEach((g) => expect(g.length).toBe(1)); }); test("skips system messages", () => { const with_system: NormalisedMessage[] = [ { role: "system", content: "You are helpful", type: "text", index: 0, metadata: { title: null } }, ...msgs ]; const grouped = group_messages(with_system, true); expect(grouped.length).toBe(3); }); }); describe("normalise_messages utility", () => { test("normalises text messages", () => { const messages: Message[] = [text_msg("user", "Hello world", 0)]; const result = normalise_messages(messages, ""); expect(result).not.toBeNull(); expect(result!.length).toBe(1); expect(result![0].type).toBe("text"); expect(result![0].content).toBe("Hello world"); expect(result![0].role).toBe("user"); }); test("handles null input", () => { expect(normalise_messages(null, "")).toBeNull(); }); test("redirects /file src URLs using root", () => { const messages: Message[] = [ text_msg("assistant", 'Check ', 0) ]; const result = normalise_messages(messages, "http://localhost:7860/"); expect(result![0].type).toBe("text"); expect((result![0] as any).content).toContain("http://localhost:7860/file"); }); test("nests thought messages by parent_id", () => { const messages: Message[] = [ { role: "assistant", content: [{ type: "text", text: "Thinking..." }], index: 0, metadata: { title: "Step 1", id: "1", parent_id: null } }, { role: "assistant", content: [{ type: "text", text: "Sub-step" }], index: 1, metadata: { title: "Sub", id: "2", parent_id: "1" } } ]; const result = normalise_messages(messages, ""); // The child should be nested, so only 1 top-level message expect(result!.length).toBe(1); const thought = result![0] as ThoughtNode; expect(thought.children.length).toBe(1); expect(thought.children[0].content).toBe("Sub-step"); }); }); describe("is_last_bot_message utility", () => { test("returns true for the last bot message group", () => { const msgs: NormalisedMessage[] = [ { role: "user", content: "Hi", type: "text", index: 0, metadata: { title: null } }, { role: "assistant", content: "Hello", type: "text", index: 1, metadata: { title: null } } ]; expect(is_last_bot_message([msgs[1]], msgs)).toBe(true); }); test("returns false for non-last bot message", () => { const msgs: NormalisedMessage[] = [ { role: "user", content: "Hi", type: "text", index: 0, metadata: { title: null } }, { role: "assistant", content: "Hello", type: "text", index: 1, metadata: { title: null } }, { role: "user", content: "Thanks", type: "text", index: 2, metadata: { title: null } }, { role: "assistant", content: "Bye", type: "text", index: 3, metadata: { title: null } } ]; expect(is_last_bot_message([msgs[1]], msgs)).toBe(false); }); test("returns false when last message is user", () => { const msgs: NormalisedMessage[] = [ { role: "assistant", content: "Hello", type: "text", index: 0, metadata: { title: null } }, { role: "user", content: "Hi", type: "text", index: 1, metadata: { title: null } } ]; expect(is_last_bot_message([msgs[1]], msgs)).toBe(false); }); }); describe("all_text / get_thought_content utilities", () => { test("all_text returns content for a single message", () => { const msg: any = { type: "text", content: "Hello world", metadata: { title: null } }; expect(all_text(msg)).toBe("Hello world"); }); test("all_text joins content for array of messages", () => { const msgs: any[] = [ { type: "text", content: "Line 1", metadata: { title: null } }, { type: "text", content: "Line 2", metadata: { title: null } } ]; expect(all_text(msgs)).toBe("Line 1\nLine 2"); }); test("get_thought_content includes title and content", () => { const msg: any = { type: "text", content: "thinking...", metadata: { title: "Step 1" }, children: [] }; const result = get_thought_content(msg); expect(result).toContain("Step 1"); expect(result).toContain("thinking..."); }); test("get_thought_content recurses into children", () => { const msg: any = { type: "text", content: "parent", metadata: { title: "Parent" }, children: [ { type: "text", content: "child", metadata: { title: "Child" }, children: [] } ] }; const result = get_thought_content(msg); expect(result).toContain("Parent"); expect(result).toContain("Child"); expect(result).toContain("child"); }); }); describe("Edge cases", () => { afterEach(() => cleanup()); test("displays like/dislike on every message when group_consecutive_messages=false", async () => { const { container } = await render(Chatbot, { ...default_props, group_consecutive_messages: false, likeable: true, like_user_message: true, value: [ text_msg("user", "Hello", 0), text_msg("assistant", "Hi", 1), text_msg("assistant", "How can I help?", 2), text_msg("user", "Thanks", 3), text_msg("assistant", "Welcome!", 4) ] }); // Each message gets its own button panel when not grouped // querySelectorAll is used because there's no semantic query for // the message-buttons wrapper — it has no role/label/testid const buttonPanels = container.querySelectorAll(".message-buttons"); expect(buttonPanels.length).toBe(5); }); test("single message conversation renders correctly", async () => { const { getAllByTestId } = await render(Chatbot, { ...default_props, value: [text_msg("user", "Solo", 0)] }); expect(getAllByTestId("user").length).toBe(1); }); test("empty array value shows placeholder area", async () => { const { queryAllByTestId } = await render(Chatbot, { ...default_props, value: [] }); expect(queryAllByTestId("user").length).toBe(0); expect(queryAllByTestId("bot").length).toBe(0); }); }); describe("Thoughts / metadata", () => { afterEach(() => cleanup()); test("thought message renders title from metadata", async () => { const { getByText } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Analyzing the problem..." }], index: 0, metadata: { title: "Thinking...", id: "1" } } as Message ] }); expect(getByText("Thinking...")).toBeTruthy(); }); test("thought message renders log and duration from metadata", async () => { const { getByText, container } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Step done" }], index: 0, metadata: { title: "Step 1", id: "1", log: "Completed analysis", duration: 1.5, status: "done" } } as Message ] }); expect(getByText("Step 1")).toBeTruthy(); // Log and duration are rendered inside the same .duration span, // so getByText may not find them individually. Check container text. const thoughtGroup = container.querySelector(".thought-group"); expect(thoughtGroup).not.toBeNull(); expect(thoughtGroup!.textContent).toContain("Completed analysis"); expect(thoughtGroup!.textContent).toContain("1.5s"); }); test("thought with status='pending' shows loading spinner", async () => { const { container } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "" }], index: 0, metadata: { title: "Thinking...", id: "1", status: "pending" } } as Message ] }); // Loading spinner has class loading-spinner — no role/label/testid available const spinner = container.querySelector(".loading-spinner"); expect(spinner).not.toBeNull(); }); test("thought with status='done' does not show loading spinner", async () => { const { container } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Done thinking" }], index: 0, metadata: { title: "Analysis", id: "1", status: "done" } } as Message ] }); const spinner = container.querySelector(".loading-spinner"); expect(spinner).toBeNull(); }); test("thought with status='done' is collapsed by default", async () => { const { queryByText, getByRole } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Hidden reasoning" }], index: 0, metadata: { title: "My Thought", id: "1", status: "done" } } as Message ] }); // status=done means collapsed by default — content not in DOM const toggle = getByRole("button", { name: /My Thought/ }); expect(toggle).toBeTruthy(); expect(queryByText("Hidden reasoning")).toBeNull(); }); test("clicking thought title expands content", async () => { const { queryByText, getByRole } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Hidden reasoning" }], index: 0, metadata: { title: "My Thought", id: "1", status: "done" } } as Message ] }); expect(queryByText("Hidden reasoning")).toBeNull(); // Click to expand — content appears in DOM await fireEvent.click(getByRole("button", { name: /My Thought/ })); expect(queryByText("Hidden reasoning")).not.toBeNull(); }); test("nested thought children render within parent", async () => { const { getByText } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Parent content" }], index: 0, metadata: { title: "Parent Thought", id: "1", status: "pending" } } as Message, { role: "assistant", content: [{ type: "text", text: "Child content" }], index: 1, metadata: { title: "Child Thought", id: "2", parent_id: "1", status: "pending" } } as Message ] }); // Both should render — parent auto-expanded because status=pending expect(getByText("Parent Thought")).toBeTruthy(); expect(getByText("Child Thought")).toBeTruthy(); }); test("integer duration renders without decimal", async () => { const { getByText } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Done" }], index: 0, metadata: { title: "Step", id: "1", duration: 3, status: "done" } } as Message ] }); expect(getByText(/3s/)).toBeTruthy(); }); test("sub-second duration renders in milliseconds", async () => { const { getByText } = await render(Chatbot, { ...default_props, value: [ { role: "assistant", content: [{ type: "text", text: "Fast" }], index: 0, metadata: { title: "Quick Step", id: "1", duration: 0.05, status: "done" } } as Message ] }); expect(getByText(/50\.0ms/)).toBeTruthy(); }); }); // ── Visual-only (test.todo) ────────────────────────────────────────── test.todo( "VISUAL: layout='bubble' renders messages in speech bubble style — needs Playwright visual regression screenshot comparison" ); test.todo( "VISUAL: layout='panel' renders messages in panel style — needs Playwright visual regression screenshot comparison" ); test.todo( "VISUAL: avatar_images displays user and bot avatars next to messages — needs Playwright visual regression screenshot comparison" ); test.todo( "VISUAL: rtl=true renders right-to-left layout — needs Playwright visual regression screenshot comparison" ); test.todo( "VISUAL: height/min_height/max_height control component sizing — needs Playwright visual regression screenshot comparison" );