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.
234 lines
9.0 KiB
Python
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
|