938 lines
27 KiB
TypeScript
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);
|
|
});
|
|
});
|