Files
llm-wiki/workbench/web/test/chat-panel-auto-scroll.test.tsx
T
2026-07-12 21:26:08 +08:00

312 lines
11 KiB
TypeScript

import { fireEvent } from "@testing-library/react";
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import React from "react";
import { ChatPanel } from "../src/components/ChatPanel";
import { TooltipProvider } from "../src/components/ui/tooltip";
import { changeText, click, pressKey, render, screen, waitFor } from "./render";
const originalFetch = globalThis.fetch;
let restoreScrollBox: (() => void) | null = null;
type ChatPanelProps = React.ComponentProps<typeof ChatPanel>;
type StreamEvent = { event: string; data: string };
afterEach(() => {
restoreScrollBox?.();
restoreScrollBox = null;
globalThis.fetch = originalFetch;
});
describe("ChatPanel auto scroll", () => {
it("scrolls to the bottom on existing messages and after a streamed reply when follow is active", async () => {
installChatFetch([
{ event: "text_delta", data: "第一段回复" },
{ event: "done", data: "{}" },
]);
const scrollBox = installChatScrollerLayoutMock({
clientHeight: 320,
scrollHeight: 960,
scrollTop: 0,
});
renderChatPanel({
initialMessages: [
{ id: "u1", role: "user", content: "第一条", tools: [] },
{ id: "a1", role: "assistant", content: "第一条回复", tools: [] },
],
});
const scroller = chatScroller();
await waitFor(() => assert.equal(scroller.scrollTop, 640));
scrollBox.update({ scrollHeight: 1280 });
await changeText(screen.getByPlaceholderText(/写下想法/), "继续");
await pressKey(screen.getByPlaceholderText(/写下想法/), "Enter", { metaKey: true });
await waitFor(() => assert.equal(scroller.scrollTop, 960));
assert.ok(scrollBox.calls.length >= 2);
const assistantBubbles = screen.getAllByLabelText("助手气泡");
assert.match(assistantBubbles.at(-1)?.textContent ?? "", /第一段回复/);
assert.equal(screen.queryByRole("button", { name: "回到底部" }), null);
});
it("pauses follow when the user scrolls up and restores with the arrow button", async () => {
const promptStream = createControlledSseStream();
installChatFetch(promptStream.stream);
const scrollBox = installChatScrollerLayoutMock({
clientHeight: 300,
scrollHeight: 1000,
scrollTop: 700,
});
renderChatPanel({
initialMessages: [
{ id: "u1", role: "user", content: "历史问题", tools: [] },
{ id: "a1", role: "assistant", content: "历史回答", tools: [] },
],
});
const scroller = chatScroller();
scrollBox.update({ scrollHeight: 1300 });
await changeText(screen.getByPlaceholderText(/写下想法/), "继续展开");
await pressKey(screen.getByPlaceholderText(/写下想法/), "Enter", { metaKey: true });
await waitFor(() => assert.equal(scroller.scrollTop, 1000));
scroller.scrollTop = 120;
fireEvent.scroll(scroller);
const returnButton = await screen.findByRole("button", { name: "回到底部" });
assert.equal(returnButton.textContent, "");
scrollBox.update({ scrollHeight: 1500 });
promptStream.send({ event: "text_delta", data: "新的长回复" });
promptStream.send({ event: "done", data: "{}" });
promptStream.close();
await waitFor(() => {
const assistantBubbles = screen.getAllByLabelText("助手气泡");
assert.match(assistantBubbles.at(-1)?.textContent ?? "", /新的长回复/);
});
assert.equal(scroller.scrollTop, 120);
assert.ok(screen.getByRole("button", { name: "回到底部" }));
await click(screen.getByRole("button", { name: "回到底部" }));
await waitFor(() => assert.equal(scroller.scrollTop, 1200));
assert.equal(screen.queryByRole("button", { name: "回到底部" }), null);
});
it("restores follow when the user manually scrolls back near the bottom", async () => {
const promptStream = createControlledSseStream();
installChatFetch(promptStream.stream);
const scrollBox = installChatScrollerLayoutMock({
clientHeight: 300,
scrollHeight: 1300,
scrollTop: 1000,
});
renderChatPanel({
initialMessages: [
{ id: "u1", role: "user", content: "历史问题", tools: [] },
{ id: "a1", role: "assistant", content: "历史回答", tools: [] },
],
});
const scroller = chatScroller();
await changeText(screen.getByPlaceholderText(/写下想法/), "继续展开");
await pressKey(screen.getByPlaceholderText(/写下想法/), "Enter", { metaKey: true });
await waitFor(() => assert.equal(scroller.scrollTop, 1000));
scroller.scrollTop = 200;
fireEvent.scroll(scroller);
assert.ok(await screen.findByRole("button", { name: "回到底部" }));
scroller.scrollTop = 960;
fireEvent.scroll(scroller);
await waitFor(() => assert.equal(screen.queryByRole("button", { name: "回到底部" }), null));
scrollBox.update({ scrollHeight: 1600 });
promptStream.send({ event: "text_delta", data: "继续增长的回复" });
promptStream.close();
await waitFor(() => assert.equal(scroller.scrollTop, 1300));
});
it("keeps the return-to-bottom control as a themed icon-only button", () => {
const css = readFileSync(resolve(import.meta.dirname, "../src/index.css"), "utf8");
assert.match(css, /\.chat-input-area\s*\{[\s\S]*position:\s*relative/);
assert.match(css, /\.chat-scroll-bottom\s*\{/);
assert.match(css, /\.chat-scroll-bottom[\s\S]*justify-content:\s*center/);
assert.match(css, /\.chat-scroll-bottom-btn\s*\{/);
assert.match(css, /\.chat-scroll-bottom-btn[\s\S]*border-radius:\s*999px/);
assert.match(css, /\.chat-scroll-bottom-btn[\s\S]*var\(--app-surface\)/);
assert.match(css, /\.chat-scroll-bottom-btn[\s\S]*box-shadow/);
assert.match(css, /\.chat-scroll-bottom-btn:hover\s*\{/);
assert.match(css, /\.chat-scroll-bottom-btn:focus-visible\s*\{/);
});
});
function renderChatPanel(props: Partial<ChatPanelProps> = {}) {
return render(
<TooltipProvider>
<ChatPanel
currentKnowledgeBaseName="AI学习知识库"
currentKnowledgeBasePath="/kb"
initialMessages={[]}
{...props}
/>
</TooltipProvider>,
);
}
function chatScroller(): HTMLDivElement {
const element = document.querySelector(".chat-messages");
assert.ok(element instanceof HTMLElement);
return element as HTMLDivElement;
}
function installChatScrollerLayoutMock(
initialMetrics: { clientHeight: number; scrollHeight: number; scrollTop: number },
) {
const metrics = { ...initialMetrics };
const calls: number[] = [];
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
const originalScrollTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
const originalScrollTo = HTMLElement.prototype.scrollTo as ((...args: unknown[]) => void) | undefined;
const isChatScroller = (element: Element) =>
element instanceof HTMLElement && element.classList.contains("chat-messages");
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
if (isChatScroller(this)) return metrics.clientHeight;
return originalClientHeight?.get?.call(this) ?? 0;
},
});
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
if (isChatScroller(this)) return metrics.scrollHeight;
return originalScrollHeight?.get?.call(this) ?? 0;
},
});
Object.defineProperty(HTMLElement.prototype, "scrollTop", {
configurable: true,
get() {
if (isChatScroller(this)) return metrics.scrollTop;
return originalScrollTop?.get?.call(this) ?? 0;
},
set(value: number) {
if (isChatScroller(this)) {
metrics.scrollTop = value;
return;
}
originalScrollTop?.set?.call(this, value);
},
});
HTMLElement.prototype.scrollTo = function scrollTo(options?: ScrollToOptions | number, y?: number) {
if (!isChatScroller(this)) {
originalScrollTo?.call(this, options, y);
return;
}
const top = typeof options === "number" ? y ?? options : options?.top ?? metrics.scrollTop;
const nextTop = clampScrollTop(Number(top), metrics);
calls.push(nextTop);
metrics.scrollTop = nextTop;
};
const restore = () => {
restoreDescriptor(HTMLElement.prototype, "clientHeight", originalClientHeight);
restoreDescriptor(HTMLElement.prototype, "scrollHeight", originalScrollHeight);
restoreDescriptor(HTMLElement.prototype, "scrollTop", originalScrollTop);
if (originalScrollTo) HTMLElement.prototype.scrollTo = originalScrollTo as typeof HTMLElement.prototype.scrollTo;
else Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
};
restoreScrollBox = restore;
return {
calls,
metrics,
restore,
update(nextMetrics: Partial<typeof metrics>) {
Object.assign(metrics, nextMetrics);
},
};
}
function clampScrollTop(
top: number,
metrics: { clientHeight: number; scrollHeight: number; scrollTop: number },
) {
if (!Number.isFinite(top)) return metrics.scrollTop;
return Math.max(0, Math.min(top, Math.max(0, metrics.scrollHeight - metrics.clientHeight)));
}
function restoreDescriptor(
prototype: HTMLElement,
key: "clientHeight" | "scrollHeight" | "scrollTop",
descriptor: PropertyDescriptor | undefined,
) {
if (descriptor) Object.defineProperty(prototype, key, descriptor);
else Reflect.deleteProperty(prototype, key);
}
function installChatFetch(eventsOrStream: StreamEvent[] | ReadableStream<Uint8Array>) {
globalThis.fetch = (async (input) => {
const url = typeof input === "string" ? input : input instanceof Request ? input.url : String(input);
if (url.includes("/api/commands")) {
return jsonResponse({ ok: true, items: [] });
}
if (url.includes("/api/prompt")) {
const body = Array.isArray(eventsOrStream) ? sseStream(eventsOrStream) : eventsOrStream;
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return jsonResponse({ ok: true });
}) as typeof fetch;
}
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function sseStream(events: StreamEvent[]) {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const item of events) {
controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${item.data}\n\n`));
}
controller.close();
},
});
}
function createControlledSseStream() {
const encoder = new TextEncoder();
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
return {
stream: new ReadableStream<Uint8Array>({
start(activeController) {
controller = activeController;
},
}),
send(item: StreamEvent) {
assert.ok(controller);
controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${item.data}\n\n`));
},
close() {
assert.ok(controller);
controller.close();
},
};
}