Files
hermes-webui/tests/test_sse_recovery_scroll_stranding.py
T

164 lines
8.8 KiB
Python

"""Regression locks: SSE-recovery / streaming scroll-stranding fixes.
Two distinct "jump back" classes, both verified live this session:
1. Content-grew-beneath-a-pinned-viewport (static/ui.js scroll listener):
while streaming on a tall transcript, new content increases scrollHeight under
a stationary viewport. The reader never scrolled (top did not move up,
_messageUserUnpinned is false), but bottomDistance crosses the nearBottom
threshold, so the old code fell through to `_scrollPinned=false`, killing
auto-follow mid-stream. The follow writer and the scroll listener then fought
frame-by-frame; the viewport stalled while content kept growing and was
progressively stranded mid-transcript. Fix: in the `!_messageUserUnpinned`
branch, when the viewport did NOT move up and auto-follow is on, keep the pin
and re-snap to the true bottom instead of unpinning.
2. SSE-recovery follow-restore (static/messages.js): _handleStreamError (SSE
drop), the Task-cancelled apply/fallback paths, and the reconnect-stream-dead
cleanup all push/replace S.messages then renderMessages({preserveScroll:true}).
preserveScroll's restore path keys on the pre-render snapshot's bottom-distance,
which during a live stream can read large (content grew under a followed
viewport), so it yanked a following reader up to a stale historical position on
a process restart / SSE drop / cancel. Fix: capture follow-intent
(_isMessagePaneNearBottom) BEFORE mutating S.messages, and after the recovery
render, scrollToBottom() if the reader was following — so they see the
interruption/cancellation notice in place instead of being thrown back into the
transcript. Readers who had scrolled up to read history are left where they were.
These are structural source-locks (the behavioral A/B was verified live via
Playwright: OLD stranded the reader 470-580px from bottom, FIX landed at 1px).
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
def _compact(s: str) -> str:
return "".join(s.split())
def test_content_grew_keeps_pin_and_resnaps_not_unpin():
# The scroll listener's !_messageUserUnpinned branch must, when the viewport
# did NOT move up and auto-follow is on, keep the pin and re-snap to bottom
# rather than unpinning. Assert the new guard + re-snap call are present.
c = _compact(UI_JS)
assert _compact("}else if(!movedUp && window._autoScrollFollow && _scrollPinned){") in c, (
"content-grew-beneath-pinned guard missing from the scroll listener"
)
# The guard body must re-snap to bottom, not set _scrollPinned=false.
idx = c.find(_compact("else if(!movedUp && window._autoScrollFollow && _scrollPinned){"))
assert idx != -1
# The re-snap call must be the FIRST scroll action after the guard opens,
# before the chain reaches any `else{ _scrollPinned=false }` fallthrough.
after = c[idx:]
resnap = after.find(_compact("_setMessageScrollToBottom()"))
fallthrough = after.find(_compact("}else{_nearBottomCount=0;_scrollPinned=false;}"))
assert resnap != -1, "content-grew guard must re-snap via _setMessageScrollToBottom()"
assert fallthrough != -1, "the original unpin fallthrough should still exist for the real scroll-away case"
assert resnap < fallthrough, (
"the re-snap must be inside the content-grew guard body, BEFORE the unpin fallthrough"
)
def test_sse_recovery_paths_capture_follow_intent_and_refollow():
# All four SSE-recovery mutation points must capture follow-intent before
# mutating S.messages and re-follow after the recovery render.
c = _compact(MESSAGES_JS)
for guard in (
"_wasFollowingAtDisconnect", # _handleStreamError (SSE drop)
"_wasFollowingAtCancel", # Task cancelled (embedded payload)
"_wasFollowingAtCancelFb", # Task cancelled (fallback)
"_wasFollowingAtReconnectDead", # reconnect-stream-dead cleanup
):
assert guard in MESSAGES_JS, f"SSE-recovery follow-intent guard '{guard}' missing"
# each guard is computed via _isMessagePaneNearBottom AND a sticky-unpin
# check, then consumed by a scrollToBottom() re-follow.
assert _compact("_isMessagePaneNearBottom(1200)") in c, (
"follow-intent must be computed via _isMessagePaneNearBottom(1200)"
)
# STICKY-FOLLOW INVARIANT (maintainer must-fix): proximity alone is NOT
# enough — a reader who manually scrolled up but stayed within 1200px sets
# _messageUserUnpinned and must NOT be re-followed on recovery. Each guard
# must AND in the sticky-unpin state via _isMessageReaderUnpinned (with a
# _messageUserUnpinned fallback). Assert the guard is gated on NOT-unpinned.
assert _compact("_isMessageReaderUnpinned") in c, (
"follow-intent must consult the sticky _isMessageReaderUnpinned state"
)
assert _compact("_messageUserUnpinned") in c, (
"follow-intent must fall back to _messageUserUnpinned when the helper is absent"
)
# Each guard must drive a scrollToBottom re-follow.
for guard in ("_wasFollowingAtDisconnect", "_wasFollowingAtCancel",
"_wasFollowingAtCancelFb", "_wasFollowingAtReconnectDead"):
assert _compact(f"if({guard} && typeof scrollToBottom==='function') scrollToBottom()") in c, (
f"guard '{guard}' must re-follow via scrollToBottom() when the reader was following"
)
def test_follow_intent_captured_before_mutation():
# Ordering invariant: inside _handleStreamError, the _wasFollowingAtDisconnect
# capture must appear BEFORE the terminal-error marker is inserted into
# S.messages (capturing after the mutation would read a post-insert
# bottom-distance and defeat the fix). Scope the search to the
# _handleStreamError function body — messages.js has more than one
# "Connection interrupted" push and more than one _handleStreamError
# reference, so a global str.find() would compare across unrelated paths.
src = MESSAGES_JS
fn = src.find("function _handleStreamError")
assert fn != -1, "could not locate _handleStreamError"
body = src[fn:fn + 8000]
cap = body.find("_wasFollowingAtDisconnect=")
mutation = body.find("_ensureSingleTerminalStreamErrorMarker(S.messages)")
assert cap != -1, "follow-intent capture not found in _handleStreamError"
assert mutation != -1, "terminal-error marker insertion not found in _handleStreamError"
assert cap < mutation, (
"follow-intent must be captured BEFORE the terminal-error marker is "
"inserted into S.messages"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
def test_sticky_follow_invariant_unpinned_within_1200_is_not_refollowed():
"""Behavioral: the sticky-aware guard re-follows a genuine follower but spares
a reader who manually scrolled up (unpinned) even while still within 1200px.
Extracts the real _wasFollowingAtDisconnect expression from messages.js and
evaluates it in Node under the four (nearBottom x unpinned) states, stubbing
_isMessagePaneNearBottom / _isMessageReaderUnpinned to the scenario values.
"""
src = MESSAGES_JS
start = src.index("const _wasFollowingAtDisconnect=")
# capture through the terminating semicolon of the const declaration
end = src.index(";", src.index("_messageUserUnpinned", start))
expr = src[start:end + 1]
def run(near_bottom: bool, unpinned: bool) -> bool:
harness = textwrap.dedent(f"""
let _messageUserUnpinned = {str(unpinned).lower()};
function _isMessagePaneNearBottom(px){{ return {str(near_bottom).lower()}; }}
function _isMessageReaderUnpinned(){{ return {str(unpinned).lower()}; }}
{expr}
console.log(JSON.stringify(_wasFollowingAtDisconnect));
""")
res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout.strip())
# Genuine follower (pinned, at/near bottom) → re-follow.
assert run(near_bottom=True, unpinned=False) is True
# MAINTAINER MUST-FIX: manually-unpinned reader STILL within 1200px → NOT re-followed.
assert run(near_bottom=True, unpinned=True) is False, (
"a reader who scrolled up (unpinned) within 1200px must NOT be re-followed on recovery"
)
# Scrolled far away (not near bottom), pinned flag irrelevant → not re-followed.
assert run(near_bottom=False, unpinned=False) is False
assert run(near_bottom=False, unpinned=True) is False