import assert from "node:assert/strict"; import test from "node:test"; import React, { useRef } from "react"; import { isScrollKeyGesture, useConversationScrollAnchor } from "../src/hooks/use-conversation-scroll-anchor.ts"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; Object.assign(globalThis, { React }); /** * Real React lifecycle (createRoot + act, effects and refs are React's own) * around `useConversationScrollAnchor`. Only the scroller's geometry, its * listeners and ResizeObserver are faked: the host node React mounts is * decorated with scrollTop / scrollHeight / clientHeight, an event registry and * an async `scrollTo` that fires `scroll` afterwards, as a browser's smooth * scroll does. Numbers follow the CDP measurements in BUG-1042 / 1044 * (consultation rests ~198px above the bottom after a pin, rectification 94px). */ type Listener = (event: Record) => void; type FakeRow = { offsetTop: number; offsetHeight: number; getBoundingClientRect: () => { top: number }; }; type FakeScroller = { scrollTop: number; scrollHeight: number; clientHeight: number; tail: FakeRow; children: unknown[]; listeners: Map>; scrollListenerAdds: number; dispatch: (type: string, event?: Record) => void; scrollTo: (options: { top: number }) => void; }; type Anchor = ReturnType; let observeCalls = 0; const observers = new Set(); class FakeResizeObserver { readonly targets = new Set(); constructor(readonly callback: () => void) { observers.add(this); } observe(target: unknown) { observeCalls += 1; this.targets.add(target); } disconnect() { this.targets.clear(); } unobserve() {} } /** Content grew: every observer that still watches something fires. */ function resizeAll() { for (const observer of Array.from(observers)) { if (observer.targets.size) observer.callback(); } } function row(offsetTop: number, offsetHeight: number, scroller: () => FakeScroller | null): FakeRow { return { offsetTop, offsetHeight, getBoundingClientRect() { return { top: this.offsetTop - (scroller()?.scrollTop ?? 0) }; }, }; } const decorated = new WeakMap(); // The inline ref callback below runs again on every render; decorate each host node once. function decorate(node: Record, geometry: { scrollHeight: number; clientHeight: number; tail: FakeRow }): FakeScroller { const existing = decorated.get(node); if (existing) return existing; const styles = new Map(); const listeners = new Map>(); let top = 0; const scroller = node as unknown as FakeScroller; Object.defineProperty(node, "style", { configurable: true, value: { setProperty: (name: string, value: string) => styles.set(name, value), getPropertyValue: (name: string) => styles.get(name) ?? "", removeProperty: (name: string) => styles.delete(name), }, }); Object.defineProperty(node, "scrollTop", { configurable: true, get: () => top, set: (value: number) => { top = Math.max(0, Math.min(value, scroller.scrollHeight - scroller.clientHeight)); }, }); Object.assign(node, { scrollHeight: geometry.scrollHeight, clientHeight: geometry.clientHeight, tail: geometry.tail, children: [{ child: 1 }, { child: 2 }], listeners, scrollListenerAdds: 0, addEventListener(type: string, listener: Listener) { if (type === "scroll") scroller.scrollListenerAdds += 1; if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type)!.add(listener); }, removeEventListener(type: string, listener: Listener) { listeners.get(type)?.delete(listener); }, dispatch(type: string, event: Record = {}) { for (const listener of Array.from(listeners.get(type) ?? [])) listener({ target: node, ...event }); }, // Smooth scroll: the position moves later, then `scroll` fires. scrollTo({ top: next }: { top: number }) { setTimeout(() => { scroller.scrollTop = next; scroller.dispatch("scroll"); }, 0); }, getBoundingClientRect: () => ({ top: 0 }), querySelector: () => scroller.tail, querySelectorAll: () => [], }); decorated.set(node, scroller); return scroller; } function setup() { const harness = createClientLifecycleHarness(); const window = globalThis.window as unknown as Record; window.matchMedia = () => ({ matches: false }); const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver"); Object.defineProperty(globalThis, "ResizeObserver", { value: FakeResizeObserver, configurable: true, writable: true }); observers.clear(); observeCalls = 0; return { harness, async close() { await harness.close(); if (originalResizeObserver) Object.defineProperty(globalThis, "ResizeObserver", originalResizeObserver); else Reflect.deleteProperty(globalThis, "ResizeObserver"); }, }; } type SurfaceProps = { readonly loading: boolean; readonly active?: boolean; readonly resetKey: string; readonly geometry: () => { scrollHeight: number; clientHeight: number; tail: FakeRow }; readonly onScroller: (scroller: FakeScroller | null) => void; readonly onAnchor: (anchor: Anchor) => void; readonly generation?: number; }; /** * Mirrors the home page: while bootstrapping it renders only the loading * screen, then reveals `.conversation` with the same `active` and `resetKey`. * The hook is called above the gate on every render, as `page.tsx` does. */ function Surface({ loading, active = true, resetKey, geometry, onScroller, onAnchor, generation = 0 }: SurfaceProps) { const conversation = useRef(null); const anchor = useConversationScrollAnchor(conversation, active, resetKey); onAnchor(anchor); if (loading) return
; return (
{ const scroller = node ? decorate(node as unknown as Record, geometry()) : null; conversation.current = scroller as unknown as HTMLDivElement | null; onScroller(scroller); }} /> ); } const VIEWPORT = 600; const PIN_SPACE = 16; test("an existing conversation revealed after the loading screen attaches the anchor (BUG-1043)", async () => { const { harness, close } = setup(); try { let scroller: FakeScroller | null = null; let anchor!: Anchor; const history = row(1700, 276, () => scroller); const props = { resetKey: "session-1", geometry: () => ({ scrollHeight: 2000, clientHeight: VIEWPORT, tail: history }), onScroller: (value: FakeScroller | null) => { scroller = value; }, onAnchor: (value: Anchor) => { anchor = value; }, }; // Given: the page boots behind the loading screen, then reveals the same session. await harness.render(); await harness.idle(); assert.equal(scroller, null); await harness.render(); await harness.idle(); // Then: the scroll listener and the resize observer are attached to the revealed scroller, // and opening the session landed on its newest content once. const element = scroller as unknown as FakeScroller; assert.equal(element.scrollListenerAdds, 1); assert.ok(observeCalls > 0, "ResizeObserver never observed the scroller's children"); assert.equal(element.scrollTop, 2000 - VIEWPORT); // Re-rendering (every streamed token re-renders the page) does not attach twice. await harness.render(); await harness.render(); await harness.idle(); assert.equal(element.scrollListenerAdds, 1); assert.equal(element.listeners.get("scroll")?.size, 1); // When: a short question is sent. The user row lands at 2000, the reply is 80px, and the // spacer makes room for the pin; consultation rests ~198px above the bottom. const user = row(2000, 48, () => scroller); element.tail = row(2064, 80, () => scroller); element.scrollHeight = 2000 - PIN_SPACE + VIEWPORT + 198; await harness.update(() => anchor.pinLatestTurn(user as unknown as HTMLElement)); await harness.idle(); await harness.idle(); // Then: the head is pinned and, once the smooth scroll lands, a short reply shows no jump chip. assert.equal(element.scrollTop, 2000 - PIN_SPACE); assert.equal(anchor.latestBelowFold, false); assert.equal(anchor.anchored, false); } finally { await close(); } }); test("a long reply after reveal shows the jump chip and following works once pressed (BUG-1043)", async () => { const { harness, close } = setup(); try { let scroller: FakeScroller | null = null; let anchor!: Anchor; const history = row(1700, 276, () => scroller); const props = { resetKey: "session-1", geometry: () => ({ scrollHeight: 2000, clientHeight: VIEWPORT, tail: history }), onScroller: (value: FakeScroller | null) => { scroller = value; }, onAnchor: (value: Anchor) => { anchor = value; }, }; await harness.render(); await harness.render(); await harness.idle(); const element = scroller as unknown as FakeScroller; const user = row(2000, 48, () => scroller); element.tail = row(2064, 1400, () => scroller); element.scrollHeight = 2064 + 1400 + 198; await harness.update(() => anchor.pinLatestTurn(user as unknown as HTMLElement)); await harness.idle(); await harness.idle(); assert.equal(element.scrollTop, 2000 - PIN_SPACE); assert.equal(anchor.latestBelowFold, true); // When: the reader presses 跳到最新. await harness.update(() => anchor.anchorToLatest()); await harness.idle(); await harness.idle(); assert.equal(element.scrollTop, element.scrollHeight - VIEWPORT); assert.equal(anchor.latestBelowFold, false); assert.equal(anchor.anchored, true); // Then: further growth keeps the viewport on the bottom. element.tail.offsetHeight += 400; element.scrollHeight += 400; await harness.update(() => resizeAll()); await harness.idle(); assert.equal(element.scrollTop, element.scrollHeight - VIEWPORT); } finally { await close(); } }); test("a scroller that remounts under the same key re-attaches to the new element (BUG-1043)", async () => { const { harness, close } = setup(); try { const seen: FakeScroller[] = []; let anchor!: Anchor; const props = { loading: false, resetKey: "session-1", geometry: () => ({ scrollHeight: 1200, clientHeight: VIEWPORT, tail: row(900, 276, () => null) }), onScroller: (value: FakeScroller | null) => { if (value && !seen.includes(value)) seen.push(value); }, onAnchor: (value: Anchor) => { anchor = value; }, }; await harness.render(); await harness.idle(); await harness.render(); await harness.idle(); assert.equal(seen.length, 2); const [first, second] = seen; assert.equal(first.listeners.get("scroll")?.size ?? 0, 0, "the detached scroller kept its listener"); assert.equal(second.listeners.get("scroll")?.size, 1); assert.ok(anchor); // Unmounting the page releases the listener. await harness.render(); await harness.idle(); assert.equal(second.listeners.get("scroll")?.size ?? 0, 0); } finally { await close(); } }); async function pinnedRectificationTurn() { const env = setup(); let scroller: FakeScroller | null = null; let anchor!: Anchor; const history = row(1700, 276, () => scroller); const props = { loading: false, resetKey: "case-1", geometry: () => ({ scrollHeight: 2000, clientHeight: VIEWPORT, tail: history }), onScroller: (value: FakeScroller | null) => { scroller = value; }, onAnchor: (value: Anchor) => { anchor = value; }, }; // The rectification surface mounts after reveal, so its scroller exists from the first render. await env.harness.render(); await env.harness.idle(); const element = scroller as unknown as FakeScroller; assert.equal(element.scrollTop, 2000 - VIEWPORT); // A choice tap: no user row, the new assistant is the head and the tail. The full-viewport // spacer plus the rectification list's bottom padding leave the pin resting 94px above the // bottom, i.e. inside the 96px threshold (CDP measurement, BUG-1044). const head = row(2000, 80, () => scroller); element.tail = head; element.scrollHeight = 2000 - PIN_SPACE + VIEWPORT + 94; await env.harness.update(() => anchor.pinLatestTurn(head as unknown as HTMLElement)); await env.harness.idle(); await env.harness.idle(); return { ...env, element, head, anchor: () => anchor }; } test("a rectification pin resting within the threshold is not pulled to the bottom (BUG-1044)", async () => { const { harness, close, element, head, anchor } = await pinnedRectificationTurn(); try { const pinned = 2000 - PIN_SPACE; assert.equal(element.scrollTop, pinned); assert.equal(element.scrollHeight - element.scrollTop - element.clientHeight, 94); assert.equal(anchor().anchored, false); // A scroll event nobody made (layout, scroll anchoring) at the same resting position. await harness.update(() => element.dispatch("scroll")); await harness.idle(); assert.equal(anchor().anchored, false); // When: the reply streams past the viewport. head.offsetHeight = 1400; element.scrollHeight = 2000 + 1400 + 94; await harness.update(() => resizeAll()); await harness.idle(); // Then: the head stays at the top and the jump chip appears instead. assert.equal(element.scrollTop, pinned); assert.equal(anchor().anchored, false); assert.equal(anchor().latestBelowFold, true); } finally { await close(); } }); test("after a pin only a reader's own scroll to the bottom resumes following (BUG-1044)", async () => { const { harness, close, element, head, anchor } = await pinnedRectificationTurn(); try { head.offsetHeight = 1400; element.scrollHeight = 2000 + 1400 + 94; await harness.update(() => resizeAll()); await harness.idle(); assert.equal(element.scrollTop, 2000 - PIN_SPACE); // When: the reader wheels down to the bottom. await harness.update(() => { element.dispatch("wheel", { deltaY: 900 }); element.scrollTop = element.scrollHeight; element.dispatch("scroll"); }); await harness.idle(); assert.equal(anchor().anchored, true); assert.equal(anchor().latestBelowFold, false); // Then: further growth is followed. head.offsetHeight += 400; element.scrollHeight += 400; await harness.update(() => resizeAll()); await harness.idle(); assert.equal(element.scrollTop, element.scrollHeight - VIEWPORT); } finally { await close(); } }); test("after a pin a touch drag upward releases the pin without following (BUG-930 kept)", async () => { const { harness, close, element, head, anchor } = await pinnedRectificationTurn(); try { await harness.update(() => { element.dispatch("touchmove"); element.scrollTop -= 300; element.dispatch("scroll"); }); await harness.idle(); assert.equal(anchor().anchored, false); const readingAt = element.scrollTop; head.offsetHeight = 1400; element.scrollHeight = 2000 + 1400 + 94; await harness.update(() => resizeAll()); await harness.idle(); assert.equal(element.scrollTop, readingAt); assert.equal(anchor().latestBelowFold, true); } finally { await close(); } }); test("clicking inside the transcript is not a scroll gesture (BUG-1044)", async () => { const { harness, close, element, head, anchor } = await pinnedRectificationTurn(); try { // A tap on a button in a message (copy, thinking toggle) bubbles pointerdown to the scroller. await harness.update(() => { element.dispatch("pointerdown", { target: head }); element.dispatch("scroll"); }); await harness.idle(); head.offsetHeight = 1400; element.scrollHeight = 2000 + 1400 + 94; await harness.update(() => resizeAll()); await harness.idle(); assert.equal(element.scrollTop, 2000 - PIN_SPACE); assert.equal(anchor().anchored, false); } finally { await close(); } }); test("switching the hook off and on (starter home, rectification surface) detaches and re-attaches", async () => { const { harness, close } = setup(); try { let scroller: FakeScroller | null = null; const props = { loading: false, resetKey: "session-1", geometry: () => ({ scrollHeight: 1200, clientHeight: VIEWPORT, tail: row(900, 276, () => null) }), onScroller: (value: FakeScroller | null) => { scroller = value; }, onAnchor: () => undefined, }; await harness.render(); await harness.idle(); const element = scroller as unknown as FakeScroller; assert.equal(element.listeners.get("scroll")?.size ?? 0, 0); await harness.render(); await harness.idle(); assert.equal(element.listeners.get("scroll")?.size, 1); await harness.render(); await harness.idle(); assert.equal(element.listeners.get("scroll")?.size ?? 0, 0); } finally { await close(); } }); test("scroll keys count as a reader gesture only outside text fields and buttons", () => { const target = (match: string | null) => ({ closest: (selector: string) => (match && selector.includes(match) ? {} : null) }); const key = (value: string, over: Partial = {}) => ({ key: value, target: target(null), altKey: false, ctrlKey: false, metaKey: false, ...over, }) as unknown as KeyboardEvent; assert.equal(isScrollKeyGesture(key("PageDown")), true); assert.equal(isScrollKeyGesture(key("End")), true); assert.equal(isScrollKeyGesture(key(" ")), true); assert.equal(isScrollKeyGesture(key("a")), false); assert.equal(isScrollKeyGesture(key("Enter")), false); assert.equal(isScrollKeyGesture(key("ArrowDown", { target: target("textarea") as unknown as EventTarget })), false); assert.equal(isScrollKeyGesture(key(" ", { target: target("button") as unknown as EventTarget })), false); assert.equal(isScrollKeyGesture(key("PageDown", { target: target("button") as unknown as EventTarget })), true); assert.equal(isScrollKeyGesture(key("ArrowDown", { metaKey: true })), false); });