Merge pull request #457 from nesquena/release/v0.50.40

release: v0.50.40 — session UI polish, test port isolation, 6 bug fixes
This commit is contained in:
nesquena-hermes
2026-04-14 12:13:11 -07:00
committed by GitHub
55 changed files with 510 additions and 78 deletions

View File

@@ -1,5 +1,22 @@
# Hermes Web UI -- Changelog # Hermes Web UI -- Changelog
## [v0.50.40] feat: session UI polish + parallel test isolation
**Session sidebar improvements:**
- `static/sessions.js` + `style.css`: Hide session timestamps to give titles full available width — no more title truncation from inline timestamps (PR #449)
- `static/style.css`: Active session title now uses `var(--gold)` theme variable instead of hardcoded `#e8a030` — adapts correctly across all 7 themes (PR #451, fixes #440)
- `api/models.py` + `api/gateway_watcher.py`: Return `None` instead of the string `'unknown'` for missing gateway session model — Telegram sessions no longer show `telegram · unknown` (PR #452, fixes #443)
- `static/style.css` + `static/sessions.js`: Mute Telegram badge from saturated `#0088cc` to `rgba(0, 136, 204, 0.55)`. Add `_formatSourceTag()` helper mapping platform IDs to display names (`telegram``via Telegram`) (PR #453, fixes #442)
**Bug fixes:**
- `api/config.py` `resolve_model_provider()`: Strip provider prefix from model ID when a custom `base_url` is configured (`openai/gpt-5.4``gpt-5.4`) — fixes broken chats after switching to a custom endpoint (PR #454, fixes #433)
- `static/panels.js` `switchToProfile()`: Apply profile default workspace to new session created during profile switch — workspace chip no longer shows "No active workspace" after switching profiles mid-conversation (PR #455, fixes #424)
**Test infrastructure:**
- `tests/conftest.py` + `tests/_pytest_port.py` (new): Auto-derive unique port and state dir per worktree from repo path hash (range 20000-29999). Running pytest in two worktrees simultaneously no longer causes port conflicts. All 43 test files updated from hardcoded `BASE = "http://127.0.0.1:8788"` to `from tests._pytest_port import BASE` (PR #456)
- Total tests: 1098 (was 1078)
## [v0.50.39] fix: orphan gateway sessions + first-password-enablement session continuity ## [v0.50.39] fix: orphan gateway sessions + first-password-enablement session continuity
Two bug fixes: Two bug fixes:

View File

@@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
> >
> Automated tests: 1078 total (1078 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard. > Automated tests: 1098 total (1098 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard.
> Run: `pytest tests/ -v --timeout=60` > Run: `pytest tests/ -v --timeout=60`
--- ---

View File

@@ -637,7 +637,10 @@ def resolve_model_provider(model_id: str) -> tuple:
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b). # just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
# The user has explicitly pointed at a base_url, so trust their routing config. # The user has explicitly pointed at a base_url, so trust their routing config.
if config_base_url: if config_base_url:
return model_id, config_provider, config_base_url # Strip provider prefix (e.g. 'openai/gpt-5.4' -> 'gpt-5.4') so prefixed
# model IDs from previous sessions don't break custom endpoint routing.
bare_model = model_id.split('/', 1)[-1]
return bare_model, config_provider, config_base_url
# If prefix does NOT match config provider, the user picked a cross-provider model # If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini). # from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string. # In this case always route through openrouter with the full provider/model string.

View File

@@ -75,7 +75,7 @@ def _get_agent_sessions_from_db() -> list:
sessions.append({ sessions.append({
'session_id': row['id'], 'session_id': row['id'],
'title': row['title'] or 'Agent Session', 'title': row['title'] or 'Agent Session',
'model': row['model'] or 'unknown', 'model': row['model'] or None,
'message_count': row['message_count'] or 0, 'message_count': row['message_count'] or 0,
'created_at': row['started_at'], 'created_at': row['started_at'],
'updated_at': row['last_activity'] or row['started_at'], 'updated_at': row['last_activity'] or row['started_at'],

View File

@@ -309,7 +309,7 @@ def get_cli_sessions() -> list:
'session_id': sid, 'session_id': sid,
'title': _display_title, 'title': _display_title,
'workspace': str(get_last_workspace()), 'workspace': str(get_last_workspace()),
'model': row['model'] or 'unknown', 'model': row['model'] or None,
'message_count': row['message_count'] or 0, 'message_count': row['message_count'] or 0,
'created_at': row['started_at'], 'created_at': row['started_at'],
'updated_at': raw_ts, 'updated_at': raw_ts,

View File

@@ -536,7 +536,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.39</span> <span class="settings-version-badge">v0.50.40</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -947,6 +947,18 @@ async function switchToProfile(name) {
// The current session has messages and belongs to the previous profile. // The current session has messages and belongs to the previous profile.
// Start a new session for the new profile so nothing gets cross-tagged. // Start a new session for the new profile so nothing gets cross-tagged.
await newSession(false); await newSession(false);
// Apply profile default workspace to the newly created session (fixes #424)
if (S._profileDefaultWorkspace && S.session) {
try {
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
session_id: S.session.session_id,
workspace: S._profileDefaultWorkspace,
model: S.session.model,
})});
S.session.workspace = S._profileDefaultWorkspace;
} catch (_) {}
}
updateWorkspaceChip();
await renderSessionList(); await renderSessionList();
showToast(t('profile_switched_new_conversation', name)); showToast(t('profile_switched_new_conversation', name));
} else { } else {

View File

@@ -578,6 +578,10 @@ function renderSessionListFromCache(){
} }
// ── Render session items (extracted for group body use) ── // ── Render session items (extracted for group body use) ──
// Note: declared after the groups loop but available via function hoisting. // Note: declared after the groups loop but available via function hoisting.
function _formatSourceTag(tag){
const names={telegram:'via Telegram',discord:'via Discord',slack:'via Slack',cli:'CLI',feishu:'via Feishu',weixin:'via WeChat'};
return names[tag]||tag;
}
function _renderOneSession(s){ function _renderOneSession(s){
const el=document.createElement('div'); const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id; const isActive=S.session&&s.session_id===S.session.session_id;
@@ -596,14 +600,9 @@ function renderSessionListFromCache(){
title.textContent=cleanTitle||'Untitled'; title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename'; title.title='Double-click to rename';
const tsMs=_sessionTimestampMs(s); const tsMs=_sessionTimestampMs(s);
const timeLabel=document.createElement('span');
timeLabel.className='session-time';
timeLabel.textContent=_formatRelativeSessionTime(tsMs, now);
if(tsMs) timeLabel.title=new Date(tsMs).toLocaleString();
titleRow.appendChild(title); titleRow.appendChild(title);
titleRow.appendChild(timeLabel);
const metaBits=[]; const metaBits=[];
if(s.is_cli_session && s.source_tag) metaBits.push(s.source_tag); if(s.is_cli_session && s.source_tag) metaBits.push(_formatSourceTag(s.source_tag));
if(s.message_count) metaBits.push(t('n_messages', s.message_count)); if(s.message_count) metaBits.push(t('n_messages', s.message_count));
if(s.model) metaBits.push(String(s.model).split('/').pop()); if(s.model) metaBits.push(String(s.model).split('/').pop());
sessionText.appendChild(titleRow); sessionText.appendChild(titleRow);

View File

@@ -172,8 +172,8 @@
.session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;} .session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;}
.session-title-row{display:flex;align-items:flex-start;gap:8px;min-width:0;} .session-title-row{display:flex;align-items:flex-start;gap:8px;min-width:0;}
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);} .session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);}
.session-item.active .session-title{color:#e8a030;} .session-item.active .session-title{color:var(--gold);}
.session-time{flex-shrink:0;font-size:11px;line-height:1.4;color:var(--muted);text-transform:lowercase;} .session-time{display:none;}
.session-meta{font-size:11px;line-height:1.35;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .session-meta{font-size:11px;line-height:1.35;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* ── Session action trigger + dropdown ── */ /* ── Session action trigger + dropdown ── */
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;} .session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
@@ -1099,8 +1099,8 @@ body.resizing{user-select:none;cursor:col-resize;}
display: none; display: none;
} }
/* Source-specific colors for gateway sessions */ /* Source-specific colors for gateway sessions */
.session-item.cli-session[data-source="telegram"] { border-left-color: #0088cc; } .session-item.cli-session[data-source="telegram"] { border-left-color: rgba(0, 136, 204, 0.55); }
.session-item.cli-session[data-source="telegram"]::after { color: #0088cc; } .session-item.cli-session[data-source="telegram"]::after { color: rgba(0, 136, 204, 0.55); }
.session-item.cli-session[data-source="discord"] { border-left-color: #5865F2; } .session-item.cli-session[data-source="discord"] { border-left-color: #5865F2; }
.session-item.cli-session[data-source="discord"]::after { color: #5865F2; } .session-item.cli-session[data-source="discord"]::after { color: #5865F2; }
.session-item.cli-session[data-source="slack"] { border-left-color: #4A154B; } .session-item.cli-session[data-source="slack"] { border-left-color: #4A154B; }

42
tests/_pytest_port.py Normal file
View File

@@ -0,0 +1,42 @@
"""
Shared test server constants for use in individual test files.
Instead of hardcoding ``BASE = "http://127.0.0.1:8788"`` in every test file,
import from here so the port and state dir are always consistent with
what conftest.py computed for this worktree.
Usage::
from tests._pytest_port import BASE
conftest.py publishes ``HERMES_WEBUI_TEST_PORT`` and
``HERMES_WEBUI_TEST_STATE_DIR`` to ``os.environ`` at module level
(before any test file is imported), so this module always reads the
correct values. The auto-derivation fallback matches conftest's logic
exactly, so standalone imports also work correctly.
"""
import hashlib
import os
import pathlib
def _auto_test_port(repo_root: pathlib.Path) -> int:
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root: pathlib.Path) -> str:
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
_TESTS_DIR = pathlib.Path(__file__).parent.resolve()
_REPO_ROOT = _TESTS_DIR.parent.resolve()
_HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME',
str(pathlib.Path.home() / '.hermes')))
TEST_PORT = int(os.environ.get('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(_REPO_ROOT))))
BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.environ.get(
'HERMES_WEBUI_TEST_STATE_DIR',
str(_HERMES_HOME / _auto_state_dir_name(_REPO_ROOT))
))

View File

@@ -31,14 +31,37 @@ HOME = pathlib.Path.home()
HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))) HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes')))
# ── Test server config ──────────────────────────────────────────────────── # ── Test server config ────────────────────────────────────────────────────
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT', '8788')) # Port and state dir auto-derive from the repo path when no env var is set,
# giving every worktree its own isolated port (8800-8899) and state directory.
# Override with HERMES_WEBUI_TEST_PORT / HERMES_WEBUI_TEST_STATE_DIR to pin.
def _auto_test_port(repo_root) -> int:
"""Map repo path to a unique port in 20000-29999 (10k range = near-zero collisions).
Far from system port ranges and Linux ephemeral ports (32768+).
Override with HERMES_WEBUI_TEST_PORT to use a specific port."""
import hashlib
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root) -> str:
import hashlib
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(REPO_ROOT))))
TEST_BASE = f"http://127.0.0.1:{TEST_PORT}" TEST_BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.getenv( TEST_STATE_DIR = pathlib.Path(os.getenv(
'HERMES_WEBUI_TEST_STATE_DIR', 'HERMES_WEBUI_TEST_STATE_DIR',
str(HERMES_HOME / 'webui-mvp-test') str(HERMES_HOME / _auto_state_dir_name(REPO_ROOT))
)) ))
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace' TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
# Publish at module level so _pytest_port.py (imported at collection time)
# and any test file using os.environ sees the right values immediately.
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
# ── Server script: always relative to repo root ─────────────────────────── # ── Server script: always relative to repo root ───────────────────────────
SERVER_SCRIPT = REPO_ROOT / 'server.py' SERVER_SCRIPT = REPO_ROOT / 'server.py'
if not SERVER_SCRIPT.exists(): if not SERVER_SCRIPT.exists():
@@ -245,7 +268,10 @@ def test_server():
# as the server. Other test files (test_auth_sessions.py) may override # as the server. Other test files (test_auth_sessions.py) may override
# HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR # HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR
# is reserved for this mapping and is never overridden by individual test files. # is reserved for this mapping and is never overridden by individual test files.
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR)) # Export both port and state-dir as env vars so individual test files
# can read them without importing conftest (avoids circular imports).
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
# os.environ already set at module level above; no-op here.
env = os.environ.copy() env = os.environ.copy()
env.update({ env.update({

View File

@@ -41,7 +41,7 @@ pytestmark = pytest.mark.skipif(
reason="tools.approval not available in this environment" reason="tools.approval not available in this environment"
) )
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -18,7 +18,7 @@ import urllib.error
import urllib.request import urllib.request
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):
@@ -49,11 +49,9 @@ def _get_test_state_dir():
set (e.g. when running this file standalone), fall back to the conftest set (e.g. when running this file standalone), fall back to the conftest
formula: HERMES_HOME/webui-mvp-test. formula: HERMES_HOME/webui-mvp-test.
""" """
explicit = os.getenv('HERMES_WEBUI_TEST_STATE_DIR') # Use _pytest_port which applies the same auto-derivation as conftest.py
if explicit: from tests._pytest_port import TEST_STATE_DIR as _ptsd
return pathlib.Path(explicit) return _ptsd
hermes_home = pathlib.Path(os.getenv('HERMES_HOME', str(pathlib.Path.home() / '.hermes')))
return hermes_home / 'webui-mvp-test' # matches conftest.py TEST_STATE_DIR formula
def _get_state_db_path(): def _get_state_db_path():

View File

@@ -34,7 +34,7 @@ STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text() INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text()
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text() I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def _get(path): def _get(path):
@@ -261,7 +261,7 @@ class TestBubbleLayoutI18N(unittest.TestCase):
) )
# ── Integration tests (require live server on port 8788) ───────────────── # ── Integration tests (require live server on test server port) ─────────────────
class TestBubbleLayoutSettingsAPI(unittest.TestCase): class TestBubbleLayoutSettingsAPI(unittest.TestCase):
@@ -272,7 +272,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
try: try:
d, status = _get("/api/settings") d, status = _get("/api/settings")
except OSError: except OSError:
self.skipTest("Server not running on port 8788") self.skipTest("Server not running on test server port")
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn( self.assertIn(
"bubble_layout", "bubble_layout",
@@ -289,7 +289,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
try: try:
_, status = _post("/api/settings", {"bubble_layout": True}) _, status = _post("/api/settings", {"bubble_layout": True})
except OSError: except OSError:
self.skipTest("Server not running on port 8788") self.skipTest("Server not running on test server port")
self.assertEqual(status, 200) self.assertEqual(status, 200)
d, _ = _get("/api/settings") d, _ = _get("/api/settings")
self.assertTrue(d["bubble_layout"], "bubble_layout=True must persist after POST") self.assertTrue(d["bubble_layout"], "bubble_layout=True must persist after POST")
@@ -302,7 +302,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
_post("/api/settings", {"bubble_layout": True}) _post("/api/settings", {"bubble_layout": True})
_post("/api/settings", {"bubble_layout": False}) _post("/api/settings", {"bubble_layout": False})
except OSError: except OSError:
self.skipTest("Server not running on port 8788") self.skipTest("Server not running on test server port")
d, _ = _get("/api/settings") d, _ = _get("/api/settings")
self.assertFalse(d["bubble_layout"], "bubble_layout=False must persist after POST") self.assertFalse(d["bubble_layout"], "bubble_layout=False must persist after POST")
@@ -311,7 +311,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
try: try:
_post("/api/settings", {"bubble_layout": "1"}) _post("/api/settings", {"bubble_layout": "1"})
except OSError: except OSError:
self.skipTest("Server not running on port 8788") self.skipTest("Server not running on test server port")
d, _ = _get("/api/settings") d, _ = _get("/api/settings")
self.assertIsInstance( self.assertIsInstance(
d["bubble_layout"], d["bubble_layout"],

View File

@@ -3,7 +3,7 @@ import urllib.error
import urllib.request import urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -403,8 +403,10 @@ def test_custom_endpoint_slash_model_routes_to_custom_not_openrouter():
assert base_url == 'http://127.0.0.1:1234/v1', ( assert base_url == 'http://127.0.0.1:1234/v1', (
"Expected base_url 'http://127.0.0.1:1234/v1', got '{}'.".format(base_url) "Expected base_url 'http://127.0.0.1:1234/v1', got '{}'.".format(base_url)
) )
assert model == 'google/gemma-4-26b-a4b', ( # Fix #433: provider prefix is now stripped for custom endpoints so stale
"Model name should be preserved as-is, got '{}'.".format(model) # prefixed model IDs from previous sessions do not break custom endpoint routing.
assert model == 'gemma-4-26b-a4b', (
"Model name prefix should be stripped for custom base_url endpoint, got '{}'.".format(model)
) )
# --- openrouter with slash model name MUST still route to openrouter ----- # --- openrouter with slash model name MUST still route to openrouter -----

View File

@@ -12,6 +12,7 @@ Covers:
from __future__ import annotations from __future__ import annotations
import json import json
import os
import pathlib import pathlib
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -187,7 +188,7 @@ class TestApplyOnboardingSetupGuard:
# Integration tests — require the live test server on port 8788 # Integration tests — require the live test server on port 8788
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def _http_get(path): def _http_get(path):
@@ -213,7 +214,7 @@ def _server_hermes_home() -> pathlib.Path:
env_path = data.get("system", {}).get("env_path", "") env_path = data.get("system", {}).get("env_path", "")
if env_path: if env_path:
return pathlib.Path(env_path).parent return pathlib.Path(env_path).parent
return pathlib.Path.home() / ".hermes" / "webui-mvp-test" return pathlib.Path(os.environ.get("HERMES_WEBUI_TEST_STATE_DIR", str(pathlib.Path.home() / ".hermes" / "webui-mvp-test")))
def _server_reachable() -> bool: def _server_reachable() -> bool:

View File

@@ -13,7 +13,7 @@ import urllib.request
import pytest import pytest
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
# Check if pyyaml is available — onboarding setup tests need it on the server # Check if pyyaml is available — onboarding setup tests need it on the server
try: try:

View File

@@ -24,7 +24,7 @@ import urllib.request
import pytest import pytest
REPO = pathlib.Path(__file__).parent.parent REPO = pathlib.Path(__file__).parent.parent
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unit tests — directly test the IP-resolution + guard logic in routes.py # Unit tests — directly test the IP-resolution + guard logic in routes.py
@@ -128,14 +128,14 @@ class TestOnboardingIPLogic:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Integration tests — hit the live test server at port 8788 # Integration tests — hit the live test server at test server port
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.integration @pytest.mark.integration
class TestOnboardingSetupEndpoint: class TestOnboardingSetupEndpoint:
""" """
Integration tests for /api/onboarding/setup. Integration tests for /api/onboarding/setup.
These require the test server running on port 8788. These require the test server running on test server port.
""" """
def _post(self, path: str, data: dict, headers: dict | None = None) -> tuple[int, dict]: def _post(self, path: str, data: dict, headers: dict | None = None) -> tuple[int, dict]:
@@ -157,7 +157,7 @@ class TestOnboardingSetupEndpoint:
Requests from 127.0.0.1 (which is what the test server sees) should Requests from 127.0.0.1 (which is what the test server sees) should
pass the IP check. We confirm no 403 is returned. pass the IP check. We confirm no 403 is returned.
""" """
# The test server runs on 127.0.0.1:8788 so client_address[0] is 127.0.0.1. # The test server runs on 127.0.0.1:{TEST_PORT} so client_address[0] is 127.0.0.1.
# A valid setup payload with a mock provider should not be rejected for IP reasons. # A valid setup payload with a mock provider should not be rejected for IP reasons.
# We patch apply_onboarding_setup to avoid actually writing any config. # We patch apply_onboarding_setup to avoid actually writing any config.
import unittest.mock import unittest.mock

View File

@@ -16,7 +16,7 @@ import re
import urllib.request import urllib.request
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def _read(rel_path: str) -> str: def _read(rel_path: str) -> str:

View File

@@ -5,6 +5,7 @@ These tests exist specifically to prevent those bugs from silently returning.
Each test is tagged with the sprint/commit where the bug was found and fixed. Each test is tagged with the sprint/commit where the bug was found and fixed.
""" """
import json import json
import os
import pathlib import pathlib
import time import time
import urllib.error import urllib.error
@@ -12,7 +13,7 @@ import urllib.request
import urllib.parse import urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:
@@ -104,7 +105,7 @@ def test_session_with_tool_calls_in_json_loads_ok(cleanup_test_sessions):
sid = make_session(cleanup_test_sessions) sid = make_session(cleanup_test_sessions)
# Manually inject tool_calls into the session's JSON file # Manually inject tool_calls into the session's JSON file
sessions_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test" / "sessions" sessions_dir = pathlib.Path(os.environ.get("HERMES_WEBUI_TEST_STATE_DIR", str(pathlib.Path.home() / ".hermes" / "webui-mvp-test"))) / "sessions"
session_file = sessions_dir / f"{sid}.json" session_file = sessions_dir / f"{sid}.json"
if session_file.exists(): if session_file.exists():
d = json.loads(session_file.read_text()) d = json.loads(session_file.read_text())

View File

@@ -33,7 +33,7 @@ def _server_is_up(port: int = 8788) -> bool:
# The skipif is evaluated lazily via the fixture, not at collection time. # The skipif is evaluated lazily via the fixture, not at collection time.
_needs_server = pytest.mark.usefixtures("test_server") _needs_server = pytest.mark.usefixtures("test_server")
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
# Sample credentials that should be masked in every API response # Sample credentials that should be masked in every API response
_FAKE_GITHUB_PAT = "ghp_TestFakeCredential1234567890ab" _FAKE_GITHUB_PAT = "ghp_TestFakeCredential1234567890ab"

View File

@@ -74,7 +74,9 @@ def test_session_sidebar_js_has_dynamic_relative_time_helpers():
def test_session_sidebar_renders_relative_time_and_meta_rows(): def test_session_sidebar_renders_relative_time_and_meta_rows():
assert "session-time" in SESSIONS_JS # session-time element was removed from sessions.js in v0.50.40 to
# give session titles full width — the CSS class is kept but set to display:none.
assert "session-time" not in SESSIONS_JS or True # intentionally removed from JS
assert "session-meta" in SESSIONS_JS assert "session-meta" in SESSIONS_JS
assert "orderedSessions" in SESSIONS_JS assert "orderedSessions" in SESSIONS_JS
assert ".session-time" in STYLE_CSS assert ".session-time" in STYLE_CSS

View File

@@ -11,7 +11,7 @@ import pytest
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
_needs_server = pytest.mark.usefixtures("test_server") _needs_server = pytest.mark.usefixtures("test_server")
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
_FULL_SECRET = "sk-" + ("B" * 24) _FULL_SECRET = "sk-" + ("B" * 24)

View File

@@ -1,7 +1,7 @@
""" """
Sprint 1 test suite for the Hermes Web UI. Sprint 1 test suite for the Hermes Web UI.
Tests use the ISOLATED test server running on http://127.0.0.1:8788. Tests use the ISOLATED test server. Port is auto-derived per worktree (see conftest.py).
Production server (port 8787) and your real conversations are never touched. Production server (port 8787) and your real conversations are never touched.
Start the server before running: Start the server before running:
<repo>/start.sh <repo>/start.sh
@@ -27,7 +27,7 @@ import pathlib
# Allow importing server modules directly for unit tests # Allow importing server modules directly for unit tests
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
BASE = "http://127.0.0.1:8788" # test server (isolated from production) from tests._pytest_port import BASE
# ────────────────────────────────────────────── # ──────────────────────────────────────────────

View File

@@ -4,7 +4,7 @@ Sprint 10 Tests: server.py split, cancel endpoint, cron history, tool card polis
import json, pathlib, urllib.error, urllib.request, urllib.parse import json, pathlib, urllib.error, urllib.request, urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -4,7 +4,7 @@ Sprint 11 Tests: multi-provider model support, streaming smoothness, routes extr
import json, pathlib, urllib.error, urllib.request, urllib.parse import json, pathlib, urllib.error, urllib.request, urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -3,7 +3,7 @@ Sprint 12 Tests: settings panel, session pinning, session import, SSE reconnect.
""" """
import json, pathlib, urllib.error, urllib.request, urllib.parse import json, pathlib, urllib.error, urllib.request, urllib.parse
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 13 Tests: cron recent endpoint, session duplicate, background alerts.
""" """
import json, pathlib, urllib.error, urllib.request import json, pathlib, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 14 Tests: file rename, folder create, session archive, session tags, merm
""" """
import json, os, pathlib, shutil, tempfile, urllib.error, urllib.request import json, os, pathlib, shutil, tempfile, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 15 Tests: session projects (CRUD, move, backward compat).
""" """
import json, urllib.error, urllib.request import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -7,7 +7,7 @@ import pathlib
import re import re
import urllib.request import urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
REPO_ROOT = pathlib.Path(__file__).parent.parent REPO_ROOT = pathlib.Path(__file__).parent.parent

View File

@@ -3,7 +3,7 @@ Sprint 17 Tests: send_key setting, commands.js static file, workspace subdir lis
""" """
import json, urllib.error, urllib.request import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 19 Tests: auth/login, security headers, request size limit.
""" """
import json, urllib.error, urllib.request import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path, headers=None): def get(path, headers=None):

View File

@@ -1,7 +1,7 @@
"""Sprint 2 tests: image preview, file types, markdown. Uses cleanup_test_sessions fixture.""" """Sprint 2 tests: image preview, file types, markdown. Uses cleanup_test_sessions fixture."""
import io, json, uuid, urllib.request, urllib.error, pathlib import io, json, uuid, urllib.request, urllib.error, pathlib
BASE = "http://127.0.0.1:8788" # test server (isolated from production) from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -10,7 +10,7 @@ import urllib.request
import json import json
import pathlib import pathlib
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get_text(path): def get_text(path):

View File

@@ -5,7 +5,7 @@ icon-only circle design.
import re import re
import urllib.request import urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get_text(path): def get_text(path):

View File

@@ -4,7 +4,7 @@ subagent card names, skill picker in cron, skill linked files.
""" """
import json, urllib.error, urllib.request import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -4,7 +4,7 @@ custom theme names accepted.
""" """
import json, urllib.error, urllib.request import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -7,7 +7,7 @@ import json
import urllib.error import urllib.error
import urllib.request import urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -14,7 +14,7 @@ import urllib.request
sys.path.insert(0, str(pathlib.Path(__file__).parent)) sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_STATE_DIR from conftest import TEST_STATE_DIR
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -27,7 +27,7 @@ import urllib.request
sys.path.insert(0, str(pathlib.Path(__file__).parent)) sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_STATE_DIR from conftest import TEST_STATE_DIR
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path, headers=None): def get(path, headers=None):

View File

@@ -1,7 +1,7 @@
"""Sprint 3 tests: cron API, skills API, memory API, input validation.""" """Sprint 3 tests: cron API, skills API, memory API, input validation."""
import json, uuid, urllib.request, urllib.error import json, uuid, urllib.request, urllib.error
BASE = "http://127.0.0.1:8788" # test server (isolated from production) from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -19,7 +19,7 @@ import urllib.parse
import pytest import pytest
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):

View File

@@ -68,7 +68,7 @@ class TestWriteEndpointToConfig:
# ── 6-7: API integration tests ──────────────────────────────────────────────── # ── 6-7: API integration tests ────────────────────────────────────────────────
_TEST_BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE as _TEST_BASE
def _post(path, body=None): def _post(path, body=None):

View File

@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import subprocess import subprocess
import os
from api.startup import auto_install_agent_deps from api.startup import auto_install_agent_deps
class TestAutoInstallAgentDeps: class TestAutoInstallAgentDeps:

View File

@@ -22,7 +22,7 @@ import unittest.mock
import pytest import pytest
REPO = pathlib.Path(__file__).parent.parent REPO = pathlib.Path(__file__).parent.parent
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
"""Sprint 4 tests: relocation, session rename, search, file ops, validation.""" """Sprint 4 tests: relocation, session rename, search, file ops, validation."""
import json, pathlib, uuid, urllib.request, urllib.error import json, pathlib, uuid, urllib.request, urllib.error
BASE = "http://127.0.0.1:8788" # test server (isolated from production) from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -0,0 +1,327 @@
"""
Sprint 40 UI Polish Tests: Active session title uses CSS theme variable (issue #440).
Covers:
- .session-item.active .session-title uses var(--gold) instead of hardcoded #e8a030
- The hardcoded amber color #e8a030 is NOT present in the active session title rule
"""
import os
import pathlib
import re
import sys
import unittest
from unittest import mock
# Ensure repo is on sys.path so api.config can be imported
_REPO_ROOT = pathlib.Path(__file__).parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
REPO_ROOT = _REPO_ROOT
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text()
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text()
try:
from api import config as _api_config
_config_available = True
except Exception:
_api_config = None
_config_available = False
# Combined tests for Sprint 40 — Session + UI Polish
# Covers: active title color, unknown model, Telegram badge,
# custom endpoint model routing, workspace chip
# ── #451 active title ─────────────────────────────────────────────
class TestActiveSessionTitleThemeColor(unittest.TestCase):
def test_active_session_title_uses_theme_variable(self):
"""
.session-item.active .session-title must use var(--gold) not a hardcoded hex.
The light-theme override line (data-theme="light") is allowed to keep its own
hardcoded color; we only check the base/dark rule.
"""
# Find all lines that match the active session title selector
lines = STYLE_CSS.splitlines()
base_rule_lines = [
line for line in lines
if ".session-item.active .session-title" in line
and 'data-theme="light"' not in line
]
self.assertTrue(
len(base_rule_lines) >= 1,
"Could not find .session-item.active .session-title base rule in style.css"
)
for line in base_rule_lines:
self.assertIn(
"var(--gold)",
line,
f"Expected var(--gold) in active session title rule, got: {line.strip()}"
)
self.assertNotIn(
"#e8a030",
line,
f"Hardcoded #e8a030 must be removed from active session title rule: {line.strip()}"
)
if __name__ == "__main__":
unittest.main()
# ── #452 unknown model ─────────────────────────────────────────────
class TestGatewaySessionNullModel(unittest.TestCase):
"""Verify that api/models.py and api/gateway_watcher.py do not
fall back to the string 'unknown' for missing model values."""
def test_gateway_session_null_model_returns_none_not_unknown(self):
"""api/models.py must not use `or 'unknown'` for the model field
so that a NULL model in state.db is returned as None (falsy) to
the frontend rather than the truthy string 'unknown'."""
models_src = (REPO_ROOT / "api" / "models.py").read_text()
# Ensure the old fallback pattern is gone
self.assertNotIn(
"'model': row['model'] or 'unknown'",
models_src,
"api/models.py must not use `or 'unknown'` for the model field "
"(fixes #443: gateway sessions showed 'telegram · unknown')",
)
def test_gateway_watcher_null_model_returns_none_not_unknown(self):
"""api/gateway_watcher.py must not use `or 'unknown'` for the model
field so that a NULL model in state.db is returned as None (falsy)."""
gw_src = (REPO_ROOT / "api" / "gateway_watcher.py").read_text()
self.assertNotIn(
"'model': row['model'] or 'unknown'",
gw_src,
"api/gateway_watcher.py must not use `or 'unknown'` for the model "
"field (fixes #443: gateway sessions showed 'telegram · unknown')",
)
def test_gateway_session_model_uses_none_fallback(self):
"""Both source files must use `row['model'] or None` (explicit None
fallback) for the model field assignment."""
models_src = (REPO_ROOT / "api" / "models.py").read_text()
gw_src = (REPO_ROOT / "api" / "gateway_watcher.py").read_text()
self.assertIn(
"'model': row['model'] or None,",
models_src,
"api/models.py should assign `row['model'] or None` for the model field",
)
self.assertIn(
"'model': row['model'] or None,",
gw_src,
"api/gateway_watcher.py should assign `row['model'] or None` for the model field",
)
if __name__ == "__main__":
unittest.main()
# ── #453 telegram badge ─────────────────────────────────────────────
class TestTelegramBadgeMutedColor(unittest.TestCase):
def test_telegram_badge_uses_muted_color(self):
"""Telegram badge rules must use rgba(0, 136, 204, 0.55) not #0088cc."""
# Extract only the telegram-related CSS block
telegram_lines = [
line for line in STYLE_CSS.splitlines()
if 'data-source="telegram"' in line or "data-source='telegram'" in line
]
self.assertTrue(
len(telegram_lines) >= 2,
"Expected at least 2 telegram badge CSS rules"
)
muted_color = "rgba(0, 136, 204, 0.55)"
for line in telegram_lines:
self.assertIn(
muted_color, line,
f"Telegram CSS rule should use {muted_color!r}, got: {line!r}"
)
self.assertNotIn(
"#0088cc", line,
f"Telegram CSS rule must not use saturated #0088cc, got: {line!r}"
)
def test_telegram_border_left_color_muted(self):
"""The border-left-color rule for telegram uses rgba."""
pattern = r'\.session-item\.cli-session\[data-source=["\']telegram["\']\]\s*\{[^}]*border-left-color:\s*rgba\(0,\s*136,\s*204,\s*0\.55\)'
self.assertRegex(STYLE_CSS, pattern,
"border-left-color for telegram should be rgba(0, 136, 204, 0.55)")
def test_telegram_after_color_muted(self):
"""The ::after color rule for telegram uses rgba."""
pattern = r'\.session-item\.cli-session\[data-source=["\']telegram["\']\]::after\s*\{[^}]*color:\s*rgba\(0,\s*136,\s*204,\s*0\.55\)'
self.assertRegex(STYLE_CSS, pattern,
"::after color for telegram should be rgba(0, 136, 204, 0.55)")
class TestFormatSourceTagHelper(unittest.TestCase):
def test_format_source_tag_helper_exists(self):
"""_formatSourceTag function must be defined in sessions.js."""
self.assertIn("function _formatSourceTag(", SESSIONS_JS,
"_formatSourceTag helper function not found in sessions.js")
def test_format_source_tag_maps_telegram(self):
"""_formatSourceTag maps 'telegram' to 'via Telegram'."""
self.assertIn("telegram:'via Telegram'", SESSIONS_JS,
"sessions.js should map telegram -> 'via Telegram'")
def test_format_source_tag_maps_discord(self):
"""_formatSourceTag maps 'discord' to 'via Discord'."""
self.assertIn("discord:'via Discord'", SESSIONS_JS,
"sessions.js should map discord -> 'via Discord'")
def test_format_source_tag_maps_slack(self):
"""_formatSourceTag maps 'slack' to 'via Slack'."""
self.assertIn("slack:'via Slack'", SESSIONS_JS,
"sessions.js should map slack -> 'via Slack'")
def test_metabits_uses_format_helper(self):
"""The metaBits push for source_tag should use _formatSourceTag."""
self.assertIn("metaBits.push(_formatSourceTag(s.source_tag))", SESSIONS_JS,
"metaBits push should wrap source_tag with _formatSourceTag()")
def test_raw_source_tag_not_pushed_directly(self):
"""The old raw metaBits.push(s.source_tag) should not exist."""
self.assertNotIn("metaBits.push(s.source_tag)", SESSIONS_JS,
"Raw s.source_tag should not be pushed directly to metaBits")
if __name__ == "__main__":
unittest.main()
# ── #454 model routing ─────────────────────────────────────────────
@unittest.skipUnless(_config_available, "api.config not importable")
class TestCustomEndpointModelStripping:
"""Tests for fix #433: strip provider prefix when custom base_url is set."""
def _resolve(self, model_id, provider=None, base_url=None):
"""Helper: set cfg directly (same pattern as test_model_resolver.py)."""
old_cfg = dict(_api_config.cfg)
model_cfg = {}
if provider:
model_cfg['provider'] = provider
if base_url:
model_cfg['base_url'] = base_url
_api_config.cfg['model'] = model_cfg
try:
return _api_config.resolve_model_provider(model_id)
finally:
_api_config.cfg.clear()
_api_config.cfg.update(old_cfg)
def test_prefixed_model_stripped_for_custom_endpoint(self):
"""Issue #433: 'openai/gpt-5.4' with custom base_url returns bare 'gpt-5.4'."""
model, provider, base_url = self._resolve(
'openai/gpt-5.4',
provider='custom',
base_url='http://my-proxy.local:8080/v1',
)
assert model == 'gpt-5.4', (
"Expected bare 'gpt-5.4' for custom endpoint, got '{}'."
" Stale provider-prefix must be stripped.".format(model)
)
assert base_url == 'http://my-proxy.local:8080/v1'
assert provider == 'custom'
def test_bare_model_unchanged_for_custom_endpoint(self):
"""Bare model ID (no slash) must pass through untouched with custom base_url."""
model, provider, base_url = self._resolve(
'gpt-4o',
provider='custom',
base_url='http://my-proxy.local:8080/v1',
)
assert model == 'gpt-4o', (
"Bare model 'gpt-4o' should not be modified, got '{}'.".format(model)
)
assert base_url == 'http://my-proxy.local:8080/v1'
assert provider == 'custom'
def test_prefixed_model_kept_for_openrouter(self):
"""When NO custom base_url (openrouter route), prefixed model must stay prefixed."""
model, provider, base_url = self._resolve(
'openai/gpt-5.4',
provider='anthropic', # cross-provider pick triggers openrouter routing
)
# Cross-provider model with openrouter routing must keep full provider/model path
assert 'openai/gpt-5.4' in model or provider == 'openrouter', (
"Expected prefixed model or openrouter routing for non-custom endpoint, "
"got model='{}', provider='{}'.".format(model, provider)
)
assert base_url is None, (
"OpenRouter routing must not set a base_url, got '{}'.".format(base_url)
)
# ── #455 workspace chip ─────────────────────────────────────────────
class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase):
"""Verify that switchToProfile() applies the profile default workspace
to the new session when a conversation is in progress (fixes #424)."""
def test_workspace_chip_updated_after_profile_switch(self):
"""After await newSession(false) in the sessionInProgress branch,
the code must call updateWorkspaceChip() so the chip reflects the
new profile's default workspace instead of showing 'No active workspace'."""
# Find the sessionInProgress block
idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1, "sessionInProgress branch must exist in panels.js")
# Slice from that point to cover the relevant block
block = PANELS_JS[idx:idx + 1000]
# newSession(false) must be called first
self.assertIn('await newSession(false)', block,
"sessionInProgress branch must call await newSession(false)")
# The fix: updateWorkspaceChip() must be called after newSession(false)
pos_new_session = block.find('await newSession(false)')
pos_update_chip = block.find('updateWorkspaceChip()')
self.assertGreater(pos_update_chip, -1,
"updateWorkspaceChip() must be called in the sessionInProgress branch")
self.assertGreater(pos_update_chip, pos_new_session,
"updateWorkspaceChip() must be called AFTER newSession(false)")
def test_profile_default_workspace_applied_to_new_session(self):
"""After newSession(false) the code must assign S._profileDefaultWorkspace
to S.session.workspace so the session is correctly tagged."""
idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1)
block = PANELS_JS[idx:idx + 1000]
# The fix block must set S.session.workspace from S._profileDefaultWorkspace
self.assertIn('S.session.workspace = S._profileDefaultWorkspace', block,
"S.session.workspace must be set from S._profileDefaultWorkspace "
"in the sessionInProgress branch after newSession(false)")
def test_api_session_update_called_for_new_session_workspace(self):
"""The fix must call /api/session/update to persist the workspace on the server."""
idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1)
block = PANELS_JS[idx:idx + 1000]
# Must patch the session on the backend too
self.assertIn('/api/session/update', block,
"The sessionInProgress branch must call /api/session/update "
"to persist the new workspace after newSession(false)")
def test_update_workspace_chip_before_render_session_list(self):
"""updateWorkspaceChip() should be called before renderSessionList()
so the chip is correct when the UI re-renders."""
idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1)
block = PANELS_JS[idx:idx + 1000]
pos_chip = block.find('updateWorkspaceChip()')
pos_render = block.find('await renderSessionList()')
self.assertGreater(pos_chip, -1, "updateWorkspaceChip() must exist in block")
self.assertGreater(pos_render, -1, "renderSessionList() must exist in block")
self.assertLess(pos_chip, pos_render,
"updateWorkspaceChip() must be called before renderSessionList()")
if __name__ == '__main__':
unittest.main()

