Files
hermes-webui/tests/test_issue1360_streaming_scroll_hardening.py
Brett Yelverton 2a40ca4240 fix(streaming): restore mobile scroll-jank guard during live renders
A later streaming parse-cache change dropped the #MOBILESCROLL guard from
the live-stream render tick while leaving the helper and CSS in place.
On iOS PWA, Safari can paint a transient scrollTop=0 frame during streamed
DOM rebuilds, forcing the reader to the first/oldest message.

Re-add _fixMobileScrollJank() before streaming DOM work begins and pin it
with a regression test so the third layer of the mobile scroll fix cannot
be silently dropped again.
2026-06-24 17:20:40 +00:00

117 lines
5.2 KiB
Python

"""Regression tests for #1360: streaming must not re-pin user scroll."""
from pathlib import Path
REPO = Path(__file__).parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
def _extract_function(src: str, name: str) -> str:
marker = f"function {name}("
idx = src.find(marker)
assert idx != -1, f"{name} not found"
depth = 0
for i, ch in enumerate(src[idx:], idx):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[idx:i + 1]
raise AssertionError(f"Could not extract {name}")
def test_messages_scroller_disables_browser_scroll_anchoring_on_desktop():
# Desktop (mouse): overflow-anchor:none — tool/card inserts cannot yank
# the transcript while the user reads earlier content.
assert "@media (hover:hover) and (pointer:fine){.messages{overflow-anchor:none;}}" in STYLE_CSS, (
"On desktop (mouse-driven devices) #messages must disable browser scroll "
"anchoring so tool/card inserts cannot yank the transcript. "
"On mobile (touch devices) overflow-anchor:auto is used instead to prevent "
"scrollTop=0 jank during innerHTML rebuild (#MOBILESCROLL)."
)
def test_messages_scroller_uses_overflow_anchor_auto_on_mobile():
# Mobile (touch): overflow-anchor:auto — prevents scrollTop=0 jank during
# innerHTML rebuild and streaming DOM updates (#MOBILESCROLL).
assert "overflow-anchor:auto;" in STYLE_CSS, (
"On mobile/touch devices #messages must default to overflow-anchor:auto "
"to prevent the browser painting a frame with scrollTop=0 between "
"innerHTML='' and snapshot restore."
)
def test_streaming_render_enables_mobile_scroll_jank_guard_before_dom_writes():
"""Streaming must re-enable mobile scroll anchoring before every DOM write.
Regression: #MOBILESCROLL originally added _fixMobileScrollJank() in the
live-stream render tick, but a later streaming parse-cache change dropped
that call while leaving the helper/CSS in place. On iOS PWA this lets Safari
paint a transient scrollTop=0 frame during streamed DOM rebuilds.
"""
guard_idx = MESSAGES_JS.find("window._fixMobileScrollJank")
assert guard_idx != -1, (
"attachLiveStream() must call window._fixMobileScrollJank() during the "
"streaming render tick, before DOM writes, so iOS PWA does not jump to "
"the first/oldest message while assistant output streams."
)
render_idx = MESSAGES_JS.find("_lastRenderMs=performance.now()")
assert render_idx != -1, "streaming render timestamp not found"
assert guard_idx < render_idx, (
"The mobile scroll-jank guard must run before streaming DOM work begins."
)
def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows():
assert "clientHeight<250" in UI_JS or "bottomDistance<250" in UI_JS, (
"The near-bottom re-pin threshold should be at least 250px so small "
"macOS app windows and trackpad momentum do not re-pin too eagerly."
)
def test_queue_card_measurement_respects_manual_unpin_even_when_idle():
fn = _extract_function(UI_JS, "_renderQueueChips")
measurement_idx = fn.find("setTimeout(()=>")
assert measurement_idx != -1, "queue card measurement timeout not found"
measurement_block = fn[measurement_idx:measurement_idx + 500]
assert "scrollIfPinned()" in measurement_block
assert "scrollToBottom()" not in measurement_block, (
"queue-card layout measurement is a background update; it must not clear "
"manual _messageUserUnpinned state by calling explicit scrollToBottom()."
)
def test_queue_pill_click_respects_manual_unpin_even_when_idle():
fn = _extract_function(UI_JS, "_updateQueuePill")
click_idx = fn.find("pill.onclick=()=>")
assert click_idx != -1, "queue pill click handler not found"
click_block = fn[click_idx:click_idx + 700]
assert "scrollIfPinned()" in click_block
assert "scrollToBottom()" not in click_block, (
"queue-pill expansion is a layout update, not an explicit transcript jump; "
"it must not reset manual unpin state."
)
def test_idle_render_preserves_manual_unpin_until_explicit_bottom():
render_body = _extract_function(UI_JS, "renderMessages")
scroll_helper = _extract_function(UI_JS, "_scrollAfterMessageRender")
assert "preserveScroll||_messageUserUnpinned" in render_body.replace(" ", ""), (
"renderMessages() must capture a scroll snapshot whenever the reader is "
"manually unpinned, even for idle/non-preserve renders."
)
assert "if(_messageUserUnpinned){" in scroll_helper.replace(" ", ""), (
"idle render must restore the manually-unpinned viewport instead of "
"falling through to scrollToBottom()."
)
manual_unpin_block = scroll_helper[scroll_helper.index("if(_messageUserUnpinned)") : scroll_helper.index("scrollToBottom();")]
assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in manual_unpin_block
assert "_maybeShowNewMessageScrollCue(scrollSnapshot);" in manual_unpin_block