hermes-agent/tests/hermes_cli/test_dashboard_lifecycle_flags.py
konsisumer 486c5ffc8d fix(cli): live-probe dashboard status and share update cleanup across paths
- hermes dashboard --status now verifies each matched PID is alive AND
  bound to a listening socket before reporting it, so stale PIDs and the
  desktop app's IPC-only 'serve --port 0' backends no longer masquerade
  as running dashboards (#58578).
- The git and Windows ZIP update paths share one
  _finish_dashboard_update_cleanup(), so the ZIP fallback gets the same
  stop/restart reporting.
- _kill_stale_dashboard_processes returns a structured
  {matched, killed, failed, unrecovered} result; the explicit was-stopped
  notice fires only for processes that could NOT be auto-restarted,
  meshing with the auto-respawn from #72192.
2026-07-26 14:11:05 -07:00

218 lines
9.3 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.main._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 processes running" in out
def test_status_with_processes(self, capsys):
processes = [
(12345, "hermes dashboard --port 9119"),
(12346, "python -m hermes_cli.main dashboard --host 0.0.0.0 --port 9120"),
]
with patch("hermes_cli.main._scan_dashboard_processes", return_value=processes), \
patch("gateway.status._pid_exists", return_value=True), \
patch("hermes_cli.main._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 "2 hermes dashboard process(es) running" in out
assert "PID 12345" in out
assert "PID 12346" in out
def test_status_ignores_headless_serve_children_and_non_listeners(self, capsys):
processes = [
(11111, "hermes serve --port 0"),
(22222, "hermes dashboard --port 9119"),
(33333, "hermes dashboard --port 9120"),
]
def fake_listening(host, port):
return port == 9119
with patch("hermes_cli.main._scan_dashboard_processes", return_value=processes), \
patch("gateway.status._pid_exists", return_value=True), \
patch("hermes_cli.main._dashboard_listening", side_effect=fake_listening), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "1 hermes dashboard process(es) running" in out
assert "PID 22222" in out
assert "PID 11111" not in out
assert "PID 33333" not in out
def test_status_ignores_dead_pids(self, capsys):
with patch(
"hermes_cli.main._scan_dashboard_processes",
return_value=[(12345, "hermes dashboard --port 9119")],
), \
patch("gateway.status._pid_exists", return_value=False), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "No hermes dashboard processes running" in 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.main._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_when_nothing_running(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "No hermes dashboard processes running" in out
def test_stop_kills_and_exits_zero_when_all_killed(self, capsys):
"""After the kill, if the second scan returns empty we exit 0."""
# First scan: finds two processes. Second (verification) scan: empty.
scans = iter([[12345, 12346], []])
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.main._kill_stale_dashboard_processes") 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_exits_nonzero_if_kill_leaves_survivors(self):
"""If the second scan still finds PIDs, we exit 1 so scripts can
detect that the stop didn't succeed (e.g. permission denied)."""
scans = iter([[12345], [12345]]) # both scans find the same PID
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.main._kill_stale_dashboard_processes"), \
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_status_wins_over_stop(self, capsys):
with patch("hermes_cli.main._scan_dashboard_processes", return_value=[]), \
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \
pytest.raises(SystemExit):
cmd_dashboard(_ns(status=True, stop=True))
# Kill path must NOT run when --status is also set.
mock_kill.assert_not_called()
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
class TestArgparseWiring:
"""Confirm the flags are exposed via the real argparse tree so
``hermes dashboard --stop`` / ``--status`` actually parse."""
def test_flags_are_registered(self):
from hermes_cli.main import main as _cli_main # noqa: F401
# Rebuild the argparse tree by re-running the section of main()
# that builds it. Cheapest way: introspect via --help on the
# already-built parser would require refactoring; instead we
# parse the flags directly via a minimal replay.
import importlib
mod = importlib.import_module("hermes_cli.main")
# Find the dashboard_parser instance by running build logic would
# be too invasive. Instead parse args as if via the CLI by
# intercepting parse_args. This is overkill for a smoke test —
# we just want to know the flags don't KeyError.
with patch("hermes_cli.main._scan_dashboard_processes", return_value=[]), \
pytest.raises(SystemExit) as exc:
mod.cmd_dashboard(_ns(status=True))
assert exc.value.code == 0