/ tests / hermes_cli / test_update_check.py
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()