Files
hermes-webui/tests/test_1003_appearance_autosave.py
nesquena-hermes 0ad95cb16a release: v0.50.241 (#1293)
release: v0.50.241

Batch release of 4 PRs:

- #1290 (@nickgiulioni1) — Inline audio/video media editor with playback
  speed controls and HTTP byte-range streaming. PDF/media previews in
  workspace file browser. Composer tray inline players for audio/video.
  (Rebased from #1232.)

- #1287 (@renatomott) — Configured model badges (Primary / Fallback N) in
  the model picker, carried through to the composer chip. Persists through
  on-disk model cache.

- #1289 (@franksong2702) — Appearance autosave for theme/skin/font-size in
  Settings; inline Saving / Saved / Failed status. Font size now persists
  to config.yaml. Refs #1003.

- #1294 (@franksong2702) — Normalize agent session source metadata
  (raw_source / session_source / source_label) through /api/sessions and
  gateway watcher SSE snapshots. Existing source_tag / is_cli_session
  fields preserved. Refs #1013.

Tests: 3254 passed, 2 skipped, 3 xpassed (was 3199 before this release).

Independently reviewed and approved by nesquena (commit d1738f6).
2026-04-29 19:54:07 -07:00

139 lines
5.2 KiB
Python

"""Regression checks for Issue #1003: Appearance settings autosave.
Focus:
- Theme/Skin/Font size should autosave immediately and only show inline status.
- Appearance changes should not participate in the global unsaved-changes guard.
- Full-save flow should still include font_size.
- /api/settings should accept appearance-only payloads and preserve untouched fields.
"""
import json
import re
import urllib.error
import urllib.request
from pathlib import Path
from tests._pytest_port import BASE
BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (Path(__file__).parent.parent / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8")
def _function_block(src: str, name: str) -> str:
marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src)
assert marker is not None, f"{name}() not found"
start = marker.start()
next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1 :])
if next_marker:
end = start + 1 + next_marker.start()
else:
end = len(src)
return src[start:end]
def _post(path, body):
data = json.dumps(body).encode("utf-8")
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:
payload = e.read()
return json.loads(payload or b"{}"), e.code
def _get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def test_appearance_pickers_schedule_autosave_and_do_not_mark_dirty():
for fn in ("_pickTheme", "_pickSkin", "_pickFontSize"):
block = _function_block(BOOT_JS, fn)
assert "_scheduleAppearanceAutosave()" in block, (
f"{fn}() should invoke _scheduleAppearanceAutosave()"
)
assert "_markSettingsDirty()" not in block, (
f"{fn}() should not call _markSettingsDirty()"
)
def test_appearance_revert_preview_no_longer_rolls_back_theme_skin_font():
block = _function_block(PANELS_JS, "_revertSettingsPreview")
assert "hermes-theme" not in block
assert "hermes-skin" not in block
assert "hermes-font-size" not in block
assert "_markSettingsDirty()" not in block
def test_appearance_autosave_payload_is_theme_skin_font_only():
block = _function_block(PANELS_JS, "_appearancePayloadFromUi")
assert "theme:" in block
assert "skin:" in block
assert "font_size:" in block
assert "language:" not in block
assert "workspace" not in block
assert "show_token_usage" not in block
status_block = _function_block(PANELS_JS, "_setAppearanceAutosaveStatus")
assert "settings_autosave_saving" in status_block
assert "settings_autosave_saved" in status_block
assert "settings_autosave_failed" in status_block
assert "settings_autosave_retry" in status_block
def test_appearance_autosave_status_line_and_i18n_keys_exist():
assert 'id="settingsAppearanceAutosaveStatus"' in INDEX_HTML
required_keys = [
"settings_autosave_saving",
"settings_autosave_saved",
"settings_autosave_failed",
"settings_autosave_retry",
]
for key in required_keys:
assert I18N_JS.count(f"{key}:") >= 8, (
f"{key} must be defined in all LOCALES blocks (found {I18N_JS.count(f'{key}:')})"
)
def test_full_save_settings_still_includes_font_size():
block = _function_block(PANELS_JS, "saveSettings")
compact = block.replace(" ", "")
assert "body.theme=theme;" in compact
assert "body.skin=skin;" in compact
assert "body.font_size=fontSize;" in compact
def test_settings_api_accepts_appearance_only_payload_without_overwriting_other_fields():
original, status = _get("/api/settings")
assert status == 200
snapshot = {
"theme": original.get("theme"),
"skin": original.get("skin"),
"font_size": original.get("font_size", "default"),
"show_token_usage": original.get("show_token_usage"),
"show_cli_sessions": original.get("show_cli_sessions"),
"check_for_updates": original.get("check_for_updates"),
}
try:
d, status = _post("/api/settings", {"theme": "system", "skin": "charizard", "font_size": "large"})
assert status == 200
assert d.get("theme") == "system"
assert d.get("skin") == "charizard"
assert d.get("font_size") == "large"
reloaded, _ = _get("/api/settings")
assert reloaded.get("show_token_usage") == snapshot["show_token_usage"]
assert reloaded.get("show_cli_sessions") == snapshot["show_cli_sessions"]
assert reloaded.get("check_for_updates") == snapshot["check_for_updates"]
finally:
_post("/api/settings", {
"theme": snapshot["theme"],
"skin": snapshot["skin"],
"font_size": snapshot["font_size"],
})