mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
Two additional CI failures surfaced when the first PR ran through GHA — both were pre-existing but blocked merge. 1) tests/cron/test_scheduler.py::TestRunJobWakeGate (3 tests) run_job calls resolve_runtime_provider BEFORE constructing AIAgent, so patching run_agent.AIAgent alone isn't enough — the resolver raises 'No inference provider configured' in hermetic CI (no API keys) and the test never reaches the mocked AIAgent. Added autouse fixture that stubs resolve_runtime_provider with a fake openrouter runtime. 2) tests/hermes_cli/test_update_check.py::test_get_update_result_timeout Observed on CI: assert 4950 is None. A background update-check thread (from an earlier test or hermes_cli.main's own prefetch_update_check call) raced a real git-fetch result (4950 commits behind origin/main) into banner._update_result during this test's wait(0.1). Wrap the test in patch.object(banner, 'check_for_updates', return_value=None) so any in-flight thread writes None rather than a real value. Validation: Under CI-parity env (env -i, no creds): 6/6 pass Broader suite (tests/hermes_cli + cron + gateway + run_agent/streaming + toolsets + discord_tool): 6033 passed, pre-existing failures in telegram_approval_buttons (3) and internal_event_bypass_pairing (1) are unrelated.
180 lines
6.7 KiB
Python
180 lines
6.7 KiB
Python
"""Tests for the update check mechanism in hermes_cli.banner."""
|
|
|
|
import json
|
|
import os
|
|
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.
|
|
|
|
Race protection: a background update-check thread from an earlier
|
|
test, or from hermes_cli.main's own prefetch_update_check(), could
|
|
write to module-level ``_update_result`` during this test's
|
|
``wait(0.1)``. Observed on CI: a real git-fetch returned 4950
|
|
commits-behind mid-test, failing ``assert 4950 is None``. Patching
|
|
``check_for_updates`` for the duration of the test ensures any
|
|
in-flight thread writes ``None`` rather than a real fetch result.
|
|
"""
|
|
import hermes_cli.banner as banner
|
|
|
|
with patch.object(banner, "check_for_updates", return_value=None):
|
|
# Fresh Event so we hit the timeout branch deterministically.
|
|
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), \
|
|
patch.dict(os.environ, {"HERMES_HOME": str(default_home)}):
|
|
_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), \
|
|
patch.dict(os.environ, {"HERMES_HOME": str(default_home)}):
|
|
_invalidate_update_cache()
|
|
|
|
assert not (default_home / ".update_check").exists()
|