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:
+374
-21
@@ -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()}
|
||||
/>,
|
||||
|
||||
Reference in New Issue
Block a user