Files
hermes-agent/tests/hermes_cli/test_cli_command_messages.py
teknium1 23036e20a6 fix(ux): plain-language, actionable user-facing messages (core)
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
2026-09-15 04:12:13 -07:00

155 lines
8.0 KiB
Python

"""User-facing message contracts for `hermes skills/plugins/mcp/cron/doctor` failure paths.
Every failure line must say what happened and name an existing command to type next; none may
lead with a raw exception, a status literal, or a hardcoded ``~/.hermes`` path (UX message audit,
findings cli-08/20/21/23/26/34, tools-runtime-13/14/20/27).
"""
from __future__ import annotations
import io
import re
from types import SimpleNamespace
from rich.console import Console
def _console():
buf = io.StringIO()
return Console(file=buf, force_terminal=False, width=200, highlight=False, markup=True), buf
# ── skills hub ────────────────────────────────────────────────────────────────────────────────
def test_skill_fetch_failure_names_search_and_doctor():
from hermes_cli.skills_hub import _print_fetch_failure
c, buf = _console()
_print_fetch_failure(c, sources=[], identifier="someone/typo-skill")
out = buf.getvalue()
assert "hermes skills search typo-skill" in out
assert "hermes doctor" in out
assert "from any source" not in out
def test_scan_block_message_explains_force_and_points_at_inspect():
from hermes_cli.skills_hub import _scan_block_message
result = SimpleNamespace(verdict="dangerous", trust_level="community", findings=[1, 2, 3])
msg = _scan_block_message(result, "acme/evil-skill")
assert "3 high-risk pattern(s)" in msg
assert "even with --force" in msg
assert "hermes skills inspect acme/evil-skill" in msg
assert "verdict" not in msg # scanner jargon stays in the audit log
soft = _scan_block_message(SimpleNamespace(verdict="suspicious", trust_level="community", findings=[1]),
"acme/meh")
assert "Re-run with --force" in soft
# ── plugins ───────────────────────────────────────────────────────────────────────────────────
def test_plugin_clone_failure_leads_with_next_steps_and_escapes_git_output():
from hermes_cli.plugins_cmd import _clone_failure_message
msg = _clone_failure_message("https://github.com/acme/nope", "fatal: repository '[x]' not found")
assert msg.startswith("Could not download the plugin from https://github.com/acme/nope")
assert "hermes plugins search" in msg and "gh auth login" in msg
assert "Details:" in msg
assert "\\[x]" in msg # Rich markup escaped so the raw git text renders verbatim
def test_unknown_plugin_message_points_at_list_and_full_key_rule():
from hermes_cli.plugins_cmd import _unknown_plugin_message
msg = _unknown_plugin_message("firecrawl")
assert "No plugin named 'firecrawl'" in msg
assert "hermes plugins list" in msg and "web/firecrawl" in msg and "hermes plugins install" in msg
assert "(none)" not in msg
# ── MCP ───────────────────────────────────────────────────────────────────────────────────────
def test_banner_failed_mcp_line_shows_reason_and_command():
from hermes_cli.banner import _mcp_server_line
line = _mcp_server_line({"name": "gh", "transport": "stdio", "connected": False, "status": "failed",
"error": "missing executable 'npx' (ensure Node.js is installed)"}, dim="grey", text="white")
assert "could not connect" in line and "missing executable 'npx'" in line
assert "hermes mcp test gh" in line
auth = _mcp_server_line({"name": "linear", "transport": "http", "connected": False, "status": "failed",
"error": "Client error '401 Unauthorized' for url"}, dim="grey", text="white")
assert "hermes mcp login linear" in auth
def test_mcp_probe_failure_unwraps_group_and_points_at_login_for_401():
from hermes_cli.mcp_config import _probe_failure_next_step, _probe_failure_reason
class _Resp:
status_code = 401
class _HttpErr(Exception):
response = _Resp()
group = ExceptionGroup("unhandled errors in a TaskGroup", [_HttpErr("Client error '401 Unauthorized'")])
assert "ExceptionGroup" not in _probe_failure_reason(group)
assert "401" in _probe_failure_reason(group)
assert "hermes mcp login linear" in _probe_failure_next_step("linear", group)
missing = FileNotFoundError(2, "No such file or directory", "npx")
assert "config.yaml" in _probe_failure_next_step("gh", missing)
assert "~/.hermes" not in _probe_failure_next_step("gh", missing) or "profiles" in _probe_failure_next_step("gh", missing)
# ── cron ──────────────────────────────────────────────────────────────────────────────────────
def test_cron_delivery_failure_lines_have_no_status_literals_and_name_the_fix():
from hermes_cli.cron import _cron_doctor_issues_for_job, _job_warnings, _last_run_display
job = {"id": "job42", "last_status": "delivery_failed", "last_error": None,
"last_delivery_error": "telegram: 502 Bad Gateway\nsecond raw line", "enabled": False,
"last_fire_error": {"at": "2026-09-01T07:00:00+00:00",
"detail": "scheduled fire could not be forwarded to the gateway api_server"}}
texts = [_last_run_display(job), *_job_warnings(job), *_cron_doctor_issues_for_job(job)]
joined = "\n".join(texts)
assert "delivery_failed" not in joined and "delivery_queued" not in joined
assert "second raw line" not in joined # only the first line of the raw blob is shown
assert "hermes cron edit job42 --deliver" in joined
assert "hermes gateway restart" in joined and "hermes cron run job42" in joined
assert "gateway was unreachable" in joined
def test_cron_queued_status_reads_as_in_progress():
from hermes_cli.cron import _last_run_display
assert "in progress" in _last_run_display({"last_status": "delivery_queued"})
# ── doctor / advisories / paths ───────────────────────────────────────────────────────────────
def test_advisory_remediation_uses_active_hermes_home(monkeypatch, tmp_path):
from hermes_cli import security_advisories as sa
monkeypatch.setattr(sa, "display_hermes_home", lambda: "~/.hermes/profiles/work")
hit = SimpleNamespace(advisory=sa.ADVISORIES[0], package="mistralai", installed_version="2.4.6")
text = "\n".join(sa.full_remediation_text(hit))
assert "~/.hermes/profiles/work/.env" in text
assert "{hermes_home}" not in text
assert not re.search(r"~/\.hermes/\.env", text)
def test_doctor_ack_failure_names_config_path_and_retry(monkeypatch, capsys):
import hermes_cli.doctor as doctor
monkeypatch.setattr("hermes_cli.security_advisories.ack_advisory", lambda _id: False)
monkeypatch.setattr(doctor, "_DHH", "~/.hermes/profiles/work")
from hermes_cli.security_advisories import ADVISORIES
try:
doctor._ack_advisory(ADVISORIES[0].id)
except SystemExit as exc:
assert exc.code == 1
out = capsys.readouterr().out
assert "~/.hermes/profiles/work/config.yaml" in out
assert "hermes config path" in out and f"hermes doctor --ack {ADVISORIES[0].id}" in out
assert "Failed to persist" not in out
def test_backup_recovery_hint_cites_the_slash_command_not_a_fake_subcommand():
from hermes_cli.backup import _snapshot_recovery_hint
hint = _snapshot_recovery_hint()
assert "/snapshot list" in hint and "/snapshot restore" in hint
assert "hermes snapshot" not in hint # `hermes snapshot` is not a CLI subcommand
assert "CLI only" in hint # /snapshot is cli_only; Desktop/gateway readers must not go looking for it