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:
Raphael Westphal
2026-08-18 14:56:08 +02:00
parent ecb91e941c
commit 0ecef2a9bd
30 changed files with 6305 additions and 54 deletions
+179
View File
@@ -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);
});
});