import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatMessage, ToolState } from "./derive"; import ChatStream, { Bubble, TypingIndicator, lineDiff, renderMarkdown, toolArgsEntries, toolSummaryText, } from "./ChatStream"; function msg(partial: Partial): ChatMessage { return { key: `k-${Math.random()}`, role: "assistant", text: "", thinking: null, toolCalls: [], toolCallId: null, streaming: false, ts: 0, ...partial, }; } const tool = (p: Partial): ToolState => ({ id: "c1", name: "bash", args: "", running: false, isError: false, preview: "", ...p, }); function stream(p: { messages: ChatMessage[]; busy: boolean; hasOlder?: boolean; loadingOlder?: boolean; onOlder?: () => void; searchOpen?: boolean; onSearchClose?: () => void; showTs?: boolean; }): React.ReactElement { return ( undefined)} searchOpen={p.searchOpen ?? false} onSearchClose={p.onSearchClose ?? (() => undefined)} showTs={p.showTs ?? false} /> ); } describe("Bubble", () => { it("renders plain text per role class", () => { const { container } = render( , ); expect(container.querySelector(".bubble-row.user")).not.toBeNull(); expect(container.textContent).toContain("hi there"); }); it("notice messages render as a ⚠ line, not a chat bubble", () => { const { container } = render( , ); expect(container.querySelector(".notice-line")).not.toBeNull(); expect(container.textContent).toContain("⚠ model not found"); expect(container.querySelector(".bubble")).toBeNull(); }); it("renders empty assistant text as nothing but shows nothing when empty", () => { const { container } = render( , ); expect(container.querySelector(".bubble")?.children).toHaveLength(0); }); it("toolResult collapses to a one-line preview, expands to full text", async () => { const long = `${"x".repeat(200)}`; render( , ); const details = screen .getByText("result") .closest("details") as HTMLDetailsElement; expect(details.open).toBe(false); expect(details.textContent).toContain("…"); await userEvent.click(screen.getByText("result")); expect(details.open).toBe(true); expect(details.textContent).toContain(long); }); it("toolResult with short text keeps full one-line preview", () => { render( , ); const details = screen .getByText("result") .closest("details") as HTMLDetailsElement; expect(details.textContent).toContain("short out"); expect(details.textContent).not.toContain("…"); }); it("flattens whitespace in previews", () => { render( , ); expect(screen.getAllByText("a b c").length).toBeGreaterThan(0); }); it("assistant tool calls attach tool cards", async () => { const tools = new Map([ [ "c1", tool({ id: "c1", name: "bash", args: "ls -la", running: false, isError: false, preview: "file", }), ], ]); render( , ); expect(screen.getByText("🛠 bash")).toBeInTheDocument(); expect(screen.getByText("finished")).toBeInTheDocument(); const summary = screen .getByText("🛠 bash") .closest("summary") as HTMLElement; const card = summary.closest("details") as HTMLDetailsElement; expect(card.open).toBe(false); await userEvent.click(summary); expect(card.open).toBe(true); expect(card.textContent).toContain("ls -la"); expect(card.textContent).toContain("file"); }); it("tool card status variants: running, error, done", () => { const tools = new Map([ ["c1", tool({ id: "c1", running: true })], ["c2", tool({ id: "c2", running: false, isError: true })], ["c3", tool({ id: "c3", running: false, isError: false })], ]); render( ({ id, name: `t-${id}`, argsJson: "{}", })), })} tools={tools} />, ); expect(screen.getByText("working…")).toBeInTheDocument(); expect(screen.getByText("error")).toBeInTheDocument(); expect(screen.getByText("done")).toBeInTheDocument(); }); it("tool call with no matching state renders no card", () => { const { container } = render( , ); expect(container.querySelectorAll(".tool-card")).toHaveLength(0); }); it("thinking block only for non-empty thinking", async () => { render(); const details = screen .getByText("thinking") .closest("details") as HTMLDetailsElement; await userEvent.click(screen.getByText("thinking")); expect(details.open).toBe(true); expect(details.textContent).toContain("because"); const { container } = render( , ); expect(container.querySelector(".thinking")).toBeNull(); }); it("streaming bubble shows the caret", () => { const { container } = render( , ); expect(container.querySelector(".stream-caret")).not.toBeNull(); }); }); describe("TypingIndicator", () => { it("renders three dots with aria-live", () => { const { container } = render(); expect(container.querySelector('[aria-live="polite"]')).not.toBeNull(); expect(container.querySelectorAll(".dot")).toHaveLength(3); }); }); describe("ChatStream", () => { it("renders messages and typing indicator while busy with no open stream", () => { const { container, rerender } = render( stream({ messages: [msg({ key: "a", text: "one" })], busy: true }), ); expect(container.querySelector(".typing")).not.toBeNull(); rerender( stream({ messages: [ msg({ key: "a", text: "one" }), msg({ key: "b", streaming: true }), ], busy: true, }), ); expect(container.querySelector(".typing")).toBeNull(); rerender( stream({ messages: [msg({ key: "a", text: "one" })], busy: false }), ); expect(container.querySelector(".typing")).toBeNull(); }); it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => { const { container, rerender } = render( stream({ messages: [msg({ key: "a" })], busy: false }), ); const scroller = container.querySelector(".chat-scroll") as HTMLElement; Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 1000, }); Object.defineProperty(scroller, "clientHeight", { configurable: true, value: 300, }); scroller.dispatchEvent(new Event("scroll")); // pinned: scrollTop at bottom scroller.scrollTop = 700; Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 700, }); scroller.dispatchEvent(new Event("scroll")); rerender( stream({ messages: [msg({ key: "a" }), msg({ key: "b" })], busy: false, }), ); expect(scroller.scrollTop).toBe(1000); // scroll far up -> unpin Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 0, }); scroller.dispatchEvent(new Event("scroll")); rerender( stream({ messages: [msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })], busy: false, }), ); expect(scroller.scrollTop).toBe(0); // scroll near bottom (within 80px) -> pinned again Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 940, }); scroller.dispatchEvent(new Event("scroll")); rerender( stream({ messages: [ msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" }), msg({ key: "d" }), ], busy: false, }), ); expect(scroller.scrollTop).toBe(1000); }); it("load-older button renders only when an older page exists and fires on click", () => { const onOlder = vi.fn(); const { rerender } = render( stream({ messages: [msg({ key: "a" })], busy: false }), ); expect( screen.queryByRole("button", { name: "Load older messages" }), ).toBeNull(); rerender( stream({ messages: [msg({ key: "a" })], busy: false, hasOlder: true, onOlder, }), ); const btn = screen.getByRole("button", { name: "Load older messages" }); fireEvent.click(btn); expect(onOlder).toHaveBeenCalledTimes(1); rerender( stream({ messages: [msg({ key: "a" })], busy: false, hasOlder: true, loadingOlder: true, onOlder, }), ); expect( screen.getByRole("button", { name: "Load older messages" }), ).toBeDisabled(); }); }); describe("renderMarkdown", () => { const md = (text: string, query = ""): HTMLElement => { const { container } = render(
{renderMarkdown(text, query)}
, ); return container.querySelector('[data-testid="md"]') as HTMLElement; }; it("plain text passes through unchanged with no element wrappers", () => { const el = md("hello world"); expect(el.textContent).toBe("hello world"); expect(el.children).toHaveLength(0); }); it("``` fences render as pre.md-code with the fenced body", () => { const el = md("before\n```js\nlet a = 1\n```\nafter"); const pre = el.querySelector("pre.md-code"); expect(pre).not.toBeNull(); expect(pre?.textContent).toBe("let a = 1"); expect(el.textContent).toContain("before"); expect(el.textContent).toContain("after"); expect(el.querySelectorAll("pre")).toHaveLength(1); }); it("an unterminated fence (streaming) renders the tail as code", () => { const el = md("head\n```\ncode tail"); const pre = el.querySelector("pre.md-code"); expect(pre?.textContent).toBe("code tail"); expect(el.textContent).toContain("head"); }); it("`inline` code renders as ", () => { const el = md("use `npm run` now"); expect(el.querySelector("code")?.textContent).toBe("npm run"); expect(el.textContent).toBe("use npm run now"); }); it("**bold** and *italic*", () => { const el = md("**b** and *i*"); expect(el.querySelector("strong")?.textContent).toBe("b"); expect(el.querySelector("em")?.textContent).toBe("i"); // leading and trailing plain segments (empty ones are skipped) const edges = md("**b**"); expect(edges.children).toHaveLength(1); expect(edges.textContent).toBe("b"); }); it("[t](http…) links open in a new tab with noopener", () => { const el = md("see [docs](https://x.dev/a?b=1) and [m](http://y.org)"); const links = el.querySelectorAll("a"); expect(links).toHaveLength(2); expect(links[0]).toHaveAttribute("href", "https://x.dev/a?b=1"); expect(links[0]).toHaveAttribute("target", "_blank"); expect(links[0]?.getAttribute("rel")).toContain("noopener"); expect(links[0]?.textContent).toBe("docs"); }); it("javascript: (and other non-http) links are dropped, text kept", () => { const el = md("[x](javascript:alert(1)) and [y](data:text/plain,hi)"); expect(el.querySelector("a")).toBeNull(); expect(el.textContent).toContain("[x](javascript:alert(1))"); expect(el.textContent).toContain("[y](data:text/plain,hi)"); }); it("query highlights match as mark.hit, also inside bold", () => { const el = md("find the needle here and **needle bold**", "NEEDLE"); const marks = el.querySelectorAll("mark.hit"); expect(marks).toHaveLength(2); expect(marks[0]?.textContent).toBe("needle"); expect(el.querySelector("strong mark.hit")).not.toBeNull(); }); it("a query with no match renders plain text", () => { const el = md("nothing to see", "zzz"); expect(el.querySelector("mark")).toBeNull(); expect(el.textContent).toBe("nothing to see"); }); it("code fences and inline code stay unhighlighted", () => { const el = md("```\nneedle\n```\nand `needle` done", "needle"); expect(el.querySelectorAll("mark.hit")).toHaveLength(0); expect(el.textContent).toContain("needle"); }); it("renders inside a bubble text node", () => { render( , ); expect(screen.getByText("b").tagName).toBe("STRONG"); expect(screen.getByText("c").tagName).toBe("CODE"); expect(screen.getByText("l")).toHaveAttribute("href", "https://a.dev"); }); }); describe("ChatStream search overlay", () => { const needles = (): ChatMessage[] => [ msg({ key: "a", role: "user", text: "alpha NEEDLE one" }), msg({ key: "b", role: "assistant", text: "no match here" }), msg({ key: "c", role: "assistant", text: "second needle" }), ]; beforeEach(() => { Element.prototype.scrollIntoView = vi.fn(); }); afterEach(() => { delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; }); it("filters messages case-insensitively over text, shows n/m and marks hits", () => { render(stream({ messages: needles(), busy: false, searchOpen: true })); const input = screen.getByLabelText("Search"); fireEvent.change(input, { target: { value: "needle" } }); expect(screen.getByText("1/2")).toBeInTheDocument(); expect(document.querySelectorAll("mark.hit")).toHaveLength(2); }); it("matches also thinking and tool names, not just text", () => { const messages = [ msg({ key: "t", text: "x", thinking: "hidden thought" }), msg({ key: "u", text: "plain", toolCalls: [{ id: "c1", name: "deploy", argsJson: "{}" }], }), msg({ key: "v", text: "nothing" }), ]; const { rerender } = render( stream({ messages, busy: false, searchOpen: true }), ); fireEvent.change(screen.getByLabelText("Search"), { target: { value: "hidden" }, }); expect(screen.getByText("1/1")).toBeInTheDocument(); rerender( stream({ messages: [messages[1]!, messages[2]!], busy: false, searchOpen: true, }), ); fireEvent.change(screen.getByLabelText("Search"), { target: { value: "deploy" }, }); expect(screen.getByText("1/1")).toBeInTheDocument(); }); it("Enter/Shift+Enter cycle matches and scroll them into view", () => { render(stream({ messages: needles(), busy: false, searchOpen: true })); const input = screen.getByLabelText("Search"); fireEvent.change(input, { target: { value: "needle" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(screen.getByText("2/2")).toBeInTheDocument(); expect(Element.prototype.scrollIntoView).toHaveBeenCalled(); expect( document.querySelector('[data-key="c"]')?.closest(".msg-current"), ).not.toBeNull(); fireEvent.keyDown(input, { key: "Enter" }); // wraps around expect(screen.getByText("1/2")).toBeInTheDocument(); fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); // back to 2 expect(screen.getByText("2/2")).toBeInTheDocument(); }); it("no matches shows 0/0 and Enter is a no-op", () => { render(stream({ messages: needles(), busy: false, searchOpen: true })); const input = screen.getByLabelText("Search"); fireEvent.change(input, { target: { value: "zzz" } }); expect(screen.getByText("0/0")).toBeInTheDocument(); fireEvent.keyDown(input, { key: "Enter" }); expect(screen.getByText("0/0")).toBeInTheDocument(); }); it("empty query clears highlights and count", () => { const { rerender } = render( stream({ messages: needles(), busy: false, searchOpen: true }), ); const input = screen.getByLabelText("Search"); fireEvent.change(input, { target: { value: "needle" } }); expect(document.querySelectorAll("mark.hit")).toHaveLength(2); fireEvent.change(input, { target: { value: "" } }); expect(document.querySelectorAll("mark.hit")).toHaveLength(0); expect(screen.queryByText(/^\d+\/\d+$/)).toBeNull(); // same when reopened after closing with a query typed fireEvent.change(input, { target: { value: "needle" } }); rerender(stream({ messages: needles(), busy: false, searchOpen: false })); rerender(stream({ messages: needles(), busy: false, searchOpen: true })); const reopened = screen.getByLabelText("Search") as HTMLInputElement; expect(reopened.value).toBe(""); expect(document.querySelectorAll("mark.hit")).toHaveLength(0); }); it("Esc closes the overlay via onSearchClose", () => { const onSearchClose = vi.fn(); render( stream({ messages: needles(), busy: false, searchOpen: true, onSearchClose, }), ); fireEvent.keyDown(screen.getByLabelText("Search"), { key: "Escape" }); expect(onSearchClose).toHaveBeenCalledTimes(1); }); it("a shrunken match set clamps the cursor", () => { const { rerender } = render( stream({ messages: needles(), busy: false, searchOpen: true }), ); const input = screen.getByLabelText("Search"); fireEvent.change(input, { target: { value: "needle" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(screen.getByText("2/2")).toBeInTheDocument(); // live events remove the second match under the cursor rerender( stream({ messages: [needles()[0]!, needles()[1]!], busy: false, searchOpen: true, }), ); expect(screen.getByText("1/1")).toBeInTheDocument(); }); }); describe("ChatStream scroll FAB", () => { function sized(h: number, top: number): void { const scroller = document.querySelector(".chat-scroll") as HTMLElement; Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: h, }); Object.defineProperty(scroller, "clientHeight", { configurable: true, value: 300, }); Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: top, }); fireEvent.scroll(scroller); } it("hidden while near the bottom, appears past 400px away", () => { render(stream({ messages: [msg({ key: "a" })], busy: false })); sized(2000, 1700); // 0px away expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); sized(2000, 1301); // 399px away expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); sized(2000, 1299); // 401px away expect( screen.getByRole("button", { name: "Jump to latest" }), ).toBeInTheDocument(); }); it("clicking the FAB pins and jumps to the bottom", () => { const { rerender } = render( stream({ messages: [msg({ key: "a" })], busy: false }), ); sized(2000, 0); const fab = screen.getByRole("button", { name: "Jump to latest" }); fireEvent.click(fab); const scroller = document.querySelector(".chat-scroll") as HTMLElement; expect(scroller.scrollTop).toBe(2000); expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); // pinned again: a new message keeps the view at the bottom (no FAB) rerender( stream({ messages: [msg({ key: "a" }), msg({ key: "b" })], busy: false }), ); expect(scroller.scrollTop).toBe(2000); expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); }); }); describe("ChatStream timestamps", () => { const TS: number = Date.UTC(2024, 0, 2, 10, 30); it("hidden by default, title attr on the row always", () => { const { container, rerender } = render( stream({ messages: [msg({ key: "a", ts: TS })], busy: false, showTs: false, }), ); expect(container.querySelector(".ts")).toBeNull(); expect(container.querySelector(".bubble-row")).toHaveAttribute( "title", "2024-01-02T10:30:00.000Z", ); rerender( stream({ messages: [msg({ key: "a", ts: TS })], busy: false, showTs: true, }), ); expect(container.querySelector(".ts")).not.toBeNull(); expect(container.querySelector(".ts")).toHaveAttribute( "title", "2024-01-02T10:30:00.000Z", ); expect(container.querySelector(".ts")?.textContent).toMatch(/^\d{2}:\d{2}/); }); it("ts 0 (streaming) renders nothing", () => { const { container } = render( stream({ messages: [msg({ key: "a", ts: 0, streaming: true })], busy: false, showTs: true, }), ); expect(container.querySelector(".ts")).toBeNull(); expect(container.querySelector(".bubble-row")).not.toHaveAttribute("title"); }); }); describe("copy button", () => { it("copy button writes message text and flashes copied", async () => { vi.useFakeTimers(); const writeText = vi.fn(() => Promise.resolve()); Object.assign(navigator, { clipboard: { writeText } }); render( , ); const btn = screen.getByRole("button", { name: "Copy message" }); fireEvent.click(btn); expect(writeText).toHaveBeenCalledWith("copy me"); await vi.waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument(), ); // feedback clears after COPY_FEEDBACK_MS (H) act(() => { vi.advanceTimersByTime(1200); }); expect(screen.getByText("copy")).toBeInTheDocument(); vi.useRealTimers(); }); it("user bubbles get a copy button, toolResults do not", () => { const { rerender } = render( , ); expect( screen.getByRole("button", { name: "Copy message" }), ).toBeInTheDocument(); rerender( , ); expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull(); }); }); describe("pretty tool args", () => { const tool = (args: string): ToolState => ({ id: "t1", name: "bash", args, running: false, isError: false, preview: "out", }); it("bash command renders as the bare command, no JSON braces", () => { render(); expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la"); expect(document.querySelector(".tool-args")?.textContent).not.toContain('"command"'); }); it("priority ordering puts command first even when not first in JSON", () => { const entries = toolArgsEntries('{"path":"a.go","command":"go build ./..."}'); expect(entries[0]?.v).toBe("go build ./..."); expect(entries[1]?.k).toBe("path"); }); it("single string arg renders label-less; multi-field keeps labels", () => { render(); expect(document.querySelector(".tool-summary")?.textContent).toBe("src/main.ts"); expect(document.querySelector(".tool-args .arg-k")).toBeNull(); }); it("non-JSON args pass through raw", () => { const entries = toolArgsEntries("just text"); expect(entries).toEqual([{ k: null, v: "just text" }]); }); it("JSON string arg unwraps", () => { const entries = toolArgsEntries('"plain string"'); expect(entries).toEqual([{ k: null, v: "plain string" }]); }); it("non-string values are pretty JSON", () => { const entries = toolArgsEntries('{"offset":1}'); expect(entries[0]?.v).toBe("1"); }); }); describe("tool summary + diff", () => { const tool = (args: string, name = "bash"): ToolState => ({ id: "t1", name, args, running: false, isError: false, preview: "", }); it("bash summary shows the command", () => { render( , ); expect(screen.getByText("🛠 bash")).toBeInTheDocument(); expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la"); }); it("read summary shows the path", () => { render( , ); expect(document.querySelector(".tool-summary")?.textContent).toBe("src/main.ts"); }); it("long summary is truncated with ellipsis", () => { const long: string = "x".repeat(300); expect(toolSummaryText(`{"command":"${long}"}`)).toBe(long); render( , ); const summary = document.querySelector(".tool-summary"); expect(summary?.textContent?.endsWith("…")).toBe(true); }); it("edit tool renders a line diff of old/new text", () => { render( , ); expect(document.querySelector(".diff-del")?.textContent).toContain("-const a = 1;"); expect(document.querySelector(".diff-add")?.textContent).toContain("+const a = 3;"); expect(document.querySelector(".diff-ctx")?.textContent).toContain(" const b = 2;"); expect(document.querySelector(".tool-args")).toBeNull(); }); it("write tool renders content as added lines", () => { render( , ); const adds = document.querySelectorAll(".diff-add"); expect(adds).toHaveLength(2); expect(adds[0]?.textContent).toContain("+hello"); expect(adds[1]?.textContent).toContain("+world"); }); }); describe("lineDiff", () => { it("identical text is all context", () => { const d = lineDiff("a\nb", "a\nb"); expect(d).toEqual([ { kind: "ctx", text: "a" }, { kind: "ctx", text: "b" }, ]); }); it("single line change is del + add", () => { const d = lineDiff("a\nb\nc", "a\nx\nc"); expect(d).toEqual([ { kind: "ctx", text: "a" }, { kind: "del", text: "b" }, { kind: "add", text: "x" }, { kind: "ctx", text: "c" }, ]); }); it("pure addition and deletion", () => { expect(lineDiff("a", "a\nb")).toEqual([ { kind: "ctx", text: "a" }, { kind: "add", text: "b" }, ]); expect(lineDiff("a\nb", "a")).toEqual([ { kind: "ctx", text: "a" }, { kind: "del", text: "b" }, ]); }); it("oversized blocks fall back to del-all + add-all", () => { const a = Array.from({ length: 401 }, (_, i) => `l${i}`).join("\n"); const d = lineDiff(a, "x"); expect(d.filter((l) => l.kind === "del")).toHaveLength(401); expect(d.filter((l) => l.kind === "add")).toHaveLength(1); }); });