Files
webui/tests/test_sprint11.py
Hermes 7019c25021 Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
  Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)

Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export

Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name

Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)

Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00

97 lines
3.5 KiB
Python

"""
Sprint 11 Tests: multi-provider model support, streaming smoothness, routes extraction.
"""
import json, pathlib, urllib.error, urllib.request, urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
# ── /api/models endpoint ──────────────────────────────────────────────────
def test_models_endpoint_returns_200():
"""GET /api/models returns a valid response."""
d, status = get("/api/models")
assert status == 200
def test_models_has_required_fields():
"""Response includes groups, default_model, and active_provider."""
d, _ = get("/api/models")
assert 'groups' in d
assert 'default_model' in d
assert 'active_provider' in d
def test_models_groups_structure():
"""Each group has provider name and models list."""
d, _ = get("/api/models")
assert isinstance(d['groups'], list)
assert len(d['groups']) > 0
for group in d['groups']:
assert 'provider' in group
assert 'models' in group
assert isinstance(group['models'], list)
assert len(group['models']) > 0
def test_models_model_structure():
"""Each model has id and label."""
d, _ = get("/api/models")
for group in d['groups']:
for model in group['models']:
assert 'id' in model
assert 'label' in model
assert isinstance(model['id'], str)
assert isinstance(model['label'], str)
assert len(model['id']) > 0
assert len(model['label']) > 0
def test_models_default_model_not_empty():
"""Default model should be a non-empty string."""
d, _ = get("/api/models")
assert isinstance(d['default_model'], str)
assert len(d['default_model']) > 0
def test_models_at_least_one_provider():
"""At least one provider group should exist (fallback list at minimum)."""
d, _ = get("/api/models")
providers = [g['provider'] for g in d['groups']]
assert len(providers) >= 1
def test_models_no_duplicate_ids():
"""Model IDs should not be duplicated within a single group."""
d, _ = get("/api/models")
for group in d['groups']:
ids = [m['id'] for m in group['models']]
assert len(ids) == len(set(ids)), f"Duplicate model IDs in {group['provider']}: {ids}"
def test_session_preserves_unlisted_model():
"""A session with a model not in the dropdown should still load correctly."""
# Create a session with a custom model string
d, _ = post("/api/session/new", {})
sid = d['session']['session_id']
try:
custom_model = 'custom-provider/test-model-999'
post("/api/session/update", {
'session_id': sid,
'model': custom_model,
'workspace': d['session']['workspace']
})
# Reload and verify model persisted
d2, _ = get(f"/api/session?session_id={sid}")
assert d2['session']['model'] == custom_model
finally:
post("/api/session/delete", {'session_id': sid})