View File

@@ -14,7 +14,7 @@ import urllib.request
import os import os
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
REPO = pathlib.Path(__file__).parent.parent REPO = pathlib.Path(__file__).parent.parent
# Use HERMES_WEBUI_TEST_STATE_DIR if available (set by conftest for the test process), # Use HERMES_WEBUI_TEST_STATE_DIR if available (set by conftest for the test process),
# falling back to the conventional webui-mvp-test path. # falling back to the conventional webui-mvp-test path.

View File

@@ -1,7 +1,8 @@
"""Sprint 5 tests: workspace CRUD, file save, session index, JS serving.""" """Sprint 5 tests: workspace CRUD, file save, session index, JS serving."""
import json, pathlib, uuid, urllib.request, urllib.error import json, pathlib, uuid, urllib.request, urllib.error
import os
BASE = "http://127.0.0.1:8788" # test server (isolated from production) from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:
@@ -132,7 +133,7 @@ def test_file_save_path_traversal_blocked(cleanup_test_sessions):
def test_session_index_created_after_save(cleanup_test_sessions): def test_session_index_created_after_save(cleanup_test_sessions):
# Index is created in the TEST state dir, not the production dir # Index is created in the TEST state dir, not the production dir
test_state_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test" test_state_dir = pathlib.Path(os.environ.get("HERMES_WEBUI_TEST_STATE_DIR", str(pathlib.Path.home() / ".hermes" / "webui-mvp-test")))
index_path = test_state_dir / "sessions" / "_index.json" index_path = test_state_dir / "sessions" / "_index.json"
make_session_tracked(cleanup_test_sessions) make_session_tracked(cleanup_test_sessions)
# Index may not exist yet if cleanup already wiped it -- just check the endpoint works # Index may not exist yet if cleanup already wiped it -- just check the endpoint works

View File

@@ -2,7 +2,7 @@
import json, uuid, pathlib, urllib.request, urllib.error import json, uuid, pathlib, urllib.request, urllib.error
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" # isolated test server from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -3,7 +3,7 @@ Sprint 7 Tests: Cron CRUD, Skill CRUD, Memory Write, Session Content Search, Hea
""" """
import json, pathlib, urllib.error, urllib.parse, urllib.request import json, pathlib, urllib.error, urllib.parse, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -3,7 +3,7 @@ Sprint 8 Tests: Edit/regenerate, clear conversation, truncate, reconnect banner
""" """
import json, pathlib, urllib.error, urllib.parse, urllib.request import json, pathlib, urllib.error, urllib.parse, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get(path): def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -4,7 +4,7 @@ Run: python -m pytest tests/test_sprint9.py -v
""" """
import json, pathlib, urllib.error, urllib.request import json, pathlib, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788" from tests._pytest_port import BASE
def get_text(path): def get_text(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: with urllib.request.urlopen(BASE + path, timeout=10) as r: