Files
hermes-webui/tests/test_issue644.py
T
nesquena-hermes 7d1aa2e261 v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)

Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.

Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
  with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
  settings_up_to_date, settings_updates_available, settings_updates_disabled)
  in all 6 locales (en, ru, es, de, zh, zh-Hant)

* fix: sanitize error message in checkUpdatesNow to avoid exposing paths

Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.

* fix: fully sanitize error in update check — never expose raw e.message in UI

Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales

* fix(#785): align HTML selectors with CSS and add regression tests

- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
  checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales

* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)

* feat: categorize providers in setup wizard (#603)

- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
  Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
  Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
  instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant

* ci: re-run tests

* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644

The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.

Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.

* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests

- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
  gemini, deepseek, mistral, and x-ai through apply_onboarding_setup

* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai

* fix(onboarding): correct stale model defaults for specialized providers

Three issues in the new specialized provider catalog (#1027 hold reason):

1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
   has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
   Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
   which returns []. The catalog in api/config.py is keyed under "google"
   (even though the agent's alias map normalizes google -> gemini).
   Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
   the actual 5-model list. Also forward-compatible lookup for x-ai
   (xai or x-ai key).

Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.

deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.

Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
  prefixes (3.x for gemini, 4.x for grok)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: inline HTML preview in workspace panel (#779)

Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.

Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
  in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
  in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales

The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.

* docs: document sandbox security tradeoff for HTML preview

Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.

* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading

routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.

* fix(security): CSP sandbox header for inline HTML preview

The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.

Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.

Added test_inline_html_response_sets_csp_sandbox to pin the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)

* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209

The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.

Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:33:41 -07:00

154 lines
5.9 KiB
Python

"""Tests for PR #644 — load provider models from config.yaml in get_available_models()."""
import pytest
import api.config as _cfg
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the models TTL cache before and after every test in this file."""
try:
_cfg.invalidate_models_cache()
except Exception:
pass
yield
try:
_cfg.invalidate_models_cache()
except Exception:
pass
def _available_models_with_cfg(cfg_override):
"""Helper: temporarily patch config.cfg, call get_available_models(), restore.
We also freeze _cfg_mtime to the *current* config file mtime so that
get_available_models() does not call reload_config() from disk (which
would overwrite the in-memory mock with the on-disk config.yaml).
See #644 — this race exists in CI where config.yaml is present.
"""
old_cfg = dict(_cfg.cfg)
_cfg.cfg.clear()
_cfg.cfg.update(cfg_override)
# Freeze mtime so reload_config() is not triggered inside get_available_models()
old_mtime = _cfg._cfg_mtime
try:
from pathlib import Path
_cfg._cfg_mtime = Path(_cfg._get_config_path()).stat().st_mtime
except OSError:
_cfg._cfg_mtime = 0.0
try:
return _cfg.get_available_models()
finally:
_cfg.cfg.clear()
_cfg.cfg.update(old_cfg)
_cfg._cfg_mtime = old_mtime
class TestConfigYamlModelsLoading:
"""Verify that providers with explicit models in config.yaml use those models."""
def test_provider_in_config_but_not_provider_models_gets_cfg_models(self):
"""A provider only in cfg.providers (not _PROVIDER_MODELS) should appear
with its configured model list instead of being skipped entirely."""
cfg = {
"model": {"provider": "my-custom-llm"},
"providers": {
"my-custom-llm": {
"base_url": "http://custom.local/v1",
"models": ["custom-model-a", "custom-model-b"],
}
},
}
result = _available_models_with_cfg(cfg)
groups = {g["provider"]: g["models"] for g in result["groups"]}
# Provider should appear (previously it was silently skipped)
provider_names = [g["provider"] for g in result["groups"]]
found = any("my-custom-llm" in n.lower() or "My-Custom-Llm" in n for n in provider_names)
# If it appears, its models must include our cfg models
for g in result["groups"]:
if "custom" in g["provider"].lower():
model_ids = [m["id"] for m in g["models"]]
assert any("custom-model-a" in mid for mid in model_ids), (
f"custom-model-a not in group models: {model_ids}"
)
def test_provider_models_dict_format_expanded(self):
"""models: {model_id: {context_length: ...}} — keys become model IDs."""
cfg = {
"model": {"provider": "anthropic"},
"providers": {
"anthropic": {
"models": {
"claude-custom-1": {"context_length": 200000},
"claude-custom-2": {"context_length": 100000},
}
}
},
}
result = _available_models_with_cfg(cfg)
# Find Anthropic group
for g in result["groups"]:
if g["provider"] == "Anthropic":
model_ids = [m["id"] for m in g["models"]]
assert "claude-custom-1" in model_ids, (
f"claude-custom-1 not in Anthropic models: {model_ids}"
)
assert "claude-custom-2" in model_ids, (
f"claude-custom-2 not in Anthropic models: {model_ids}"
)
break
def test_provider_models_list_format_expanded(self):
"""models: [model_id, ...] — items become model IDs."""
cfg = {
"model": {"provider": "anthropic"},
"providers": {
"anthropic": {
"models": ["claude-list-only-1", "claude-list-only-2"],
}
},
}
result = _available_models_with_cfg(cfg)
for g in result["groups"]:
if g["provider"] == "Anthropic":
model_ids = [m["id"] for m in g["models"]]
assert "claude-list-only-1" in model_ids, (
f"claude-list-only-1 not in Anthropic models: {model_ids}"
)
break
def test_provider_in_provider_models_but_no_cfg_override_unchanged(self):
"""When no models key in cfg.providers, hardcoded _PROVIDER_MODELS still used."""
cfg = {
"model": {"provider": "anthropic"},
"providers": {
"anthropic": {
"api_key": "sk-test",
# No 'models' key
}
},
}
result = _available_models_with_cfg(cfg)
raw_ids = {m["id"] for m in _cfg._PROVIDER_MODELS.get("anthropic", [])}
for g in result["groups"]:
if g["provider"] == "Anthropic":
returned_ids = {m["id"] for m in g["models"]}
# Should still have the hardcoded models
overlap = raw_ids & returned_ids
assert overlap, (
f"No _PROVIDER_MODELS models found in Anthropic group. "
f"Expected subset of {raw_ids}, got {returned_ids}"
)
break
def test_non_dict_models_value_falls_through_gracefully(self):
"""If models value is neither dict nor list (e.g. null), no crash."""
cfg = {
"model": {"provider": "anthropic"},
"providers": {
"anthropic": {"models": None}, # invalid — should not crash
},
}
# Should not raise
result = _available_models_with_cfg(cfg)
assert "groups" in result