web: integrate QoL slices 1-5 (model switch/rename, history deletes, usage stats, repo prepare+badges+imageUsed, markdown/search/FAB/timestamps) — 263 tests, ≥95% all metrics

This commit is contained in:
Raphael Westphal
2026-08-19 10:35:40 +02:00
parent 698b742cb3
commit c8ad68aaea
17 changed files with 2618 additions and 170 deletions
+374 -21
View File
@@ -1,8 +1,12 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatMessage, ToolState } from "./derive";
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
import ChatStream, {
Bubble,
TypingIndicator,
renderMarkdown,
} from "./ChatStream";
function msg(partial: Partial<ChatMessage>): ChatMessage {
return {
@@ -13,6 +17,7 @@ function msg(partial: Partial<ChatMessage>): ChatMessage {
toolCalls: [],
toolCallId: null,
streaming: false,
ts: 0,
...partial,
};
}
@@ -27,6 +32,31 @@ const tool = (p: Partial<ToolState>): ToolState => ({
...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(
@@ -39,6 +69,18 @@ describe("Bubble", () => {
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()} />,
@@ -195,25 +237,6 @@ describe("TypingIndicator", () => {
});
describe("ChatStream", () => {
function stream(p: {
messages: ChatMessage[];
busy: boolean;
hasOlder?: boolean;
loadingOlder?: boolean;
onOlder?: () => void;
}): 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)}
/>
);
}
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 }),
@@ -339,6 +362,333 @@ describe("ChatStream", () => {
});
});
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();
@@ -354,6 +704,7 @@ describe("copy button", () => {
toolCalls: [],
toolCallId: null,
streaming: false,
ts: 0,
}}
tools={new Map()}
/>,
@@ -384,6 +735,7 @@ describe("copy button", () => {
toolCalls: [],
toolCallId: null,
streaming: false,
ts: 0,
}}
tools={new Map()}
/>,
@@ -401,6 +753,7 @@ describe("copy button", () => {
toolCalls: [],
toolCallId: "c1",
streaming: false,
ts: 0,
}}
tools={new Map()}
/>,