test: ≥95% coverage — daemon 96.9% (go), web 95.5-99% (vitest 142 tests); store.ts hooks-order crash fix
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatMessage, ToolState } from "./derive";
|
||||
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
||||
|
||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||
return {
|
||||
key: `k-${Math.random()}`,
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: null,
|
||||
toolCalls: [],
|
||||
toolCallId: null,
|
||||
streaming: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const tool = (p: Partial<ToolState>): ToolState => ({
|
||||
id: "c1",
|
||||
name: "bash",
|
||||
args: "",
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "",
|
||||
...p,
|
||||
});
|
||||
|
||||
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("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(
|
||||
<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy />
|
||||
);
|
||||
expect(container.querySelector(".typing")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<ChatStream messages={[msg({ key: "a", text: "one" }), msg({ key: "b", streaming: true })]} tools={new Map()} busy />
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
rerender(<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} 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(<ChatStream messages={[msg({ key: "a" })]} tools={new Map()} 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(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" })]} tools={new Map()} 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(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]} tools={new Map()} 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(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" }), msg({ key: "d" })]} tools={new Map()} busy={false} />);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user