Files
webui/tests/test_issue677.py
nesquena-hermes 352354790f fix: streaming scroll override, Gemini 3.x models, read-only workspace, two-container UID — v0.50.87 (closes #677 #669 #670 #668)
- #677: renderMessages() and appendThinking() use scrollIfPinned() during stream; scroll threshold 80→150px; floating ↓ scroll-to-bottom button added
- #669: Gemini 3.1 Pro Preview, 3 Flash Preview, 3.1 Flash Lite Preview added to all provider sections; gemini-3.1-flash-lite-preview was the missing ID causing API_KEY_INVALID; GEMINI_API_KEY env var detection added
- #670: docker_init.bash guards chown/write-test with [ -w ]; :ro workspace mounts no longer crash startup
- #668: UID/GID auto-detect probes /home/hermeswebui/.hermes and HERMES_HOME before /workspace; two-container Zeabur/Compose setups inherit correct UID automatically
- 18 new tests; 1441 total passing
2026-04-18 17:09:59 +00:00

136 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for fix #677: auto-scroll override during streaming.
The scroll system has a _scrollPinned flag and scrollIfPinned() to respect
user scroll position. The bug was that scrollToBottom() was called
unconditionally inside renderMessages() and appendThinking(), even during
an active stream — overriding any scroll position the user had set.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
class TestScrollPinningFix:
def test_render_messages_respects_active_stream(self):
"""renderMessages() must not call scrollToBottom() while streaming (#677).
During an active stream, scrollToBottom() unconditionally re-pins scroll
and overrides the user's position. renderMessages() must use scrollIfPinned()
instead when S.activeStreamId is set.
"""
# Find renderMessages function
rm_start = UI_JS.find("function renderMessages()")
assert rm_start != -1, "renderMessages() not found in ui.js"
rm_end = UI_JS.find("\nfunction ", rm_start + 1)
rm_body = UI_JS[rm_start:rm_end]
# Must check activeStreamId before deciding which scroll fn to call
assert "activeStreamId" in rm_body, (
"renderMessages() must check S.activeStreamId before scrolling — "
"unconditional scrollToBottom() overrides user scroll position (#677)"
)
# scrollIfPinned must be called inside renderMessages (stream path)
assert "scrollIfPinned()" in rm_body, (
"renderMessages() must call scrollIfPinned() during streaming (#677)"
)
def test_append_thinking_uses_scroll_if_pinned(self):
"""appendThinking() must use scrollIfPinned() not scrollToBottom() (#677).
appendThinking() fires continuously during streaming — calling scrollToBottom()
inside it re-pins on every token, preventing the user from scrolling up.
"""
at_start = UI_JS.find("function appendThinking(")
assert at_start != -1, "appendThinking() not found in ui.js"
at_end = UI_JS.find("\nfunction ", at_start + 1)
at_body = UI_JS[at_start:at_end]
assert "scrollIfPinned()" in at_body, (
"appendThinking() must call scrollIfPinned() not scrollToBottom() (#677)"
)
assert "scrollToBottom()" not in at_body, (
"appendThinking() must not call scrollToBottom() — it fires mid-stream (#677)"
)
def test_scroll_threshold_increased(self):
"""Scroll re-pin threshold must be at least 150px (#677).
80px was too small — a fast mouse scroll wheel can jump 100120px in one
tick, causing unintended re-pin. 150px gives a proper dead zone.
"""
# Find the nearBottom assignment in the scroll listener
near_bottom_pos = UI_JS.find("nearBottom=")
if near_bottom_pos == -1:
near_bottom_pos = UI_JS.find("nearBottom =")
assert near_bottom_pos != -1, "nearBottom scroll threshold assignment not found"
threshold_line = UI_JS[near_bottom_pos:near_bottom_pos + 120]
# Extract the numeric threshold
match = re.search(r"<\s*(\d+)", threshold_line)
assert match, f"Numeric threshold not found near nearBottom assignment: {threshold_line!r}"
threshold = int(match.group(1))
assert threshold >= 150, (
f"Scroll re-pin threshold is {threshold}px — must be >= 150px to avoid "
f"hair-trigger re-pinning on fast scroll wheels (#677)"
)
def test_scroll_to_bottom_button_exists_in_html(self):
"""index.html must contain a scroll-to-bottom button (#677).
All major streaming chat UIs (Claude, ChatGPT) show a floating ↓ button
when the user has scrolled up, giving a clear escape hatch to return to live output.
"""
assert "scrollToBottomBtn" in INDEX_HTML, (
"index.html must contain a #scrollToBottomBtn element (#677)"
)
assert "scroll-to-bottom-btn" in INDEX_HTML, (
"index.html must use class scroll-to-bottom-btn for the scroll button (#677)"
)
def test_scroll_to_bottom_button_hidden_by_default(self):
"""Scroll-to-bottom button must be hidden by default (display:none) (#677)."""
btn_pos = INDEX_HTML.find("scrollToBottomBtn")
assert btn_pos != -1
btn_context = INDEX_HTML[btn_pos:btn_pos + 200]
assert "display:none" in btn_context or 'display="none"' in btn_context, (
"scrollToBottomBtn must be hidden by default — only shown when user scrolls up (#677)"
)
def test_scroll_to_bottom_button_css_exists(self):
"""style.css must have styling for .scroll-to-bottom-btn (#677)."""
assert ".scroll-to-bottom-btn" in STYLE_CSS, (
"style.css must define .scroll-to-bottom-btn styles (#677)"
)
def test_scroll_to_bottom_button_is_sticky(self):
"""Scroll-to-bottom button must use position:sticky so it stays visible (#677)."""
btn_css_pos = STYLE_CSS.find(".scroll-to-bottom-btn")
assert btn_css_pos != -1
btn_css = STYLE_CSS[btn_css_pos:btn_css_pos + 300]
assert "sticky" in btn_css, (
".scroll-to-bottom-btn must use position:sticky to stay at bottom of viewport (#677)"
)
def test_scroll_listener_hides_button_when_pinned(self):
"""Scroll listener must hide the button when user is near the bottom (#677)."""
scroll_listener_start = UI_JS.find("el.addEventListener('scroll'")
assert scroll_listener_start != -1, "scroll event listener not found"
listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 300]
assert "scrollToBottomBtn" in listener_block, (
"Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)"
)
def test_scroll_to_bottom_button_calls_scroll_to_bottom(self):
"""scrollToBottomBtn onclick must call scrollToBottom() (#677)."""
btn_pos = INDEX_HTML.find("scrollToBottomBtn")
assert btn_pos != -1
btn_context = INDEX_HTML[btn_pos:btn_pos + 200]
assert "scrollToBottom()" in btn_context, (
"scrollToBottomBtn onclick must call scrollToBottom() (#677)"
)