Files
hermes-webui/tests/test_issue3479_ios_stream_scroll_jump.py
T

153 lines
6.9 KiB
Python

"""Regression coverage for #3479: iOS Safari must not see a top-scroll frame.
The live token path writes into an existing streaming-markdown DOM node. The
jump came from discrete transcript rebuilds and card replacement paths, so these
tests pin those call sites rather than the per-token renderer.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").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 pos in range(brace, len(src)):
ch = src[pos]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start : pos + 1]
raise AssertionError(f"{name} body did not terminate")
def _compact(text: str) -> str:
return "".join(text.split())
def test_refresh_session_uses_same_frame_scroll_snapshot_restore():
body = _function_body(UI_JS, "refreshSession")
assert "syncTopbar(); _renderMessagesWithScrollSnapshot();" in body
assert "syncTopbar(); renderMessages();" not in body
def test_handoff_rebuilds_use_same_frame_scroll_snapshot_restore():
clear_body = _function_body(UI_JS, "clearHandoffUi")
set_body = _function_body(UI_JS, "setHandoffUi")
assert "_renderMessagesWithScrollSnapshot();" in clear_body
assert "_renderMessagesWithScrollSnapshot();" in set_body
assert "renderMessages();" not in clear_body
assert "renderMessages();" not in set_body
def test_live_compression_card_replacement_restores_snapshot_before_follow_settle():
body = _function_body(UI_JS, "appendLiveCompressionCard")
capture_idx = body.index("const scrollSnapshot=_captureMessageScrollSnapshot();")
replace_idx = body.index("if(existing) existing.replaceWith(node);")
restore_idx = body.index("_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);")
settle_idx = body.index("if(typeof scrollIfPinned==='function') scrollIfPinned();")
assert capture_idx < replace_idx < restore_idx < settle_idx
def test_live_anchor_worklog_rebuild_restores_snapshot_before_follow_settle():
body = _function_body(UI_JS, "renderLiveAnchorActivityScene")
capture_idx = body.index("const scrollSnapshot=_captureMessageScrollSnapshot();")
guard_idx = body.index("const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot);")
remove_idx = body.index("blocks.querySelectorAll('[data-anchor-scene-owner=\"1\"],[data-anchor-scene-row=\"1\"]')")
restore_detail_idx = body.index("_restoreWorklogDetailDisclosureState(blocks, liveDisclosureState);")
dedupe_idx = body.index("_dedupeLiveProcessedWorklogAnchors(turn);")
move_status_idx = body.index("_moveLiveRunStatusToTurnEnd();")
restore_idx = body.index("_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);")
release_idx = body.index("_restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);")
settle_idx = body.index("if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();")
assert capture_idx < guard_idx < remove_idx < restore_detail_idx < dedupe_idx < move_status_idx < restore_idx < release_idx < settle_idx
def test_live_anchor_worklog_rebuild_guards_height_for_unpinned_reader():
guard = _function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")
compact = _compact(guard)
assert "constbeforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight);" in compact
assert "beforeBottomDistance>250&&(_messageUserUnpinned||_scrollPinned===false)" in compact
assert "scrollSnapshot.pinned=false;" in compact
assert "scrollSnapshot.userUnpinned=true;" in compact
assert "scrollSnapshot.bottom=beforeBottomDistance;" in compact
assert "_messageUserUnpinned=true;" in compact
assert "_scrollPinned=false;" in compact
assert "_nearBottomCount=0;" in compact
assert "msgInner.style.minHeight=`${guardHeight}px`;" in compact
def test_same_frame_snapshot_preserves_bottom_distance_and_unpinned_state():
capture = _function_body(UI_JS, "_captureMessageScrollSnapshot")
restore = _function_body(UI_JS, "_restoreMessageScrollSnapshotSameFrame")
wrapper = _function_body(UI_JS, "_renderMessagesWithScrollSnapshot")
assert "bottom" in capture
assert "readerAwayFromBottom?false:_shouldFollowMessagesOnDomReplace()" in _compact(capture)
assert "readerAwayFromBottom?true:_messageUserUnpinned" in _compact(capture)
assert "maxTop-Math.max(0,bottom)" in restore
assert "_messageUserUnpinned=true" in restore
assert "_scrollPinned=false" in restore
assert "renderMessages({...(options||{}),preserveScroll:true});" in wrapper
assert "_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);" in wrapper
def test_preserve_scroll_restores_reader_away_from_bottom_before_following():
body = _function_body(UI_JS, "_scrollAfterMessageRender")
compact = _compact(body)
reader_idx = compact.index("constreaderAwayFromBottom=")
follow_idx = compact.index("if(!readerAwayFromBottom&&!_messageUserUnpinned&&_followMessagesAfterDomReplace())return;")
restore_idx = compact.index("_restoreMessageScrollSnapshot(scrollSnapshot);")
assert "Number(scrollSnapshot.bottom)>250" in compact
assert reader_idx < follow_idx < restore_idx
def test_scroll_snapshot_restore_reinstates_unpinned_state_when_reader_is_mid_answer():
restore = _function_body(UI_JS, "_restoreMessageScrollSnapshot")
compact = _compact(restore)
assert "constbottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;" in compact
assert "if(bottomDistance>250)" in compact
assert "_messageUserUnpinned=true" in compact
assert "_scrollPinned=false" in compact
def test_same_session_force_refresh_does_not_reset_scroll_direction_tracker():
body = _function_body(SESSIONS_JS, "loadSession")
compact = _compact(body)
assert "constcurrentSid=S.session?S.session.session_id:null;" in compact
assert "if(currentSid!==sid&&typeofwindow!=='undefined'&&typeofwindow._resetScrollDirectionTracker==='function')" in compact
def test_clarify_card_is_height_clamped_and_scrollable_on_mobile_viewports():
compact = _compact(STYLE_CSS)
assert ".clarify-card" in STYLE_CSS
assert "max-height:clamp(180px,min(68vh,calc(100vh-220px)),420px)" in compact
assert "@supports(height:100dvh)" in compact
assert "max-height:clamp(180px,min(62dvh,calc(100dvh-180px)),360px)" in compact
assert "overflow-y:auto" in compact
assert "-webkit-overflow-scrolling:touch" in compact
assert "overscroll-behavior:contain" in compact