164 lines
8.8 KiB
Python
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
|