* refactor: re-architect tests to mirror the codebase
* Update tests.yml
* fix: add missing tool_error imports after registry refactor
* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist
patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.
* fix(tests): fix update_check and telegram xdist failures
- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
directly, it uses get_hermes_home() from hermes_constants.
- test_telegram_conflict/approval_buttons: provide real exception classes
for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
except clause in connect() doesn't fail with "catching classes that do
not inherit from BaseException" when xdist pollutes sys.modules.
* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
168 lines
5.9 KiB
Python
168 lines
5.9 KiB
Python
"""Tests for the update check mechanism in hermes_cli.banner."""
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def test_version_string_no_v_prefix():
|
|
"""__version__ should be bare semver without a 'v' prefix."""
|
|
from hermes_cli import __version__
|
|
assert not __version__.startswith("v"), f"__version__ should not start with 'v', got {__version__!r}"
|
|
|
|
|
|
def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
|
"""When cache is fresh, check_for_updates should return cached value without calling git."""
|
|
from hermes_cli.banner import check_for_updates
|
|
|
|
# Create a fake git repo and fresh cache
|
|
repo_dir = tmp_path / "hermes-agent"
|
|
repo_dir.mkdir()
|
|
(repo_dir / ".git").mkdir()
|
|
|
|
cache_file = tmp_path / ".update_check"
|
|
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3}))
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
|
result = check_for_updates()
|
|
|
|
assert result == 3
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
|
|
"""When cache is expired, check_for_updates should call git fetch."""
|
|
from hermes_cli.banner import check_for_updates
|
|
|
|
repo_dir = tmp_path / "hermes-agent"
|
|
repo_dir.mkdir()
|
|
(repo_dir / ".git").mkdir()
|
|
|
|
# Write an expired cache (timestamp far in the past)
|
|
cache_file = tmp_path / ".update_check"
|
|
cache_file.write_text(json.dumps({"ts": 0, "behind": 1}))
|
|
|
|
mock_result = MagicMock(returncode=0, stdout="5\n")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
with patch("hermes_cli.banner.subprocess.run", return_value=mock_result) as mock_run:
|
|
result = check_for_updates()
|
|
|
|
assert result == 5
|
|
assert mock_run.call_count == 2 # git fetch + git rev-list
|
|
|
|
|
|
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
|
|
"""Returns None when .git directory doesn't exist anywhere."""
|
|
import hermes_cli.banner as banner
|
|
|
|
# Create a fake banner.py so the fallback path also has no .git
|
|
fake_banner = tmp_path / "hermes_cli" / "banner.py"
|
|
fake_banner.parent.mkdir(parents=True, exist_ok=True)
|
|
fake_banner.touch()
|
|
|
|
monkeypatch.setattr(banner, "__file__", str(fake_banner))
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
|
result = banner.check_for_updates()
|
|
assert result is None
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch):
|
|
"""Dev install: falls back to Path(__file__).parent.parent when HERMES_HOME has no git repo."""
|
|
import hermes_cli.banner as banner
|
|
|
|
project_root = Path(banner.__file__).parent.parent.resolve()
|
|
if not (project_root / ".git").exists():
|
|
pytest.skip("Not running from a git checkout")
|
|
|
|
# Point HERMES_HOME at a temp dir with no hermes-agent/.git
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="0\n")
|
|
result = banner.check_for_updates()
|
|
# Should have fallen back to project root and run git commands
|
|
assert mock_run.call_count >= 1
|
|
|
|
|
|
def test_prefetch_non_blocking():
|
|
"""prefetch_update_check() should return immediately without blocking."""
|
|
import hermes_cli.banner as banner
|
|
|
|
# Reset module state
|
|
banner._update_result = None
|
|
banner._update_check_done = threading.Event()
|
|
|
|
with patch.object(banner, "check_for_updates", return_value=5):
|
|
start = time.monotonic()
|
|
banner.prefetch_update_check()
|
|
elapsed = time.monotonic() - start
|
|
|
|
# Should return almost immediately (well under 1 second)
|
|
assert elapsed < 1.0
|
|
|
|
# Wait for the background thread to finish
|
|
banner._update_check_done.wait(timeout=5)
|
|
assert banner._update_result == 5
|
|
|
|
|
|
def test_get_update_result_timeout():
|
|
"""get_update_result() returns None when check hasn't completed within timeout."""
|
|
import hermes_cli.banner as banner
|
|
|
|
# Reset module state — don't set the event
|
|
banner._update_result = None
|
|
banner._update_check_done = threading.Event()
|
|
|
|
start = time.monotonic()
|
|
result = banner.get_update_result(timeout=0.1)
|
|
elapsed = time.monotonic() - start
|
|
|
|
# Should have waited ~0.1s and returned None
|
|
assert result is None
|
|
assert elapsed < 0.5
|
|
|
|
|
|
def test_invalidate_update_cache_clears_all_profiles(tmp_path):
|
|
"""_invalidate_update_cache() should delete .update_check from ALL profiles."""
|
|
from hermes_cli.main import _invalidate_update_cache
|
|
|
|
# Build a fake ~/.hermes with default + two named profiles
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
(default_home / ".update_check").write_text('{"ts":1,"behind":50}')
|
|
|
|
profiles_root = default_home / "profiles"
|
|
for name in ("ops", "dev"):
|
|
p = profiles_root / name
|
|
p.mkdir(parents=True)
|
|
(p / ".update_check").write_text('{"ts":1,"behind":50}')
|
|
|
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
_invalidate_update_cache()
|
|
|
|
# All three caches should be gone
|
|
assert not (default_home / ".update_check").exists(), "default profile cache not cleared"
|
|
assert not (profiles_root / "ops" / ".update_check").exists(), "ops profile cache not cleared"
|
|
assert not (profiles_root / "dev" / ".update_check").exists(), "dev profile cache not cleared"
|
|
|
|
|
|
def test_invalidate_update_cache_no_profiles_dir(tmp_path):
|
|
"""Works fine when no profiles directory exists (single-profile setup)."""
|
|
from hermes_cli.main import _invalidate_update_cache
|
|
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
(default_home / ".update_check").write_text('{"ts":1,"behind":5}')
|
|
|
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
_invalidate_update_cache()
|
|
|
|
assert not (default_home / ".update_check").exists()
|