test_update_check.py
1 """Tests for the update check mechanism in hermes_cli.banner.""" 2 3 import json 4 import os 5 import threading 6 import time 7 from pathlib import Path 8 from unittest.mock import MagicMock, patch 9 10 import pytest 11 12 13 def test_version_string_no_v_prefix(): 14 """__version__ should be bare semver without a 'v' prefix.""" 15 from hermes_cli import __version__ 16 assert not __version__.startswith("v"), f"__version__ should not start with 'v', got {__version__!r}" 17 18 19 def test_check_for_updates_uses_cache(tmp_path, monkeypatch): 20 """When cache is fresh, check_for_updates should return cached value without calling git.""" 21 from hermes_cli.banner import check_for_updates 22 23 # Create a fake git repo and fresh cache 24 repo_dir = tmp_path / "hermes-agent" 25 repo_dir.mkdir() 26 (repo_dir / ".git").mkdir() 27 28 cache_file = tmp_path / ".update_check" 29 cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3})) 30 31 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 32 with patch("hermes_cli.banner.subprocess.run") as mock_run: 33 result = check_for_updates() 34 35 assert result == 3 36 mock_run.assert_not_called() 37 38 39 def test_check_for_updates_expired_cache(tmp_path, monkeypatch): 40 """When cache is expired, check_for_updates should call git fetch.""" 41 from hermes_cli.banner import check_for_updates 42 43 repo_dir = tmp_path / "hermes-agent" 44 repo_dir.mkdir() 45 (repo_dir / ".git").mkdir() 46 47 # Write an expired cache (timestamp far in the past) 48 cache_file = tmp_path / ".update_check" 49 cache_file.write_text(json.dumps({"ts": 0, "behind": 1})) 50 51 mock_result = MagicMock(returncode=0, stdout="5\n") 52 53 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 54 with patch("hermes_cli.banner.subprocess.run", return_value=mock_result) as mock_run: 55 result = check_for_updates() 56 57 assert result == 5 58 assert mock_run.call_count == 2 # git fetch + git rev-list 59 60 61 def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): 62 """Returns None when .git directory doesn't exist anywhere.""" 63 import hermes_cli.banner as banner 64 65 # Create a fake banner.py so the fallback path also has no .git 66 fake_banner = tmp_path / "hermes_cli" / "banner.py" 67 fake_banner.parent.mkdir(parents=True, exist_ok=True) 68 fake_banner.touch() 69 70 monkeypatch.setattr(banner, "__file__", str(fake_banner)) 71 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 72 with patch("hermes_cli.banner.subprocess.run") as mock_run: 73 result = banner.check_for_updates() 74 assert result is None 75 mock_run.assert_not_called() 76 77 78 def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch): 79 """Dev install: falls back to Path(__file__).parent.parent when HERMES_HOME has no git repo.""" 80 import hermes_cli.banner as banner 81 82 project_root = Path(banner.__file__).parent.parent.resolve() 83 if not (project_root / ".git").exists(): 84 pytest.skip("Not running from a git checkout") 85 86 # Point HERMES_HOME at a temp dir with no hermes-agent/.git 87 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 88 with patch("hermes_cli.banner.subprocess.run") as mock_run: 89 mock_run.return_value = MagicMock(returncode=0, stdout="0\n") 90 result = banner.check_for_updates() 91 # Should have fallen back to project root and run git commands 92 assert mock_run.call_count >= 1 93 94 95 def test_prefetch_non_blocking(): 96 """prefetch_update_check() should return immediately without blocking.""" 97 import hermes_cli.banner as banner 98 99 # Reset module state 100 banner._update_result = None 101 banner._update_check_done = threading.Event() 102 103 with patch.object(banner, "check_for_updates", return_value=5): 104 start = time.monotonic() 105 banner.prefetch_update_check() 106 elapsed = time.monotonic() - start 107 108 # Should return almost immediately (well under 1 second) 109 assert elapsed < 1.0 110 111 # Wait for the background thread to finish 112 banner._update_check_done.wait(timeout=5) 113 assert banner._update_result == 5 114 115 116 def test_invalidate_update_cache_clears_all_profiles(tmp_path): 117 """_invalidate_update_cache() should delete .update_check from ALL profiles.""" 118 from hermes_cli.main import _invalidate_update_cache 119 120 # Build a fake ~/.hermes with default + two named profiles 121 default_home = tmp_path / ".hermes" 122 default_home.mkdir() 123 (default_home / ".update_check").write_text('{"ts":1,"behind":50}') 124 125 profiles_root = default_home / "profiles" 126 for name in ("ops", "dev"): 127 p = profiles_root / name 128 p.mkdir(parents=True) 129 (p / ".update_check").write_text('{"ts":1,"behind":50}') 130 131 with patch.object(Path, "home", return_value=tmp_path), \ 132 patch.dict(os.environ, {"HERMES_HOME": str(default_home)}): 133 _invalidate_update_cache() 134 135 # All three caches should be gone 136 assert not (default_home / ".update_check").exists(), "default profile cache not cleared" 137 assert not (profiles_root / "ops" / ".update_check").exists(), "ops profile cache not cleared" 138 assert not (profiles_root / "dev" / ".update_check").exists(), "dev profile cache not cleared" 139 140 141 def test_invalidate_update_cache_no_profiles_dir(tmp_path): 142 """Works fine when no profiles directory exists (single-profile setup).""" 143 from hermes_cli.main import _invalidate_update_cache 144 145 default_home = tmp_path / ".hermes" 146 default_home.mkdir() 147 (default_home / ".update_check").write_text('{"ts":1,"behind":5}') 148 149 with patch.object(Path, "home", return_value=tmp_path), \ 150 patch.dict(os.environ, {"HERMES_HOME": str(default_home)}): 151 _invalidate_update_cache() 152 153 assert not (default_home / ".update_check").exists()