Files
hermes-webui/tests/test_issue3479_ios_stream_scroll_jump.py
T
11c0d1667f Release v0.51.254 — Release HV (stage-r2) (#3593)
## Release v0.51.254 — Release HV (stage-r2)

Phase-2 medium wave 1 — 4 PRs (UI/mobile/cancel fixes + an un-held model dedup).

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3528 | @franksong2702 | Render partial tool calls after cancel — interrupted turns keep their `_partial_tool_calls` rows in the transcript + fallback tool-cards. (Codex confirmed it stays render-only, not forwarded to the provider API.) |
| #3550 | @lurebat | Android offline recovery soft-reattaches the live stream instead of hard-reloading the page on a transient background/disconnect. |
| #3479 | @mvanhorn | iOS Safari no longer snaps the conversation to the top when a handoff/compression card is inserted mid-stream or on `refreshSession()`. |
| #3478 | @JayC-L | **Un-held:** named custom providers (`@custom:name:model`) dedup against bare model IDs without regressing Ollama multi-colon tags (`qwen2.5:7b-instruct-q4`). Only `@custom:` IDs strip the two-segment prefix. |

### Hold-sweep note
#3478/#3489 was held yesterday for an Ollama multi-colon-tag regression risk (a blanket `lastIndexOf` would lose the model). The author pushed a scoped fix (only `@custom:` IDs use `lastIndexOf`); I verified `_normId` in node against the regression cases — Ollama bare tags are preserved. Un-held + shipped.

### Gate
- Full pytest suite: **7588 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — #3552 partial-tool-calls verified render-only (no `_API_SAFE_MSG_KEYS` leak / no 400-on-strict-provider, the v0.50.251 #1375 trap); #3551 no EventSource double-subscribe; #3541 no regression vs the #3525 scroll-follow shipped in v0.51.253; #3489 no over-dedup.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: lurebat <lurebat@users.noreply.github.com>
Co-authored-by: mvanhorn <mvanhorn@users.noreply.github.com>
Co-authored-by: JayC-L <JayC-L@users.noreply.github.com>
2026-06-04 11:13:45 -07:00

91 lines
3.5 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")
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_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 "pinned:_shouldFollowMessagesOnDomReplace()" in _compact(capture)
assert "userUnpinned:_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_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