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
This commit is contained in:
@@ -147,20 +147,21 @@ class TestWorkspacePermissions:
|
||||
)
|
||||
|
||||
def test_workspace_uses_sudo_chown(self):
|
||||
"""docker_init.bash must chown the workspace to hermeswebui after mkdir."""
|
||||
ws_section = INIT_SCRIPT[
|
||||
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"):
|
||||
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800
|
||||
]
|
||||
assert "sudo chown" in ws_section and "hermeswebui" in ws_section, (
|
||||
"""docker_init.bash must chown the workspace to hermeswebui when writable.
|
||||
|
||||
The chown is now conditional on the workspace being writable, to allow
|
||||
read-only (:ro) workspace mounts without crashing (#670). The sudo chown
|
||||
must still be present in the script (just guarded by [ -w ]).
|
||||
"""
|
||||
assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in INIT_SCRIPT, (
|
||||
"docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace "
|
||||
"directory after creating it, so the app user can write to it (#357)"
|
||||
"when it is writable, so the app user can write to it (#357)"
|
||||
)
|
||||
|
||||
def test_workspace_mkdir_before_chown(self):
|
||||
"""sudo mkdir must come before sudo chown in docker_init.bash."""
|
||||
mkdir_pos = INIT_SCRIPT.find("sudo mkdir -p \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"")
|
||||
chown_pos = INIT_SCRIPT.find("sudo chown hermeswebui:hermeswebui \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"")
|
||||
mkdir_pos = INIT_SCRIPT.find('sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
|
||||
chown_pos = INIT_SCRIPT.find('sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
|
||||
assert mkdir_pos != -1, "sudo mkdir for workspace not found"
|
||||
assert chown_pos != -1, "sudo chown for workspace not found"
|
||||
assert mkdir_pos < chown_pos, "sudo mkdir must come before sudo chown"
|
||||
@@ -171,10 +172,19 @@ class TestWorkspacePermissions:
|
||||
"sudo mkdir for workspace must call error_exit on failure"
|
||||
)
|
||||
|
||||
def test_workspace_error_exit_on_chown_failure(self):
|
||||
"""sudo chown must call error_exit on failure."""
|
||||
assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, (
|
||||
"sudo chown for workspace must call error_exit on failure"
|
||||
def test_workspace_chown_is_conditional_on_writable(self):
|
||||
"""chown and write-test must be skipped for read-only workspace mounts (#670).
|
||||
|
||||
The script must check [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ] before
|
||||
attempting chown or a write test, so :ro bind-mounts don't crash startup.
|
||||
"""
|
||||
assert '[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]' in INIT_SCRIPT, (
|
||||
"docker_init.bash must guard chown with [ -w ] to support read-only "
|
||||
"workspace mounts (:ro) without crashing (#670)"
|
||||
)
|
||||
# Read-only path must log a clear message rather than calling error_exit
|
||||
assert "read-only workspace is supported" in INIT_SCRIPT, (
|
||||
"docker_init.bash must print a clear message when workspace is read-only (#670)"
|
||||
)
|
||||
|
||||
def test_init_script_syntax_valid(self):
|
||||
|
||||
@@ -47,9 +47,9 @@ def test_569_autodetect_before_usermod():
|
||||
|
||||
def test_569_skips_root_uid():
|
||||
"""Auto-detect must not use UID 0 (root-owned mount = untrustworthy)."""
|
||||
detect_block_start = INIT_SH.find("Auto-detect from mounted workspace")
|
||||
detect_block_start = INIT_SH.find("Auto-detect from mounted volumes")
|
||||
assert detect_block_start != -1, "auto-detect comment block not found"
|
||||
block = INIT_SH[detect_block_start:detect_block_start + 600]
|
||||
block = INIT_SH[detect_block_start:detect_block_start + 1200]
|
||||
assert '"0"' in block or "'0'" in block, (
|
||||
"Auto-detect block must skip UID 0 to avoid incorrectly using root ownership"
|
||||
)
|
||||
@@ -65,6 +65,58 @@ def test_569_fallback_preserved():
|
||||
)
|
||||
|
||||
|
||||
# ── #668: UID/GID auto-detect from hermes-home shared volume (two-container) ──
|
||||
|
||||
def test_668_uid_autodetect_checks_hermes_home():
|
||||
"""docker_init.bash must probe hermes-home dirs for UID in two-container setups.
|
||||
|
||||
When hermes-agent and hermes-webui run in separate containers sharing a
|
||||
named volume, /workspace may not exist but ~/.hermes will be owned by the
|
||||
agent's UID. The init script must probe it so the webui user is remapped
|
||||
to match (#668).
|
||||
"""
|
||||
assert "/home/hermeswebui/.hermes" in INIT_SH, (
|
||||
"docker_init.bash must probe /home/hermeswebui/.hermes for UID detection "
|
||||
"to support two-container setups where /workspace may not exist (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_gid_autodetect_checks_hermes_home():
|
||||
"""docker_init.bash must probe hermes-home dirs for GID in two-container setups (#668)."""
|
||||
# Both UID and GID detection share the same probe dirs — check GID block too
|
||||
gid_detect_start = INIT_SH.find("Auto-detect GID from mounted volumes")
|
||||
assert gid_detect_start != -1, (
|
||||
"GID auto-detect comment must be updated to mention shared volumes (#668)"
|
||||
)
|
||||
gid_block = INIT_SH[gid_detect_start:gid_detect_start + 600]
|
||||
assert "/home/hermeswebui/.hermes" in gid_block or "HERMES_HOME" in gid_block, (
|
||||
"GID auto-detect block must probe hermes-home dirs (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_uid_probe_loop_uses_break():
|
||||
"""UID probe loop must stop on first match (no double-detection)."""
|
||||
uid_detect_start = INIT_SH.find("Auto-detect from mounted volumes")
|
||||
assert uid_detect_start != -1, "UID auto-detect comment not found"
|
||||
uid_block = INIT_SH[uid_detect_start:uid_detect_start + 1200]
|
||||
assert "break" in uid_block, (
|
||||
"UID probe loop must break after first successful detection "
|
||||
"to avoid being overridden by a later probe dir (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_hermes_home_probe_before_workspace():
|
||||
"""Hermes-home probe must appear before /workspace probe in docker_init.bash (#668)."""
|
||||
hermes_home_pos = INIT_SH.find("/home/hermeswebui/.hermes")
|
||||
workspace_pos = INIT_SH.find('if [ -d "/workspace" ]')
|
||||
assert hermes_home_pos != -1, "/home/hermeswebui/.hermes probe not found"
|
||||
assert workspace_pos != -1, "/workspace probe not found"
|
||||
assert hermes_home_pos < workspace_pos, (
|
||||
"Hermes-home probe must come before /workspace probe — "
|
||||
"shared volume UID should take priority over workspace UID (#668)"
|
||||
)
|
||||
|
||||
|
||||
# ── #579: topbar message count already filters tool messages ──────────────────
|
||||
|
||||
def test_579_topbar_filters_tool_messages():
|
||||
|
||||
135
tests/test_issue677.py
Normal file
135
tests/test_issue677.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
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 100–120px 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)"
|
||||
)
|
||||
@@ -230,3 +230,88 @@ class TestLiveModelFetching:
|
||||
assert live_route_pos > handle_get_pos, (
|
||||
"/api/models/live must be inside handle_get() (#375)"
|
||||
)
|
||||
|
||||
|
||||
# ── #669: Gemini model IDs must be valid for Google AI Studio endpoint ────────
|
||||
|
||||
class TestGeminiModelIds:
|
||||
"""Gemini 3.x model IDs must be valid for the native Google AI Studio provider.
|
||||
|
||||
The original code had gemini-3.1-flash-lite-preview missing from the
|
||||
dropdown. The fallback list also erroneously used gemini-3.1-pro-preview
|
||||
in some provider sections while omitting gemini-3.1-flash-lite-preview.
|
||||
All provider sections must now include the full current Gemini 3.x lineup.
|
||||
"""
|
||||
|
||||
VALID_GEMINI_3 = [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
]
|
||||
|
||||
def test_gemini_provider_models_has_3x(self):
|
||||
"""_PROVIDER_MODELS['gemini'] must contain valid Gemini 3.x model IDs (#669)."""
|
||||
gemini_block_start = CONFIG_PY.find('"gemini": [')
|
||||
assert gemini_block_start != -1, "_PROVIDER_MODELS['gemini'] block not found"
|
||||
gemini_block = CONFIG_PY[gemini_block_start:gemini_block_start + 600]
|
||||
for mid in self.VALID_GEMINI_3:
|
||||
assert mid in gemini_block, (
|
||||
f"_PROVIDER_MODELS['gemini'] must contain {mid!r} — "
|
||||
f"this is a valid Google AI Studio model ID (#669)"
|
||||
)
|
||||
|
||||
def test_gemini_provider_models_has_flash_lite(self):
|
||||
"""_PROVIDER_MODELS['gemini'] must contain gemini-3.1-flash-lite-preview (#669).
|
||||
|
||||
This was the model the reporter selected from the wizard — it must appear
|
||||
in the native gemini provider model list so users can select it.
|
||||
"""
|
||||
gemini_block_start = CONFIG_PY.find('"gemini": [')
|
||||
assert gemini_block_start != -1
|
||||
gemini_block = CONFIG_PY[gemini_block_start:gemini_block_start + 600]
|
||||
assert "gemini-3.1-flash-lite-preview" in gemini_block, (
|
||||
"_PROVIDER_MODELS['gemini'] missing gemini-3.1-flash-lite-preview — "
|
||||
"this was the exact model the #669 reporter tried and got API_KEY_INVALID"
|
||||
)
|
||||
|
||||
def test_fallback_models_has_gemini_3x(self):
|
||||
"""_FALLBACK_MODELS must contain valid Gemini 3.x OpenRouter model IDs (#669)."""
|
||||
fallback_start = CONFIG_PY.find("_FALLBACK_MODELS = [")
|
||||
fallback_end = CONFIG_PY.find("]", fallback_start + len("_FALLBACK_MODELS = ["))
|
||||
# Find the closing bracket for the list (multi-line)
|
||||
depth = 0
|
||||
pos = fallback_start + len("_FALLBACK_MODELS = [")
|
||||
for i, ch in enumerate(CONFIG_PY[pos:], start=pos):
|
||||
if ch == '[':
|
||||
depth += 1
|
||||
elif ch == ']':
|
||||
if depth == 0:
|
||||
fallback_end = i
|
||||
break
|
||||
depth -= 1
|
||||
fallback_block = CONFIG_PY[fallback_start:fallback_end]
|
||||
for mid in ("google/gemini-3.1-pro-preview", "google/gemini-3-flash-preview"):
|
||||
assert mid in fallback_block, (
|
||||
f"_FALLBACK_MODELS must contain {mid!r} for OpenRouter Google models (#669)"
|
||||
)
|
||||
|
||||
def test_gemini_provider_also_has_stable_25(self):
|
||||
"""_PROVIDER_MODELS['gemini'] must retain stable Gemini 2.5 models (#669)."""
|
||||
gemini_block_start = CONFIG_PY.find('"gemini": [')
|
||||
assert gemini_block_start != -1
|
||||
gemini_block = CONFIG_PY[gemini_block_start:gemini_block_start + 600]
|
||||
assert "gemini-2.5-pro" in gemini_block, (
|
||||
"_PROVIDER_MODELS['gemini'] must keep gemini-2.5-pro as a stable fallback"
|
||||
)
|
||||
|
||||
def test_no_invalid_gemini_3_pro_model(self):
|
||||
"""gemini-3-pro-preview must not appear — it was shut down March 9 2026 (#669)."""
|
||||
assert "gemini-3-pro-preview" not in CONFIG_PY or "gemini-3.1-pro-preview" in CONFIG_PY, (
|
||||
"gemini-3-pro-preview was shut down — use gemini-3.1-pro-preview instead (#669)"
|
||||
)
|
||||
# More precise: ensure the bare (non-.1) version isn't the only one present
|
||||
count_bare = CONFIG_PY.count('"gemini-3-pro-preview"')
|
||||
assert count_bare == 0, (
|
||||
f"gemini-3-pro-preview appears {count_bare} time(s) in config.py — "
|
||||
"it was shut down March 9 2026, use gemini-3.1-pro-preview (#669)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user