Files
hermes-agent/tests/hermes_cli/test_update_check.py
Nyk b89177668e fix(cli): non-blocking startup update check and banner deduplication
- Add background thread mechanism (prefetch_update_check/get_update_result)
  so git fetch runs in parallel with skill sync and agent init
- Fix repo path fallback in check_for_updates() for dev installs
- Remove duplicate build_welcome_banner (~180 lines) and
  _format_context_length from cli.py — the banner.py version is
  now the single source of truth
- Port skin banner_hero/banner_logo support and terminal width check
  from cli.py's version into banner.py
- Add update status output to hermes version command
- Add unit tests for update check, prefetch, and version string
2026-03-14 21:45:50 -07:00

136 lines
4.7 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):
"""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}))
with patch("hermes_cli.banner.os.getenv", return_value=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):
"""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")
with patch("hermes_cli.banner.os.getenv", return_value=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):
"""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()
original = banner.__file__
try:
banner.__file__ = str(fake_banner)
with patch("hermes_cli.banner.os.getenv", return_value=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()
finally:
banner.__file__ = original
def test_check_for_updates_fallback_to_project_root():
"""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
import tempfile
with tempfile.TemporaryDirectory() as td:
with patch("hermes_cli.banner.os.getenv", return_value=td):
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