Files
hermes-agent/tests/hermes_cli/test_dashboard_lifecycle_flags.py
Hermes Agent 15e26c76f3 fix(dashboard): list --port 0 serve backends in --status
--status probed the port parsed from argv, so a backend launched with
--port 0 (Desktop SSH backends) was always skipped while --stop still
killed it. Use the bind the spawn ledger records after listening.
2026-09-26 17:04:59 -05:00

194 lines
9.2 KiB
Python

"""Tests for ``hermes dashboard --stop`` / ``--status`` flags.
These flags share the detection + kill path with the post-``hermes update``
cleanup, so the heavy coverage of SIGTERM / SIGKILL / Windows taskkill lives
in ``test_update_stale_dashboard.py``. This file just verifies the flag
dispatch: argparse wiring, no-op when nothing is running, and correct
exit codes.
"""
from __future__ import annotations
import argparse
import sys
from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.main import cmd_dashboard
def _ns(**kw):
"""Build an argparse.Namespace with dashboard defaults plus overrides."""
defaults = dict(
port=9119, host="127.0.0.1", no_open=False, insecure=False,
stop=False, status=False,
)
defaults.update(kw)
return argparse.Namespace(**defaults)
class TestDashboardStatus:
def test_status_no_processes(self, capsys):
with patch("hermes_cli.dashboard_procs._scan_dashboard_processes", return_value=[]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "No hermes dashboard or serve processes running" in out
def test_status_with_processes(self, capsys):
# Includes a serve-mode backend: --status must LIST it, not hide it —
# `--stop` kills serves, so hiding them let operators kill what they
# couldn't see (#81564).
processes = [
(12345, "hermes dashboard --port 9119"),
(12346, "python -m hermes_cli.main dashboard --host 0.0.0.0 --port 9120"),
(12347, "hermes serve --host 100.94.65.93 --port 9119"),
]
with patch("hermes_cli.dashboard_procs._scan_dashboard_processes", return_value=processes), \
patch("gateway.status._pid_exists", return_value=True), \
patch("hermes_cli.main_dashboard._dashboard_listening", return_value=True), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
# Status is informational — always exits 0.
assert exc.value.code == 0
out = capsys.readouterr().out
assert "PID 12345" in out
assert "PID 12346" in out
assert "PID 12347" in out and "[serve]" in out
def test_status_lists_os_assigned_port_serve_that_stop_targets(self, capsys, monkeypatch):
"""A ``--port 0`` serve (Desktop SSH backend) is listed on the port the ledger recorded,
so ``--status`` shows every backend ``--stop`` would kill (#81564)."""
import socket
from hermes_cli import dashboard_procs, process_identity
from hermes_cli.main_dashboard import _find_stale_dashboard_pids
pid = 424242
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
listener.listen()
real_port = listener.getsockname()[1]
monkeypatch.setattr(dashboard_procs, "_iter_process_table", lambda: [
(pid, "/usr/local/bin/hermes serve --host 127.0.0.1 --port 0 --ssh-isolated")])
monkeypatch.setattr(process_identity, "ledger_entries", lambda: [
{"pid": pid, "purpose": "serve", "host": "127.0.0.1", "port": real_port}])
monkeypatch.setattr("gateway.status._pid_exists", lambda p: p == pid)
assert _find_stale_dashboard_pids() == [pid]
with pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
assert f"PID {pid} [serve]" in capsys.readouterr().out
def test_status_does_not_try_to_import_fastapi(self):
"""`--status` must not require dashboard runtime deps — it's a
process-table scan only. We prove this by making fastapi import
fail and confirming --status still succeeds."""
orig_import = __import__
def fake_import(name, *a, **kw):
if name == "fastapi":
raise ImportError("fastapi missing")
return orig_import(name, *a, **kw)
with patch("hermes_cli.dashboard_procs._scan_dashboard_processes", return_value=[]), \
patch("builtins.__import__", side_effect=fake_import), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
class TestDashboardStop:
def test_stop_kills_and_exits_zero_when_all_killed(self, capsys):
"""Every matched pid was killed -> exit 0, even when a scan afterwards would find a
process again: a launchd KeepAlive job respawns its backend on a fresh PID, and that
respawn is not a failed stop (the kill path already warns about it)."""
scans = iter([[12345, 12346], [12347]])
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda **_: next(scans)), \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes",
return_value={"matched": [12345, 12346], "killed": [12345, 12346],
"failed": [], "unrecovered": [12345, 12346]}) as mock_kill, \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
mock_kill.assert_called_once()
# --stop should pass a reason so the output doesn't say "running
# backend no longer matches the updated frontend" (that wording is
# for the post-`hermes update` path).
kwargs = mock_kill.call_args.kwargs
assert "reason" in kwargs
assert "stop" in kwargs["reason"].lower()
assert exc.value.code == 0
def test_stop_scopes_scan_and_kill_to_the_invoking_hermes_home(self, tmp_path, monkeypatch, capsys):
"""``--stop`` targets only this profile's backends (#113978): both the pre-check and the
kill run with ``scope_home`` = the invoking home, and an empty scan says so."""
own_home = tmp_path / "profiles" / "work"
monkeypatch.setenv("HERMES_HOME", str(own_home))
with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]) as scan, \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes",
return_value={"matched": [12345], "killed": [12345], "failed": [], "unrecovered": []}) as kill, \
pytest.raises(SystemExit):
cmd_dashboard(_ns(stop=True))
assert scan.call_args.kwargs["scope_home"] == str(own_home)
assert kill.call_args.kwargs["scope_home"] == str(own_home)
with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[]), \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes") as kill, \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
kill.assert_not_called()
assert exc.value.code == 0
assert "for this profile" in capsys.readouterr().out
def test_stop_exits_nonzero_if_kill_leaves_survivors(self):
"""A pid the kill path could not stop (e.g. permission denied) -> exit 1 so
scripts can detect that the stop didn't succeed."""
with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]), \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes",
return_value={"matched": [12345], "killed": [],
"failed": [(12345, "Operation not permitted")], "unrecovered": []}), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 1
def test_stop_does_not_try_to_import_fastapi(self):
"""Like --status, --stop must work without dashboard runtime deps."""
orig_import = __import__
def fake_import(name, *a, **kw):
if name == "fastapi":
raise ImportError("fastapi missing")
return orig_import(name, *a, **kw)
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch("builtins.__import__", side_effect=fake_import), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 0
class TestLifecycleFlagsTakePrecedence:
"""If both --stop and --status are set, --status wins (it's listed
first in cmd_dashboard). Neither is allowed to fall through to the
server-start path, which is the critical safety property — a user
who typed ``hermes dashboard --stop`` must not end up ALSO starting
a new server."""
def test_stop_does_not_fall_through_to_server_start(self):
"""Covers the worst-case regression: if --stop ever stopped exiting
early, the user would start the dashboard they just asked to stop."""
called = {"start": False}
def fake_start_server(**kw):
called["start"] = True
# Provide a fake web_server module so the import doesn't matter.
fake_ws = MagicMock()
fake_ws.start_server = fake_start_server
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch.dict(sys.modules, {"hermes_cli.web_server": fake_ws}), \
pytest.raises(SystemExit):
cmd_dashboard(_ns(stop=True))
assert called["start"] is False