"""Tests for hermes_cli.gui_uninstall — GUI-only uninstall + install discovery. Covers the cross-platform artifact discovery, the agent/GUI detection the desktop UI gates options on, and that ``uninstall_gui`` removes only GUI artifacts (built renderer/release/node_modules, packaged bundle, Electron userData) while leaving the Python agent + config/sessions/.env intact. """ import sys from pathlib import Path import pytest import hermes_cli.gui_uninstall as gu def _make_agent(hermes_home: Path) -> Path: """Create a fake agent install: source package + venv.""" agent_root = hermes_home / "hermes-agent" (agent_root / "hermes_cli").mkdir(parents=True) (agent_root / "hermes_cli" / "__init__.py").write_text("") (agent_root / "venv" / "bin").mkdir(parents=True) return agent_root def _make_gui_build(hermes_home: Path) -> None: """Create the source-built GUI artifacts a `hermes desktop` run produces.""" desktop = hermes_home / "hermes-agent" / "apps" / "desktop" (desktop / "dist").mkdir(parents=True) (desktop / "dist" / "index.html").write_text("") (desktop / "release" / "linux-unpacked").mkdir(parents=True) (desktop / "node_modules").mkdir(parents=True) (hermes_home / "hermes-agent" / "node_modules").mkdir(parents=True) (hermes_home / "desktop-build-stamp.json").write_text("{}") def _make_user_data(hermes_home: Path) -> None: (hermes_home / "config.yaml").write_text("x: 1\n") (hermes_home / ".env").write_text("KEY=secret\n") (hermes_home / "sessions").mkdir() def test_agent_is_installed_detects_source_and_venv(tmp_path): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() assert gu.agent_is_installed(hermes_home) is False _make_agent(hermes_home) assert gu.agent_is_installed(hermes_home) is True def test_agent_is_installed_venv_only(tmp_path): """A checkout with only a venv (no package dir yet) still counts.""" hermes_home = tmp_path / ".hermes" (hermes_home / "hermes-agent" / "venv").mkdir(parents=True) assert gu.agent_is_installed(hermes_home) is True def test_source_built_artifacts_lists_known_paths(tmp_path): hermes_home = tmp_path / ".hermes" _make_gui_build(hermes_home) artifacts = gu.source_built_gui_artifacts(hermes_home) names = {p.name for p in artifacts} assert "dist" in names assert "release" in names assert "node_modules" in names assert "desktop-build-stamp.json" in names def test_gui_is_installed_true_when_built(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" _make_gui_build(hermes_home) # Make sure packaged-app + userdata probes don't false-positive on the box # running the test. monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope") assert gu.gui_is_installed(hermes_home) is True def test_gui_is_installed_false_when_nothing(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope") assert gu.gui_is_installed(hermes_home) is False def test_uninstall_gui_removes_only_gui_artifacts(tmp_path, monkeypatch): """The core invariant: GUI gone, agent + user data untouched.""" hermes_home = tmp_path / ".hermes" agent_root = _make_agent(hermes_home) _make_gui_build(hermes_home) _make_user_data(hermes_home) # Isolate the packaged-app + userdata probes from the test machine. monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "userdata-none") removed = gu.uninstall_gui(hermes_home) removed_names = {p.name for p in removed} # GUI artifacts removed. desktop = agent_root / "apps" / "desktop" assert not (desktop / "dist").exists() assert not (desktop / "release").exists() assert not (desktop / "node_modules").exists() assert not (agent_root / "node_modules").exists() assert not (hermes_home / "desktop-build-stamp.json").exists() assert "dist" in removed_names # Agent + user data preserved. assert (agent_root / "hermes_cli" / "__init__.py").exists() assert (agent_root / "venv").exists() assert (hermes_home / "config.yaml").exists() assert (hermes_home / ".env").exists() assert (hermes_home / "sessions").exists() # The desktop source dir itself survives (only its build output is gone). assert desktop.exists() def test_uninstall_gui_removes_userdata(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" _make_agent(hermes_home) userdata = tmp_path / "Hermes-userdata" userdata.mkdir() (userdata / "connection.json").write_text("{}") monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata) gu.uninstall_gui(hermes_home) assert not userdata.exists() def test_uninstall_gui_keeps_userdata_when_requested(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" _make_agent(hermes_home) userdata = tmp_path / "Hermes-userdata" userdata.mkdir() monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata) gu.uninstall_gui(hermes_home, remove_userdata=False) assert userdata.exists() def test_uninstall_gui_removes_packaged_bundle(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" _make_agent(hermes_home) bundle = tmp_path / "Hermes.app" (bundle / "Contents").mkdir(parents=True) monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [bundle]) monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none") removed = gu.uninstall_gui(hermes_home) assert not bundle.exists() assert bundle in removed def test_gui_install_summary_shape(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" _make_agent(hermes_home) _make_gui_build(hermes_home) monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none") summary = gu.gui_install_summary(hermes_home) # JSON-serializable primitives the desktop UI gates on. assert summary["agent_installed"] is True assert summary["gui_installed"] is True assert isinstance(summary["source_built_artifacts"], list) assert all(isinstance(p, str) for p in summary["source_built_artifacts"]) assert summary["hermes_home"] == str(hermes_home) assert summary["platform"] == sys.platform def test_userdata_dir_per_platform(monkeypatch): """userData path matches Electron's app.getPath('userData') for "Hermes".""" home = Path("/home/tester") monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) monkeypatch.setattr(gu.sys, "platform", "darwin") assert gu.desktop_userdata_dir() == home / "Library" / "Application Support" / "Hermes" monkeypatch.setattr(gu.sys, "platform", "linux") monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) assert gu.desktop_userdata_dir() == home / ".config" / "Hermes" def test_userdata_dir_windows(monkeypatch): home = Path("/home/tester") monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) monkeypatch.setattr(gu.sys, "platform", "win32") monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming") assert gu.desktop_userdata_dir() == Path(r"C:\Users\tester\AppData\Roaming") / "Hermes" @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") def test_remove_path_handles_symlink(tmp_path): target = tmp_path / "real" target.mkdir() link = tmp_path / "link" link.symlink_to(target) assert gu._remove_path(link) is True assert not link.exists() # The symlink is gone but its target is untouched. assert target.exists() class _Args: """Minimal argparse-Namespace stand-in for run_uninstall.""" def __init__(self, *, yes=False, full=False, gui=False, gui_summary=False): self.yes = yes self.full = full self.gui = gui self.gui_summary = gui_summary def test_run_uninstall_yes_keep_data_is_non_interactive(tmp_path, monkeypatch): """``--yes`` (no ``--full``) runs with no prompt, sweeps the GUI, keeps data. We DO NOT spawn the real CLI here (its project_root removal would delete the test checkout) — we call run_uninstall in-process against a throwaway HERMES_HOME with all the destructive externals stubbed out. """ import hermes_cli.uninstall as uninstall hermes_home = tmp_path / ".hermes" agent_root = hermes_home / "hermes-agent" (agent_root / "hermes_cli").mkdir(parents=True) (hermes_home / "config.yaml").write_text("x: 1\n") desktop = agent_root / "apps" / "desktop" (desktop / "release").mkdir(parents=True) (hermes_home / "desktop-build-stamp.json").write_text("{}") fake_code = tmp_path / "checkout" fake_code.mkdir() # Stub every destructive external so the test only exercises the control # flow + the real GUI sweep (which is safe inside tmp_path). monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home) monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code) monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False) monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: []) monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: []) monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: []) monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: []) # Make input() blow up so a regression that reaches a prompt fails loudly. monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode")) from hermes_cli import gui_uninstall as gu_mod monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none") uninstall.run_uninstall(_Args(yes=True, full=False)) # Code checkout removed, GUI artifacts swept, but user data preserved. assert not fake_code.exists() assert not (hermes_home / "desktop-build-stamp.json").exists() assert not (desktop / "release").exists() assert (hermes_home / "config.yaml").exists() assert hermes_home.exists() def test_run_uninstall_yes_full_wipes_home(tmp_path, monkeypatch): """``--yes --full`` removes the whole HERMES_HOME non-interactively.""" import hermes_cli.uninstall as uninstall hermes_home = tmp_path / ".hermes" (hermes_home / "hermes-agent" / "hermes_cli").mkdir(parents=True) (hermes_home / "config.yaml").write_text("x: 1\n") fake_code = tmp_path / "checkout" fake_code.mkdir() monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home) monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code) monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False) monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: []) monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: []) monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: []) monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: []) monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode")) from hermes_cli import gui_uninstall as gu_mod monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none") uninstall.run_uninstall(_Args(yes=True, full=True)) assert not hermes_home.exists() def test_uninstall_module_main_gui_mode(tmp_path, monkeypatch): """`python -m hermes_cli.uninstall --mode gui` runs the GUI-only path. This is the lightweight, venv-independent entrypoint the desktop launches with a system Python (so lite/full don't rmtree their own running venv on Windows). Verify it dispatches by mode without prompting. """ import hermes_cli.uninstall as uninstall hermes_home = tmp_path / ".hermes" agent_root = hermes_home / "hermes-agent" (agent_root / "hermes_cli").mkdir(parents=True) desktop = agent_root / "apps" / "desktop" (desktop / "release").mkdir(parents=True) (hermes_home / "desktop-build-stamp.json").write_text("{}") (hermes_home / "config.yaml").write_text("x: 1\n") monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home) from hermes_cli import gui_uninstall as gu_mod monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: []) monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none") monkeypatch.setattr(gu_mod, "get_hermes_home", lambda: hermes_home) monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in module main")) rc = uninstall.main(["--mode", "gui"]) assert rc == 0 # GUI swept, agent + config kept (gui-only contract). assert not (desktop / "release").exists() assert not (hermes_home / "desktop-build-stamp.json").exists() assert (agent_root / "hermes_cli").exists() assert (hermes_home / "config.yaml").exists() def test_uninstall_module_main_rejects_bad_mode(): """An invalid --mode exits non-zero (argparse), never silently full-wipes.""" import hermes_cli.uninstall as uninstall with pytest.raises(SystemExit) as exc: uninstall.main(["--mode", "nuke"]) assert exc.value.code != 0 def test_uninstall_args_namespace_mode_mapping(): """_UninstallArgs maps mode → the gui/full flags run_uninstall reads.""" import hermes_cli.uninstall as uninstall gui = uninstall._UninstallArgs(mode="gui") assert gui.gui is True and gui.full is False and gui.yes is True lite = uninstall._UninstallArgs(mode="lite") assert lite.gui is False and lite.full is False and lite.yes is True full = uninstall._UninstallArgs(mode="full") assert full.gui is False and full.full is True and full.yes is True