Files
hermes-webui/tests/test_issue4295_midstream_scroll_anchor.py
T
nesquena-hermes 5e818546f8 stage #4578 onto master (v0.51.553) — preserve mid-stream scroll anchor + honor manual unpin (#4295)
franksong2702: preserve the reader's semantic viewport anchor during mid-stream transcript rebuilds
before raw scrollTop fallback; remount virtualized anchors before restore w/ _programmaticScroll
scoped to the remount frame; treat captured userUnpinned/pinned snapshot as authoritative so
distance-inference can't re-pin a reader who scrolled away during a live reply. Browser-only scroll
state. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-21 07:25:11 +00:00

187 lines
7.2 KiB
Python

"""Regression for #4295: keep mid-stream reader anchor while streaming.
The fix needs a scroll restore that keeps the semantic message anchor authoritative
even when DOM height grows while the user is manually unpinned.
"""
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.find(marker)
assert start >= 0, f"{name} not found"
brace = src.find("{", start)
assert brace >= 0, f"{name} body not found"
depth = 0
for i, ch in enumerate(src[brace:], brace):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"{name} body did not terminate")
def _compact(text: str) -> str:
return "".join(text.split())
def test_restore_message_scroll_snapshot_remounts_virtual_anchor_and_keeps_unpinned():
"""Drive the real restore helpers with a tiny DOM stub.
This covers the #4295 failure mode where a mid-stream render rebuild has to
remount a virtualized anchor before restoring the semantic reader position.
The geometry is intentionally near-bottom after restore; old distance-based
state inference would incorrectly re-pin the reader.
"""
script = f"""
const assert = require('assert');
let _programmaticScroll = false;
let _messageVirtualWindowKey = 'old-window';
let _lastScrollTop = 0;
let _messageUserUnpinned = false;
let _scrollPinned = true;
let _nearBottomCount = 2;
let _messageViewportAnchorRemounting = false;
let targetMounted = false;
const renderCalls = [];
const targetRow = {{
getBoundingClientRect() {{ return {{ top: 140, bottom: 180 }}; }}
}};
const container = {{
scrollTop: 300,
scrollHeight: 905,
clientHeight: 400,
getBoundingClientRect() {{ return {{ top: 100, bottom: 500 }}; }},
querySelector(selector) {{
if (selector === '[data-msg-idx="20"]' && targetMounted) return targetRow;
return null;
}}
}};
function $(id) {{ return id === 'messages' ? container : null; }}
function requestAnimationFrame(fn) {{ fn(); }}
function setTimeout(fn) {{ fn(); }}
function _getVisibleMessagesWithIdx() {{
return [{{ rawIdx: 5 }}, {{ rawIdx: 20 }}, {{ rawIdx: 42 }}];
}}
function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx) {{
return visWithIdx.findIndex((entry) => entry && entry.rawIdx === rawIdx);
}}
function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visIdx, el) {{
assert.strictEqual(visIdx, 1);
assert.strictEqual(el, container);
return 480;
}}
function renderMessages(options) {{
renderCalls.push(options);
assert.strictEqual(_messageViewportAnchorRemounting, true);
targetMounted = true;
}}
{_function_body(UI_JS, "_restoreMessageViewportAnchor")}
{_function_body(UI_JS, "_remountMessageViewportAnchor")}
{_function_body(UI_JS, "_restoreMessageScrollSnapshot")}
_restoreMessageScrollSnapshot({{
anchor: {{ rawIdx: 20, topOffset: 15 }},
top: 222,
bottom: 600,
pinned: false,
userUnpinned: true,
}});
assert.strictEqual(renderCalls.length, 1);
assert.deepStrictEqual(renderCalls[0], {{ preserveScroll: true }});
assert.strictEqual(_messageVirtualWindowKey, '');
assert.strictEqual(container.scrollTop, 505);
assert.strictEqual(_lastScrollTop, 505);
assert.strictEqual(_messageUserUnpinned, true);
assert.strictEqual(_scrollPinned, false);
assert.strictEqual(_nearBottomCount, 0);
assert.strictEqual(_programmaticScroll, false);
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_restore_message_scroll_snapshot_keeps_intermediate_distance_state():
script = f"""
const assert = require('assert');
let _programmaticScroll = false;
let _messageVirtualWindowKey = 'old-window';
let _lastScrollTop = 0;
let _messageUserUnpinned = true;
let _scrollPinned = false;
let _nearBottomCount = 0;
let _messageViewportAnchorRemounting = false;
const container = {{
scrollTop: 10,
scrollHeight: 800,
clientHeight: 400,
getBoundingClientRect() {{ return {{ top: 100, bottom: 500 }}; }},
querySelector() {{ return null; }}
}};
function $(id) {{ return id === 'messages' ? container : null; }}
function requestAnimationFrame(fn) {{ fn(); }}
function setTimeout(fn) {{ fn(); }}
function _getVisibleMessagesWithIdx() {{ return []; }}
function _messageVisibleIndexForRawIdx() {{ return -1; }}
function _messageVirtualScrollTopForVisibleIdx() {{ throw new Error('not expected'); }}
function renderMessages() {{ throw new Error('not expected'); }}
{_function_body(UI_JS, "_restoreMessageViewportAnchor")}
{_function_body(UI_JS, "_remountMessageViewportAnchor")}
{_function_body(UI_JS, "_restoreMessageScrollSnapshot")}
_restoreMessageScrollSnapshot({{
anchor: null,
top: 200,
bottom: 200,
pinned: false,
userUnpinned: false,
}});
assert.strictEqual(container.scrollTop, 200);
assert.strictEqual(_lastScrollTop, 200);
assert.strictEqual(_messageUserUnpinned, true);
assert.strictEqual(_scrollPinned, false);
assert.strictEqual(_nearBottomCount, 0);
assert.strictEqual(_programmaticScroll, false);
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_restore_message_scroll_snapshot_prefers_semantic_anchor_with_virtual_fallback():
body = _function_body(UI_JS, "_restoreMessageScrollSnapshot")
helper = _function_body(UI_JS, "_remountMessageViewportAnchor")
compact = _compact(body)
helper_compact = _compact(helper)
assert "snapshot.anchor&&typeof_restoreMessageViewportAnchor==='function'" in compact
assert "if(!restoredViaAnchor&&typeof_remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in compact
assert "typeof_getVisibleMessagesWithIdx!=='function'" in helper_compact
assert "_getVisibleMessagesWithIdx()" in helper_compact
assert "_messageVisibleIndexForRawIdx(targetIdx,visWithIdx)" in helper_compact
assert "_messageVirtualScrollTopForVisibleIdx(visWithIdx,visIdx,container)" in helper_compact
assert "_messageVirtualWindowKey=''" in helper_compact
assert "renderMessages({preserveScroll:true});" in helper_compact
assert "_messageViewportAnchorRemounting=true" in helper_compact
assert "setTimeout(()=>{_programmaticScroll=false;},0);" in helper_compact
def test_restore_message_scroll_snapshot_keeps_user_unpinned_state_authoritative_mid_stream():
body = _function_body(UI_JS, "_restoreMessageScrollSnapshot")
compact = _compact(body)
assert "constbottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;" in compact
assert "if(snapshot.userUnpinned===true){_messageUserUnpinned=true;_scrollPinned=false;_nearBottomCount=0;}elseif(snapshot.pinned===true){_messageUserUnpinned=false;_scrollPinned=true;_nearBottomCount=2;}else{" in compact
assert "_messageUserUnpinned=false;_scrollPinned=false;_nearBottomCount=0;" not in compact
def test_same_frame_restore_uses_the_shared_anchor_remount_fallback():
body = _function_body(UI_JS, "_restoreMessageScrollSnapshotSameFrame")
compact = _compact(body)
assert "if(!restoredViaAnchor&&typeof_remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in compact
assert "_messageVirtualScrollTopForVisibleIdx" not in body