Files
lvmh/web/src/ChatStream.test.tsx
T

938 lines
27 KiB
TypeScript

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>): ChatMessage {
return {
key: `k-${Math.random()}`,
role: "assistant",
text: "",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
ts: 0,
...partial,
};
}
const tool = (p: Partial<ToolState>): 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 (
<ChatStream
messages={p.messages}
tools={new Map()}
busy={p.busy}
hasOlder={p.hasOlder ?? false}
loadingOlder={p.loadingOlder ?? false}
onLoadOlder={p.onOlder ?? (() => 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(
<Bubble
msg={msg({ role: "user", text: "hi there" })}
tools={new Map()}
/>,
);
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(
<Bubble
msg={msg({ role: "system", text: "model not found", notice: true })}
tools={new Map()}
/>,
);
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(
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
);
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(
<Bubble
msg={msg({ role: "toolResult", text: long })}
tools={new Map()}
/>,
);
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(
<Bubble
msg={msg({ role: "toolResult", text: "short out" })}
tools={new Map()}
/>,
);
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(
<Bubble
msg={msg({ role: "toolResult", text: "a\n\n b c" })}
tools={new Map()}
/>,
);
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
});
it("assistant tool calls attach tool cards", async () => {
const tools = new Map<string, ToolState>([
[
"c1",
tool({
id: "c1",
name: "bash",
args: "ls -la",
running: false,
isError: false,
preview: "file",
}),
],
]);
render(
<Bubble
msg={msg({
role: "assistant",
text: "finished",
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
})}
tools={tools}
/>,
);
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<string, ToolState>([
["c1", tool({ id: "c1", running: true })],
["c2", tool({ id: "c2", running: false, isError: true })],
["c3", tool({ id: "c3", running: false, isError: false })],
]);
render(
<Bubble
msg={msg({
role: "assistant",
toolCalls: ["c1", "c2", "c3"].map((id) => ({
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(
<Bubble
msg={msg({
role: "assistant",
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
})}
tools={new Map()}
/>,
);
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
});
it("thinking block only for non-empty thinking", async () => {
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
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(
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
);
expect(container.querySelector(".thinking")).toBeNull();
});
it("streaming bubble shows the caret", () => {
const { container } = render(
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
);
expect(container.querySelector(".stream-caret")).not.toBeNull();
});
});
describe("TypingIndicator", () => {
it("renders three dots with aria-live", () => {
const { container } = render(<TypingIndicator />);
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(
<div data-testid="md">{renderMarkdown(text, query)}</div>,
);
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 <code>", () => {
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(
<Bubble
msg={msg({ text: "**b** `c` [l](https://a.dev)" })}
tools={new Map()}
/>,
);
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(
<Bubble
msg={{
key: "k",
role: "assistant",
text: "copy me",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
ts: 0,
}}
tools={new Map()}
/>,
);
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(
<Bubble
msg={{
key: "u",
role: "user",
text: "hi",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
ts: 0,
}}
tools={new Map()}
/>,
);
expect(
screen.getByRole("button", { name: "Copy message" }),
).toBeInTheDocument();
rerender(
<Bubble
msg={{
key: "t",
role: "toolResult",
text: "r",
thinking: null,
toolCalls: [],
toolCallId: "c1",
streaming: false,
ts: 0,
}}
tools={new Map()}
/>,
);
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(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])} />);
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(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])} />);
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(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
})}
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
/>,
);
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
});
it("read summary shows the path", () => {
render(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }],
})}
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
/>,
);
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(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
})}
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
/>,
);
const summary = document.querySelector(".tool-summary");
expect(summary?.textContent?.endsWith("…")).toBe(true);
});
it("edit tool renders a line diff of old/new text", () => {
render(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "edit", argsJson: "{}" }],
})}
tools={new Map([
[
"t1",
tool(
'{"path":"a.ts","edits":[{"oldText":"const a = 1;\\nconst b = 2;","newText":"const a = 3;\\nconst b = 2;"}]}',
"edit",
),
],
])}
/>,
);
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(
<Bubble
msg={msg({
toolCalls: [{ id: "t1", name: "write", argsJson: "{}" }],
})}
tools={new Map([
["t1", tool('{"path":"new.ts","content":"hello\\nworld"}', "write")],
])}
/>,
);
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);
});
});