"""Tests for lazy-backend refresh venv repair (#57828 / #58004).""" from __future__ import annotations import textwrap from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch import hermes_cli.main as m import pytest def test_detect_returns_none_when_probe_subprocess_fails(tmp_path, monkeypatch): python = tmp_path / "python" python.write_text("", encoding="utf-8") monkeypatch.setattr( m, "_resolve_install_target_python", lambda *a, **k: python ) monkeypatch.setattr( m.subprocess, "run", MagicMock(side_effect=OSError("exec failed")), ) assert m._detect_broken_lazy_refresh_imports(["uv", "pip"]) is None def test_repair_runs_force_reinstall_with_pyproject_pins( tmp_path, monkeypatch ): pyproject = tmp_path / "pyproject.toml" pyproject.write_text( textwrap.dedent( """\ [project] name = "fake" version = "0.0.0" dependencies = [ "pyyaml==6.0.3", "click==8.2.1", ] """ ) ) monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) calls: list[list[str]] = [] def fake_install(cmd, **kwargs): calls.append(cmd) detect_calls = {"count": 0} def fake_detect(prefix, *, env=None): detect_calls["count"] += 1 return [] monkeypatch.setattr(m, "_run_package_only_install", fake_install) monkeypatch.setattr(m, "_detect_broken_lazy_refresh_imports", fake_detect) ok = m._repair_broken_lazy_refresh_imports( ["uv", "pip"], ["PyYAML", "click"], env={"VIRTUAL_ENV": str(tmp_path)}, ) assert ok is True assert calls == [ [ "uv", "pip", "install", "--force-reinstall", "pyyaml==6.0.3", "click==8.2.1", ] ] assert detect_calls["count"] == 1 def test_refresh_repairs_venv_after_lazy_failure(tmp_path, monkeypatch, capsys): import tools.lazy_deps as lazy_deps_mod monkeypatch.setattr(lazy_deps_mod, "active_features", lambda: ["platform.matrix"]) monkeypatch.setattr( lazy_deps_mod, "refresh_active_features", lambda **kw: {"platform.matrix": "failed: pip install failed"}, ) repair_calls: list[list[str]] = [] def fake_repair(prefix, packages, *, env=None): repair_calls.append(packages) return True monkeypatch.setattr(m, "_detect_broken_lazy_refresh_imports", lambda *a, **k: ["PyYAML"]) monkeypatch.setattr(m, "_repair_broken_lazy_refresh_imports", fake_repair) ok = m._refresh_active_lazy_features(["uv", "pip"], env={"VIRTUAL_ENV": str(tmp_path)}) out = capsys.readouterr().out assert ok is True assert repair_calls == [["PyYAML"]] assert "Venv repair succeeded" in out assert "import probes" in out assert "Backends keep their previously-installed version" not in out def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch): """Replacement runtimes must not re-detect features after packages vanish.""" import tools.lazy_deps as lazy_deps_mod monkeypatch.setattr( lazy_deps_mod, "active_features", lambda: pytest.fail("post-rebuild detection must not run"), ) restored = [] monkeypatch.setattr( lazy_deps_mod, "restore_features", lambda features: restored.append(features) or {"platform.telegram": "restored"}, ) assert m._refresh_active_lazy_features( ["uv", "pip"], features=["platform.telegram"] ) is True assert restored == [["platform.telegram"]] def test_capture_active_tool_dependencies_uses_tools_status_probes(monkeypatch): from hermes_cli import tools_config monkeypatch.setattr( tools_config, "_module_installed", lambda module: module in {"langfuse", "ddgs"}, ) assert m._capture_active_tool_dependencies() == ["ddgs", "langfuse"] def test_restore_active_tool_dependencies_uses_static_allowlist(monkeypatch): calls = [] monkeypatch.setattr( m, "_run_package_only_install", lambda cmd, *, env=None: calls.append((cmd, env)), ) env = {"VIRTUAL_ENV": "/tmp/venv"} m._restore_active_tool_dependencies( ["langfuse", "not-allowlisted"], ["uv", "pip"], env=env, ) assert calls == [(["uv", "pip", "install", "langfuse", "--quiet"], env)] def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( tmp_path, monkeypatch ): """The updater must carry pre-rebuild state into its repair refresh.""" from hermes_cli import managed_uv, update_cmd (tmp_path / ".git").mkdir() snapshot = ["platform.telegram"] tool_snapshot = ["langfuse"] refresh_calls = [] restore_calls = [] class RestoreReached(Exception): pass def fake_run(cmd, **kwargs): if "rev-parse" in cmd: return SimpleNamespace(returncode=0, stdout="main\n", stderr="") if "rev-list" in cmd: return SimpleNamespace(returncode=0, stdout="0\n", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") def fake_refresh(prefix, *, env=None, features=None): refresh_calls.append((prefix, env, features)) return True def fake_restore(dependencies, prefix, *, env=None): restore_calls.append((dependencies, prefix, env)) raise RestoreReached monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(m, "_capture_active_lazy_features", lambda: snapshot.copy()) monkeypatch.setattr( m, "_capture_active_tool_dependencies", lambda: tool_snapshot.copy() ) monkeypatch.setattr(m, "_is_windows", lambda: False) monkeypatch.setattr(m, "_run_pre_update_backup", lambda args: None) monkeypatch.setattr(m, "_pause_windows_gateways_for_update", lambda: None) monkeypatch.setattr(m, "_resume_windows_gateways_after_update", lambda state: None) monkeypatch.setattr(update_cmd, "_discard_lockfile_churn", lambda *args: None) monkeypatch.setattr(m, "_get_origin_url", lambda *args: "https://github.com/NousResearch/hermes-agent.git") monkeypatch.setattr(m, "_resolve_update_branch", lambda args: "main") monkeypatch.setattr(m, "_stash_local_changes_if_needed", lambda *args: None) monkeypatch.setattr(update_cmd, "_invalidate_update_cache", lambda: None) monkeypatch.setattr( update_cmd, "_venv_core_imports_healthy", lambda: (False, "broken") ) monkeypatch.setattr(update_cmd, "_write_update_incomplete_marker", lambda: None) monkeypatch.setattr( m, "_install_python_dependencies_with_optional_fallback", lambda *a, **k: None ) monkeypatch.setattr(m, "_refresh_active_lazy_features", fake_refresh) monkeypatch.setattr(m, "_restore_active_tool_dependencies", fake_restore) monkeypatch.setattr(m.subprocess, "run", fake_run) monkeypatch.setattr(managed_uv, "update_managed_uv", lambda **kwargs: None) monkeypatch.setattr(managed_uv, "ensure_uv", lambda **kwargs: "uv") args = SimpleNamespace( yes=True, force=False, force_venv=False, no_backup=True, backup=False, branch=None, ) with pytest.raises(RestoreReached): m._cmd_update_impl(args, gateway_mode=False) # The repair env is now built via managed_python_env (#83914): third-party # UV vars are stripped, managed pins set, then VIRTUAL_ENV re-pointed at # the install's venv. Assert the CONTRACT, not the raw environ copy. from hermes_cli.managed_uv import managed_python_env expected_env = managed_python_env() expected_env["VIRTUAL_ENV"] = str(tmp_path / "venv") assert refresh_calls == [ ( ["uv", "pip"], expected_env, snapshot, ) ] assert restore_calls == [ ( tool_snapshot, ["uv", "pip"], expected_env, ) ]