Files
hermes-webui/tests/test_sprint6.py
T
Aron Prins 970bc1d3fd refactor(ui): three-column layout with left rail + main-view migration (#899)
refactor(ui): three-column layout with left rail + main-view migration (#899)

Unifies the shell into a three-column layout (rail + sidebar + main) matching the
hermes-desktop reference, and migrates every per-item detail/edit surface into a
shared main-view canvas with consistent headers, empty states, and action buttons.

Changes:
- New desktop-only left rail (48px) with 8 nav tabs (chat/tasks/skills/memory/workspaces/profiles/todos/settings)
- Persistent app titlebar (replaces per-chat topbar), active conversation title shown
- All panel detail/create/edit views migrated to #mainSkills, #mainTasks, #mainSettings, #mainWorkspaces, #mainProfiles, #mainMemory
- Settings moved out of modal into main-view page; ESC closes it
- YAML frontmatter rendered in collapsible <details> block in skill detail
- Toasts repositioned from bottom-center to top-right with theme-aware success/error/warning/info variants
- Composer workspace chip split into two-button group: files-icon toggles file panel, label opens workspace picker
- .settings-menu → .side-menu / .side-menu-item (generalised, shared by memory and settings panels)
- i18n: ~25 new keys across en/ru/es/de/zh/zh-Hant for all new form labels, placeholders, and empty states
- Mobile: hamburger in titlebar, slide-in sidebar; box-shadow removed from sidebar
- New regression test: tests/test_settings_navigation_and_detail_refresh.py (9 tests)

Co-authored-by: Aron Prins <pwf.aron@gmail.com>
2026-04-24 09:05:25 -07:00

153 lines
5.5 KiB
Python

"""Sprint 6 tests: Escape from editor, Phase D validation, HTML extraction, cron create, session export."""
import json, uuid, pathlib, urllib.request, urllib.error
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def get_raw(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return r.read(), r.headers, 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
def make_session_tracked(created_list, ws=None):
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, pathlib.Path(d["session"]["workspace"])
# ── Phase E: HTML served from static/index.html ──
def test_index_html_served():
raw, headers, status = get_raw("/")
assert status == 200
assert b"sidebarResize" in raw, "Resize handle not found in HTML"
assert b'id="mainTasks"' in raw, "Tasks main-view not found in HTML"
assert b'id="settingsMenu"' in raw, "Settings left-rail menu not found in HTML"
assert b"btnExportJSON" in raw, "Export JSON button not found in HTML"
def test_index_html_file_exists():
p = REPO_ROOT / "static/index.html"
assert p.exists(), "static/index.html does not exist"
assert p.stat().st_size > 5000, "index.html seems too small"
def test_server_py_has_no_html_string():
txt = (REPO_ROOT / "server.py").read_text()
assert 'HTML = r"""' not in txt, "server.py still contains inline HTML string"
assert "doctype html" not in txt.lower(), "server.py still contains raw HTML"
# ── Phase D: remaining endpoint validation ──
def test_approval_respond_requires_session_id():
result, status = post("/api/approval/respond", {"choice": "deny"})
assert status == 400
def test_approval_respond_rejects_invalid_choice(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/approval/respond", {"session_id": sid, "choice": "INVALID"})
assert status == 400
def test_file_raw_requires_session_id():
try:
get_raw("/api/file/raw?path=test.png")
assert False, "Expected 400"
except urllib.error.HTTPError as e:
assert e.code == 400
def test_file_raw_unknown_session():
try:
get_raw("/api/file/raw?session_id=nosuchsession&path=test.png")
assert False, "Expected 404"
except urllib.error.HTTPError as e:
assert e.code == 404
# ── Cron create ──
def test_cron_create_requires_prompt():
result, status = post("/api/crons/create", {"schedule": "0 9 * * *"})
assert status == 400
assert "prompt" in result.get("error", "").lower()
def test_cron_create_requires_schedule():
result, status = post("/api/crons/create", {"prompt": "Say hello"})
assert status == 400
assert "schedule" in result.get("error", "").lower()
def test_cron_create_invalid_schedule():
result, status = post("/api/crons/create", {
"prompt": "Say hello", "schedule": "not_a_valid_schedule_xyz"
})
assert status == 400
def test_cron_create_success():
uid = uuid.uuid4().hex[:6]
result, status = post("/api/crons/create", {
"name": f"test-job-{uid}",
"prompt": "Just say 'hello' and nothing else.",
"schedule": "every 999h", # far future -- won't actually run during test
"deliver": "local",
})
assert status == 200, f"Expected 200 got {status}: {result}"
assert result["ok"] is True
assert "job" in result
job_id = result["job"]["id"]
# Verify it appears in the cron list
jobs, _ = get("/api/crons")
ids = [j["id"] for j in jobs["jobs"]]
assert job_id in ids, f"Created job {job_id} not in list"
# ── Session export ──
def test_session_export_requires_session_id():
try:
get_raw("/api/session/export")
assert False
except urllib.error.HTTPError as e:
assert e.code == 400
def test_session_export_unknown_session():
try:
get_raw("/api/session/export?session_id=nosuchsession")
assert False
except urllib.error.HTTPError as e:
assert e.code == 404
def test_session_export_returns_json(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
raw, headers, status = get_raw(f"/api/session/export?session_id={sid}")
assert status == 200
assert "application/json" in headers.get("Content-Type", "")
data = json.loads(raw)
assert data["session_id"] == sid
assert "messages" in data
assert "title" in data
# ── Resizable panels: static files present ──
def test_static_index_has_resize_handles():
raw, _, status = get_raw("/")
assert status == 200
assert b"sidebarResize" in raw
assert b"rightpanelResize" in raw
def test_app_js_has_resize_logic():
"""Sprint 9: app.js replaced by modules. Resize logic lives in boot.js."""
raw, _, status = get_raw("/static/boot.js")
assert status == 200
assert b"_initResizePanels" in raw
assert b"hermes-sidebar-w" in raw
assert b"hermes-panel-w" in raw