Files
hermes-agent/tests/hermes_cli/test_data_uninstall.py
ethernet bbec973514 refactor(pm): pm owns the dependency-environment layout and interpreter paths
hermes_cli.runtime_paths (venv generations, selection, activation) moves to
pm.environments, and gains venv_bin_dir / venv_python / project_python. Every
in-tree caller asks pm for an interpreter now; pm no longer reaches back into
hermes_cli for its own environment layout (pm.packages, pm.extras, pm.ensure,
pm.paths imported hermes_cli.runtime_paths). The three open-coded
"Scripts/python.exe or bin/python" ladders in pm collapse onto venv_python.

hermes_constants.venv_python_path / venv_bin_dir and hermes_cli.runtime_paths
stay as frozen-updater-surface shims only (tests/compat/old_updater_surface.json).

To keep the boot path light, pm/__init__ resolves its facade lazily (PEP 562)
and pm.registry loads the built-in package definitions on first read instead of
at import: `import hermes_bootstrap` now loads pm + pm.environments only (25ms,
was 37ms with the eager facade dragging in the downloader). The stripped-payload
fixtures that ship only pre-import files keep working for the same reason.

Also restores two frozen-surface re-exports the F401 sweep dropped
(banner._github_compare_behind, cua_backend.resolve_cua_driver_cmd).
2026-09-18 20:02:36 -04:00

140 lines
6.0 KiB
Python

"""Data deletion respects declared runtime ownership, confirmation and scope."""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
import hermes_cli.uninstall as uninstall
@pytest.fixture
def layout(tmp_path, monkeypatch):
user = tmp_path / "user"
user.mkdir()
home = user / "hermes-data"
source = home / "workspace" / "custom-source"
store = home / "machine" / "tool-store"
userdata = user / "desktop-data"
witnesses = [source / "hermes_cli" / "__init__.py", store / "python" / "python.exe",
home / "installs" / "other-install" / "facts.json", home / "bin" / "hermes.cmd",
home / "profiles" / "sibling" / "config.yaml",
home / "cache" / "partials" / ".locks" / "other-profile-transfer"]
for path in witnesses:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("keep", encoding="utf-8")
data = [home / "config.yaml", home / "sessions" / "session.json", home / "workspace" / "notes.txt",
home / ".env", home / "logs" / "file"]
for path in data:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}" if path.suffix in {".yaml", ".json"} else "user data", encoding="utf-8")
monkeypatch.setattr(Path, "home", lambda: user)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store))
monkeypatch.setattr(uninstall, "get_project_root", lambda: source)
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: home)
monkeypatch.setattr("hermes_cli.gui_uninstall.desktop_userdata_dir", lambda: userdata)
return home, witnesses, data
@pytest.mark.parametrize("mode", ["confirmed", "cancel", "dry-run"])
def test_data_only_preserves_runtime_and_sibling_homes(layout, monkeypatch, mode):
import json
from pm.environments import install_state_dir, runtime_facts_path, selected_venv
monkeypatch.delattr(Path, "is_junction", raising=False)
home, witnesses, data = layout
source = uninstall.get_project_root()
generation = install_state_dir(source) / "environments" / "selected"
generation.mkdir(parents=True)
(generation / "pyvenv.cfg").write_text("include-system-site-packages = false", encoding="utf-8")
runtime_facts_path(source).write_text(json.dumps({"packages": {"venv": {"environment": str(generation)}}}), encoding="utf-8")
monkeypatch.setattr("builtins.input", lambda *args: "no")
uninstall.run_data_uninstall(SimpleNamespace(yes=mode != "cancel", dry_run=mode == "dry-run"))
assert selected_venv(source) == generation
assert all(path.read_text(encoding="utf-8") == "keep" for path in witnesses)
assert all(path.exists() is (mode != "confirmed") for path in data)
assert home.is_dir()
def test_data_only_reports_a_failed_removal(layout, monkeypatch, capsys):
home, witnesses, _ = layout
real = uninstall.shutil.rmtree
def refuse(path, *args, **kwargs):
if Path(path) == home / "sessions":
raise PermissionError("fixture holds sessions")
return real(path, *args, **kwargs)
monkeypatch.setattr(uninstall.shutil, "rmtree", refuse)
with pytest.raises(SystemExit) as failure:
uninstall.run_data_uninstall(SimpleNamespace(yes=True, dry_run=False))
assert failure.value.code != 0
output = capsys.readouterr().out
assert "Hermes data removed" not in output
assert str(home / "sessions") in output
assert all(path.exists() for path in witnesses)
def test_named_home_does_not_erase_siblings_or_desktop_data(layout, monkeypatch):
home, witnesses, _ = layout
active = home / "profiles" / "active"
active.mkdir()
config = active / "config.yaml"
config.write_text("model: selected", encoding="utf-8")
userdata = home.parent / "desktop-data"
userdata.mkdir()
(userdata / "preferences.json").write_text("keep", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(active))
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: active)
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert not config.exists()
assert all(path.exists() for path in witnesses)
assert (userdata / "preferences.json").read_text(encoding="utf-8") == "keep"
def test_directory_replaced_with_a_link_does_not_expand_removal(layout, tmp_path):
from hermes_cli.data_cleanup import plan_data_removal, remove_data
home, _, _ = layout
original = home / "workspace"
foreign = tmp_path / "foreign"
foreign.mkdir()
witness = foreign / "notes.txt"
witness.write_text("not Hermes data", encoding="utf-8")
probe = tmp_path / "symlink-probe"
try:
probe.symlink_to(foreign, target_is_directory=True)
except OSError as exc:
pytest.skip(f"native symlink creation unavailable: {exc}")
probe.unlink()
plan = plan_data_removal(home, uninstall.get_project_root())
original.rename(home / "workspace-moved")
original.symlink_to(foreign, target_is_directory=True)
_, failures = remove_data(plan)
assert failures
assert witness.read_text(encoding="utf-8") == "not Hermes data"
def test_data_only_preserves_the_containing_bundled_application(layout, monkeypatch):
import json
from hermes_cli.bundled_app import PAYLOAD_DIR_NAME
home, _, data = layout
app = home / "installed-app"
payload = app / "resources" / PAYLOAD_DIR_NAME
project = payload / "repo"
project.mkdir(parents=True)
(project / "install-stamp.json").write_text(json.dumps({"payload": "bundled"}), encoding="utf-8")
shell = app / "Hermes.exe"
shell.write_bytes(b"retained app bytes")
(payload / "venv").mkdir()
manifest = payload / "manifest.json"
manifest.write_text(json.dumps({"repo": "repo", "venv": "venv"}), encoding="utf-8")
monkeypatch.setattr(uninstall, "get_project_root", lambda: project)
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert shell.read_bytes() == b"retained app bytes"
assert manifest.is_file()
assert all(not path.exists() for path in data)