/** * @file ConversationView.follow.test.tsx * @description A chat sticks to its end: when it grows or is shown again * after being hidden (a size change, which fires no scroll event) it scrolls * to the newest while the reader is at the end, leaves a reader who scrolled * up alone, and a send (`follow`) brings it back to the end. * @author Nguyễn Ngọc Trí Vĩ */ import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ConversationView } from "../ConversationView"; vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript: vi.fn(async () => ({ messages: [ { type: "user", content: [{ type: "text", text: "hello" }], timestamp: "2026-10-09T00:00:00Z", }, ], total: 1, has_more: false, first_line: 1, last_line: 1, })), }, }, }; }); let resize: (() => void) | null = null; const original = globalThis.ResizeObserver; beforeEach(() => { resize = null; // @ts-expect-error test stub globalThis.ResizeObserver = class { constructor(cb: () => void) { resize = cb; } observe() {} disconnect() {} }; vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { globalThis.ResizeObserver = original; vi.restoreAllMocks(); }); /** The scroll box, with the layout jsdom does not compute. */ function box(height: number) { const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement; Object.defineProperty(el, "scrollHeight", { configurable: true, value: height }); Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 }); Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 }); return el; } describe("ConversationView sticks to its end", () => { it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => { const view = render(); await screen.findByText("hello"); const el = box(1000); // Shown again after display:none, or taller: only a resize says so. act(() => resize?.()); expect(el.scrollTop).toBe(1000); // The reader scrolls up: growing no longer pulls them down. el.scrollTop = 100; fireEvent.scroll(el); Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 }); act(() => resize?.()); expect(el.scrollTop).toBe(100); // They send a message: back to the end, and it sticks again. view.rerender(); expect(el.scrollTop).toBe(1400); Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 }); act(() => resize?.()); expect(el.scrollTop).toBe(1600); }); });