mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
144 lines
6.4 KiB
Python
144 lines
6.4 KiB
Python
"""
|
|
Tests for SOUL.md support in the memory API (GET /api/memory, POST /api/memory/write).
|
|
|
|
SOUL.md lives at HERMES_HOME/SOUL.md (not in the memories/ subdirectory).
|
|
This test file verifies:
|
|
- GET /api/memory returns soul content, path, and mtime
|
|
- POST /api/memory/write with section="soul" writes to HERMES_HOME/SOUL.md
|
|
- Redaction still applies to soul content
|
|
- Existing memory/user sections remain unaffected
|
|
"""
|
|
import json, pathlib, urllib.error, urllib.parse, urllib.request
|
|
|
|
from tests._pytest_port import BASE
|
|
|
|
|
|
def get(path):
|
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
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
|
|
|
|
|
|
# ── GET /api/memory includes soul ──────────────────────────────────
|
|
|
|
def test_memory_read_includes_soul_fields():
|
|
"""GET /api/memory must include soul, soul_path, and soul_mtime."""
|
|
data = get("/api/memory")
|
|
assert "soul" in data, "Response missing 'soul' key"
|
|
assert "soul_path" in data, "Response missing 'soul_path' key"
|
|
assert "soul_mtime" in data, "Response missing 'soul_mtime' key"
|
|
# soul_path should end with SOUL.md, not be inside memories/
|
|
assert data["soul_path"].endswith("SOUL.md"), f"soul_path should end with SOUL.md, got {data['soul_path']}"
|
|
assert "/memories/" not in data["soul_path"], f"soul_path should NOT be inside memories/, got {data['soul_path']}"
|
|
|
|
|
|
def test_memory_read_soul_default_empty():
|
|
"""When no SOUL.md exists, the soul field should be empty string."""
|
|
data = get("/api/memory")
|
|
# soul may be empty if no SOUL.md file exists — that's fine
|
|
assert isinstance(data.get("soul"), str), "soul should be a string"
|
|
|
|
|
|
# ── POST /api/memory/write supports section="soul" ─────────────────
|
|
|
|
def test_memory_write_soul_roundtrip():
|
|
"""Writing to section='soul' should persist and be readable via GET."""
|
|
original = get("/api/memory").get("soul", "")
|
|
test_content = "# Test Soul\nWritten by test_memory_write_soul_roundtrip."
|
|
data, status = post("/api/memory/write", {"section": "soul", "content": test_content})
|
|
assert status == 200, f"Expected 200, got {status}: {data}"
|
|
assert data.get("ok") is True
|
|
assert data.get("section") == "soul"
|
|
# Path should be at HERMES_HOME/SOUL.md
|
|
assert data.get("path", "").endswith("SOUL.md"), f"path should end with SOUL.md, got {data.get('path')}"
|
|
# Read back
|
|
read_back = get("/api/memory").get("soul")
|
|
assert read_back == test_content
|
|
# Restore
|
|
post("/api/memory/write", {"section": "soul", "content": original})
|
|
|
|
|
|
def test_memory_write_soul_does_not_affect_memory_or_user():
|
|
"""Writing soul should not change memory or user sections."""
|
|
state_before = get("/api/memory")
|
|
original_soul = state_before.get("soul", "")
|
|
original_memory = state_before.get("memory", "")
|
|
original_user = state_before.get("user", "")
|
|
|
|
post("/api/memory/write", {"section": "soul", "content": "# Temp Soul"})
|
|
state_after = get("/api/memory")
|
|
|
|
assert state_after.get("memory") == original_memory, "memory section changed unexpectedly"
|
|
assert state_after.get("user") == original_user, "user section changed unexpectedly"
|
|
|
|
# Restore
|
|
post("/api/memory/write", {"section": "soul", "content": original_soul})
|
|
|
|
|
|
def test_memory_write_soul_path_not_in_memories_dir():
|
|
"""The SOUL.md file should be at HERMES_HOME/SOUL.md, not memories/SOUL.md."""
|
|
data, status = post("/api/memory/write", {"section": "soul", "content": "# Path check"})
|
|
assert status == 200
|
|
assert "/memories/" not in data.get("path", ""), f"SOUL.md should NOT be in memories/ dir, got {data.get('path')}"
|
|
# Cleanup
|
|
post("/api/memory/write", {"section": "soul", "content": ""})
|
|
|
|
|
|
def test_memory_write_invalid_section_still_rejected():
|
|
"""Invalid sections should still be rejected even with soul added."""
|
|
data, status = post("/api/memory/write", {"section": "invalid", "content": "test"})
|
|
assert status == 400
|
|
|
|
|
|
# ── i18n parity ────────────────────────────────────────────────────
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
|
|
|
|
|
def test_agent_soul_i18n_key_present_in_all_locales():
|
|
"""`agent_soul` and `no_soul_yet` keys must be defined for every locale block.
|
|
|
|
Quoting these correctly across all 11 locales matters: an unescaped
|
|
apostrophe inside a single-quoted string ('l'agent') breaks JS parsing of
|
|
the entire module, which silently disables i18n for every language. The
|
|
PR's initial commit failed CI for exactly this reason (it / fr).
|
|
"""
|
|
i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
|
# i18n.js currently exposes 11 locales (en, it, ja, ru, es, de, zh-CN,
|
|
# zh-TW, pt, ko, fr). Lock that both new keys are present at least 10
|
|
# times — that's enough to catch a missing locale without coupling the
|
|
# test to the exact locale count, which shifts as new ones land.
|
|
assert i18n.count("agent_soul:") >= 10, (
|
|
"agent_soul i18n key should be defined in all locale blocks; "
|
|
f"found {i18n.count('agent_soul:')} occurrences."
|
|
)
|
|
assert i18n.count("no_soul_yet:") >= 10, (
|
|
"no_soul_yet i18n key should be defined in all locale blocks; "
|
|
f"found {i18n.count('no_soul_yet:')} occurrences."
|
|
)
|
|
|
|
|
|
def test_sparkles_icon_is_defined():
|
|
"""The Memory panel's soul section uses iconKey='sparkles'.
|
|
|
|
If the icon is missing from `static/icons.js`, `li('sparkles', 16)` emits
|
|
a console warning and returns an empty string — the section heading then
|
|
renders without an icon, which looks broken next to the memory/user
|
|
sections that do show one.
|
|
"""
|
|
icons = (REPO_ROOT / "static" / "icons.js").read_text(encoding="utf-8")
|
|
assert "'sparkles':" in icons, (
|
|
"The Memory panel's soul section references iconKey='sparkles' "
|
|
"(static/panels.js MEMORY_SECTIONS); the corresponding entry must "
|
|
"exist in static/icons.js LI_PATHS."
|
|
)
|