Files
hermes-agent/tests/hermes_cli/test_uninstall_install_kind.py
ethernet 87d0fadded fix(pm): resolve the install stamp in pm.paths so sealed stages can read it
b207701287 routed PM's stamp reads through hermes_cli.steward.install_stamp_path.
The Docker runtime base runs PM before hermes_cli ships (only __init__ and
runtime_state are copied), so ensure() died with No module named
'hermes_cli.steward'. The resolver is an install-root path built only on
pm.paths.install_root/repo_root; move it there and point every reader at it.
2026-09-24 11:12:27 -04:00

234 lines
9.0 KiB
Python

"""Tests for the install-kind uninstall gate + the data-only mode.
The rule under test: only a git checkout may have its code removed by the
uninstaller. Sealed trees (Nix, the bundled desktop app, Docker) refuse the
code-removing modes with the steward's own instructions, and the one mode
valid everywhere — ``data`` — removes user data without touching code.
Adapted from the restack branch: detection here reads the install stamp
(``install-stamp.json`` with its required ``updateMechanism``) through
``hermes_cli.steward`` instead of restack's ``installation.tree``.
"""
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
import hermes_cli.uninstall as un
from hermes_cli.steward import (
STEWARD_DESKTOP,
STEWARD_DOCKER,
STEWARD_NIX,
steward_uninstall_message,
)
# ---------------------------------------------------------------------------
# steward_uninstall_message
# ---------------------------------------------------------------------------
def test_steward_uninstall_messages_name_the_steward():
assert "Nix" in steward_uninstall_message(STEWARD_NIX)
assert "Docker" in steward_uninstall_message(STEWARD_DOCKER)
# Unknown stewards fall back to a generic refusal that still names them.
assert "somepkg" in steward_uninstall_message("somepkg")
def test_steward_desktop_message_is_per_platform():
win = steward_uninstall_message(STEWARD_DESKTOP, platform="win32")
mac = steward_uninstall_message(STEWARD_DESKTOP, platform="darwin")
linux = steward_uninstall_message(STEWARD_DESKTOP, platform="linux")
assert "Installed apps" in win
assert "Trash" in mac
assert "AppImage" in linux
def test_all_refusals_point_at_the_data_mode():
# Every refusal must leave the user a working next step for their data.
# CLI stewards point at the CLI flag; desktop refusals deliberately
# point at the app's own data path.
for steward in (STEWARD_NIX, STEWARD_DOCKER, "somepkg"):
assert "hermes uninstall --data" in steward_uninstall_message(steward)
for platform in ("win32", "darwin", "linux"):
message = steward_uninstall_message(STEWARD_DESKTOP, platform=platform)
assert "Settings -> About" in message
# ---------------------------------------------------------------------------
# code_removal_refusal — the gate itself
# ---------------------------------------------------------------------------
def _fake_project_root(monkeypatch, tmp_path: Path, *, git: bool, distribution: "str | None" = None) -> Path:
root = tmp_path / "hermes-agent"
root.mkdir()
if git:
(root / ".git").mkdir()
if distribution is not None:
# The code-scoped stamp (see pm.paths.install_stamp_path).
(root / "install-stamp.json").write_text(
json.dumps({"distribution": distribution, "updateMechanism": "external"})
, encoding="utf-8")
monkeypatch.setattr(un, "get_project_root", lambda: root)
return root
def test_git_checkout_allows_code_removal(monkeypatch, tmp_path):
_fake_project_root(monkeypatch, tmp_path, git=True)
assert un.code_removal_refusal() is None
def test_sealed_nix_tree_refuses_code_removal(monkeypatch, tmp_path):
_fake_project_root(monkeypatch, tmp_path, git=False, distribution="nix")
refusal = un.code_removal_refusal()
assert refusal is not None
assert "Nix" in refusal
def test_sealed_desktop_tree_refuses_code_removal(monkeypatch, tmp_path):
_fake_project_root(monkeypatch, tmp_path, git=False, distribution="desktop-app")
refusal = un.code_removal_refusal()
assert refusal is not None
assert "managed by the desktop app" in refusal
def test_stampless_sealed_tree_still_refuses(monkeypatch, tmp_path):
# No .git and no stamp: we cannot prove the tree is ours, so refuse.
_fake_project_root(monkeypatch, tmp_path, git=False)
assert un.code_removal_refusal() is not None
def test_run_uninstall_exits_on_sealed_tree(monkeypatch, tmp_path, capsys):
_fake_project_root(monkeypatch, tmp_path, git=False, distribution="nix")
args = SimpleNamespace(yes=True, full=True, dry_run=False)
with pytest.raises(SystemExit) as exc:
un.run_uninstall(args)
assert exc.value.code == 1
assert "Nix" in capsys.readouterr().out
def test_run_gui_uninstall_exits_on_sealed_tree(monkeypatch, tmp_path, capsys):
_fake_project_root(monkeypatch, tmp_path, git=False, distribution="desktop-app")
args = SimpleNamespace(yes=True)
with pytest.raises(SystemExit) as exc:
un.run_gui_uninstall(args)
assert exc.value.code == 1
assert "managed by the desktop app" in capsys.readouterr().out
# ---------------------------------------------------------------------------
# run_data_uninstall — user data only, on any install kind
# ---------------------------------------------------------------------------
def _make_home(tmp_path: Path) -> Path:
home = tmp_path / ".hermes"
(home / "sessions").mkdir(parents=True)
(home / "sessions" / "s1.json").write_text("{}", encoding="utf-8")
(home / "logs").mkdir()
(home / "config.yaml").write_text("x: 1\n", encoding="utf-8")
(home / ".env").write_text("KEY=secret\n", encoding="utf-8")
agent = home / "hermes-agent"
(agent / "hermes_cli").mkdir(parents=True)
(agent / "hermes_cli" / "__init__.py").write_text("", encoding="utf-8")
return home
def test_run_data_uninstall_removes_electron_userdata(monkeypatch, tmp_path):
home = _make_home(tmp_path)
userdata = tmp_path / "electron-userdata"
userdata.mkdir()
(userdata / "connection.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(un, "get_hermes_home", lambda: home)
import hermes_cli.gui_uninstall as gu
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata)
un.run_data_uninstall(SimpleNamespace(yes=True))
assert not userdata.exists()
def test_run_data_uninstall_works_on_sealed_trees(monkeypatch, tmp_path):
# The whole point of the data mode: no code-removal gate applies.
home = _make_home(tmp_path)
(home / "hermes-agent" / "install-stamp.json").write_text(
json.dumps({"distribution": "nix", "updateMechanism": "external"})
, encoding="utf-8")
monkeypatch.setattr(un, "get_hermes_home", lambda: home)
import hermes_cli.gui_uninstall as gu
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
un.run_data_uninstall(SimpleNamespace(yes=True))
assert not (home / "config.yaml").exists()
assert (home / "hermes-agent").exists()
# ---------------------------------------------------------------------------
# gui_install_summary — steward facts for the desktop UI
# ---------------------------------------------------------------------------
def test_gui_summary_reports_steward_and_gate(monkeypatch, tmp_path):
import hermes_cli.gui_uninstall as gu
from hermes_cli import steward as st
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
# Sealed nix tree → steward named, code removal denied.
monkeypatch.setattr(st, "classify_install", lambda root: ("nix", False))
summary = gu.gui_install_summary(home)
assert summary["steward"] == "nix"
assert summary["code_removal_allowed"] is False
# Git checkout → ours to remove.
monkeypatch.setattr(st, "classify_install", lambda root: ("git", True))
summary = gu.gui_install_summary(home)
assert summary["steward"] == "git"
assert summary["code_removal_allowed"] is True
def test_gui_summary_classifies_the_agent_root(tmp_path, monkeypatch):
"""The steward facts classify the home's ``hermes-agent`` checkout — the
tree the code-removal modes would delete — not some unrelated root."""
import hermes_cli.gui_uninstall as gu
from hermes_cli import steward as st
seen = []
monkeypatch.setattr(st, "classify_install", lambda root: seen.append(root) or ("git", True))
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
home = tmp_path / ".hermes"
gu.gui_install_summary(home)
assert seen == [home / "hermes-agent"]
# ---------------------------------------------------------------------------
# module entrypoint routing
# ---------------------------------------------------------------------------
def test_module_entrypoint_routes_data_mode(monkeypatch):
calls = []
monkeypatch.setattr(un, "run_data_uninstall", lambda args: calls.append(("data", args)))
monkeypatch.setattr(un, "run_gui_uninstall", lambda args: calls.append(("gui", args)))
monkeypatch.setattr(un, "run_uninstall", lambda args: calls.append(("agent", args)))
assert un.main(["--mode", "data"]) == 0
assert calls[-1][0] == "data"
assert un.main(["--mode", "gui"]) == 0
assert calls[-1][0] == "gui"
assert un.main(["--mode", "full"]) == 0
assert calls[-1][0] == "agent"
assert calls[-1][1].full is True