Files
hermes-webui/tests/test_issue6414_programmatic_scroll_user_intent.py
a818be1ed4 Release exp-v0.52.172: preserve reader scroll intent during live renders (#6453) (#6838)
* fix: preserve reader scroll intent during live renders

* fix: enforce programmatic scroll guard freshness

* test: update programmatic scroll guard assertion

* test: update scroll safety valve assertion

* fix: reconcile scroll intent helper after base refresh

* fix: timestamp fallback history scroll writes

* test: cover stale older-message fallback scroll guard

* test: execute the real slow-render history fallback

* test: provide the history fallback DOM helper

* test: retain async declaration in history fallback harness

* test: remove trailing blank line from scroll intent regression

* test(#6414): update scroll-intent harness for window._autoScrollFollow + _messageScrollInputGeneration (master drift)

* Release exp-v0.52.172: preserve reader scroll intent during live renders (#6453)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Frank Song <xuefu.song@qq.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-08-07 18:37:31 -07:00

216 lines
7.8 KiB
Python

"""Regression for #6414: real upward wheel intent wins during a render scroll guard."""
import json
import re
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
def _function_body(name: str, source: str = UI_JS) -> str:
marker = f"async function {name}"
start = source.find(marker)
if start < 0:
marker = f"function {name}"
start = source.index(marker)
brace = source.index("{", start)
depth = 0
for index in range(brace, len(source)):
if source[index] == "{":
depth += 1
elif source[index] == "}":
depth -= 1
if depth == 0:
return source[start : index + 1]
raise AssertionError(f"{name} did not terminate")
def _run_small_upward_wheel_case(programmatic_scroll_age_ms: int) -> dict[str, bool | int]:
script = f"""
let now = 1000;
const performance = {{ now: () => now }};
const messages = {{
scrollHeight: 5000,
scrollTop: 4400,
clientHeight: 500,
contains: (target) => target === messages,
}};
const document = {{ getElementById: (id) => id === 'messages' ? messages : null }};
let _programmaticScroll = true;
let _programmaticScrollSetAt = now - {programmatic_scroll_age_ms};
let _messageUserUnpinned = false;
let _scrollPinned = true;
let _nearBottomCount = 2;
let _lastNonMessageScrollIntentMs = -Infinity;
let _lastMessageScrollIntentMs = -Infinity;
let _lastMessageWheelIntentMs = -Infinity;
let _touchStartY = null;
let _messageTouchScrollActive = false;
let _messageScrollInputGeneration = 0;
let writes = 0;
let cancels = 0;
const _autoScrollFollow = true;
const window = {{ _autoScrollFollow: true }};
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
function _cancelBottomSettle() {{ cancels += 1; }}
function _markMessageTouchScrollIntent() {{}}
function _recentNonMessageScrollIntent() {{ return false; }}
function _recentMessageScrollIntent() {{ return false; }}
function _recentMessageTouchScrollIntent() {{ return false; }}
function _recentMessageWheelIntent() {{ return now - _lastMessageWheelIntentMs < 1200; }}
function _recentMessageKeyScrollIntent() {{ return false; }}
function _messageBottomDistance() {{ return messages.scrollHeight - messages.scrollTop - messages.clientHeight; }}
function _setMessageScrollToBottom() {{ writes += 1; }}
function _settleMessageScrollToBottom() {{ writes += 1; }}
{_function_body('_freshProgrammaticScrollActive')}
{_function_body('_recordNonMessageScrollIntent')}
{_function_body('scrollIfPinned')}
_recordNonMessageScrollIntent({{
type: 'wheel',
target: messages,
deltaY: -5,
}});
scrollIfPinned();
console.log(JSON.stringify({{
cancels,
writes,
programmaticScroll: _programmaticScroll,
messageUserUnpinned: _messageUserUnpinned,
scrollPinned: _scrollPinned,
}}));
"""
result = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)
def test_small_upward_wheel_unpins_during_fresh_programmatic_scroll_guard():
"""A capture-phase wheel event must beat the scroll listener's early return.
Small trackpad deltas are intentionally below the ordinary sticky-unpin
threshold. They still represent a reader trying to leave the live tail when
a render has armed `_programmaticScroll`; otherwise the listener returns and
the next stream tick pulls the reader back down.
"""
result = _run_small_upward_wheel_case(programmatic_scroll_age_ms=40)
assert result["messageUserUnpinned"] is True
assert result["scrollPinned"] is False
assert result["cancels"] == 1
assert result["writes"] == 0
def test_small_upward_wheel_does_not_unpin_after_programmatic_guard_stales():
"""A stale latch must fall back to the ordinary low-delta wheel threshold."""
result = _run_small_upward_wheel_case(programmatic_scroll_age_ms=200)
assert result["messageUserUnpinned"] is False
assert result["scrollPinned"] is True
assert result["programmaticScroll"] is False
assert result["cancels"] == 0
def test_older_message_fallback_executes_real_slow_render_writer_contract():
"""Exercise the production fallback, rather than retyping its ownership edge."""
script = f"""
let now = 1000;
const performance = {{ now: () => now }};
let storedTop = 100;
let writeObservation = null;
const container = {{
scrollHeight: 1000,
clientHeight: 300,
get scrollTop() {{ return storedTop; }},
set scrollTop(value) {{
storedTop = value;
writeObservation = {{ value, at: now, fresh: _freshProgrammaticScrollActive() }};
}},
}};
const document = {{ getElementById: (id) => id === 'messages' ? container : null }};
function $(id) {{ return document.getElementById(id); }}
const window = {{}};
const S = {{
session: {{ session_id: 'slow-render-session' }},
messages: [{{ role: 'assistant', content: 'tail' }}],
}};
let _loadingOlder = false;
let _messagesTruncated = true;
let _oldestIdx = 1;
let _messagesGeneration = 0;
let _loadingSessionId = null;
let _messageRenderWindowSize = 50;
let _scrollPinned = true;
let _programmaticScroll = true;
let _programmaticScrollSetAt = now;
const _INITIAL_MSG_LIMIT = 50;
const _msgLimitMax = 100;
const MESSAGE_RENDER_WINDOW_DEFAULT = 50;
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
async function api() {{
return {{
session: {{
messages: [
{{ role: 'user', content: 'older' }},
{{ role: 'assistant', content: 'tail' }},
],
_messages_truncated: true,
_messages_offset: 0,
tool_calls: [],
}},
}};
}}
function _sameTranscriptMessage(a, b) {{ return a && b && a.role === b.role && a.content === b.content; }}
function _syncToolCallsForLoadedMessages() {{}}
function _messageIsRenderable() {{ return true; }}
function msgContent(message) {{ return message && message.content; }}
function _currentMessageRenderWindowSize() {{ return _messageRenderWindowSize; }}
function renderMessages() {{ now += 200; container.scrollHeight = 1300; }}
function _captureMessageViewportAnchor() {{ return null; }}
function _restoreMessageViewportAnchor() {{ return false; }}
function _messageVirtualPrependedHeightDelta() {{ return null; }}
function requestAnimationFrame() {{ return 0; }}
{_function_body('_freshProgrammaticScrollActive')}
{_function_body('_loadOlderMessages', SESSIONS_JS)}
await _loadOlderMessages();
if (!writeObservation) throw new Error('real fallback did not write scrollTop');
if (!writeObservation.fresh) throw new Error('real fallback write inherited a stale latch');
if (writeObservation.at !== 1200) throw new Error('fallback timestamp was not taken after slow render');
now = writeObservation.at + 151;
if (_freshProgrammaticScrollActive()) throw new Error('fallback latch did not expire from its own write');
console.log(JSON.stringify({{ top: storedTop, observation: writeObservation }}));
"""
result = subprocess.run(
["node", "--input-type=module", "-e", script],
check=True,
capture_output=True,
text=True,
)
observed = json.loads(result.stdout)
assert observed["top"] == 400
assert observed["observation"] == {"value": 400, "at": 1200, "fresh": True}
def test_all_programmatic_scroll_writers_stamp_the_shared_freshness_clock():
"""Keep UI and session writers from silently drifting apart."""
for source_name, source in (("ui.js", UI_JS), ("sessions.js", SESSIONS_JS)):
writers = list(re.finditer(r"_programmaticScroll\s*=\s*true\s*;", source))
assert writers, f"{source_name} has no programmatic-scroll writers"
for writer in writers:
tail = source[writer.end() : writer.end() + 160]
assert re.match(
r"\s*_programmaticScrollSetAt\s*=\s*performance\.now\(\)\s*;",
tail,
), f"{source_name} writer lacks a paired freshness timestamp"