Files
hermes-agent/tests/hermes_cli/test_data_uninstall.py
ethernet 6a6771e5e7 termux: stamp the deb as a self-contained runtime, not a bundled payload
build_deb.sh wrote HERMES_DESKTOP_VARIANT=bundled, so the Termux stamp
carried payload=bundled and every 'bundled' reader treated the tree as
the repo/ of an Electron payload. hermes uninstall --data then called
resolve_bundle_layout on it and rejected the plan: the deb has no
enclosing app to protect, so data-only removal was impossible on Termux.

Add a 'runtime' variant to write_install_stamp.py for a sealed CLI
runtime with no desktop app around it. It stays sealed for the update
gate and channel identity (source_check, update_channel accept it next
to bundled/light) while is_bundled_payload keeps answering False, so
cleanup planning protects the APT-owned package tree the ordinary way
and never asks where the app is.
2026-09-21 18:37:21 -04:00

159 lines
7.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)
def test_data_only_works_from_a_self_contained_runtime_without_an_app(layout, monkeypatch):
"""A Termux-shaped runtime (sealed, APT-owned, no Electron app around it)
must plan a data-only removal instead of hunting for a desktop app."""
import json
home, _, data = layout
package = home.parent / "usr" / "lib" / "hermes-agent"
project = package / "app"
(project / "hermes_cli").mkdir(parents=True)
(project / "hermes_cli" / "__init__.py").write_text("", encoding="utf-8")
(project / "install-stamp.json").write_text(json.dumps(
{"payload": "runtime", "distribution": "apt-termux", "updateMechanism": "external"}), encoding="utf-8")
(package / "venv").mkdir()
monkeypatch.setattr(uninstall, "get_project_root", lambda: project)
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert all(not path.exists() for path in data)
assert (project / "install-stamp.json").is_file() and (package / "venv").is_dir()