Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"""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_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
|
||||
from hermes_cli import __version__
|
||||
|
||||
# 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, "ver": __version__}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
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_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_upstream_main_sha_disables_git_prompts(monkeypatch):
|
||||
"""The passive HTTPS probe must never inherit the interactive terminal."""
|
||||
from hermes_cli import banner
|
||||
|
||||
completed = MagicMock(returncode=1, stdout="", stderr="auth required")
|
||||
run = MagicMock(return_value=completed)
|
||||
monkeypatch.setattr(banner.subprocess, "run", run)
|
||||
|
||||
assert banner._upstream_main_sha() is None
|
||||
kwargs = run.call_args.kwargs
|
||||
assert kwargs["stdin"] is banner.subprocess.DEVNULL
|
||||
assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
||||
assert kwargs["env"]["GCM_INTERACTIVE"] == "Never"
|
||||
|
||||
|
||||
def test_check_via_local_git_fetch_failure_returns_none(tmp_path, monkeypatch):
|
||||
"""When git fetch fails and the stale origin/main ref is not ahead,
|
||||
_check_via_local_git must return None (#82166).
|
||||
|
||||
A stale tracking ref cannot prove *currentness* (rev-list 0 just means
|
||||
the ref hasn't caught up), so returning None is the honest inconclusive
|
||||
result — and the caller must not cache it as "up to date".
|
||||
"""
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
# Simulate a non-shallow, non-SSH-remote checkout
|
||||
def mock_git_stdout(args, *, cwd, timeout=5):
|
||||
if args[:2] == ["remote", "get-url"]:
|
||||
return "https://github.com/NousResearch/hermes-agent.git"
|
||||
if args[:2] == ["rev-parse", "--is-shallow-repository"]:
|
||||
return "false"
|
||||
return None
|
||||
|
||||
# Fetch fails (returncode != 0); stale rev-list reports 0 behind
|
||||
failed_proc = MagicMock()
|
||||
failed_proc.returncode = 1
|
||||
failed_proc.stdout = ""
|
||||
failed_proc.stderr = "fatal: could not reach remote"
|
||||
|
||||
stale_zero_proc = MagicMock()
|
||||
stale_zero_proc.returncode = 0
|
||||
stale_zero_proc.stdout = "0"
|
||||
|
||||
fetch_kwargs = None
|
||||
|
||||
def mock_run(args, **kwargs):
|
||||
nonlocal fetch_kwargs
|
||||
if args[:2] == ["git", "fetch"]:
|
||||
fetch_kwargs = kwargs
|
||||
return failed_proc
|
||||
if args[:2] == ["git", "rev-list"]:
|
||||
return stale_zero_proc
|
||||
raise AssertionError(f"unexpected subprocess.run: {args}")
|
||||
|
||||
monkeypatch.setattr(banner, "_git_stdout", mock_git_stdout)
|
||||
monkeypatch.setattr(banner.subprocess, "run", mock_run)
|
||||
|
||||
result = banner._check_via_local_git(repo_dir)
|
||||
assert result is None, (
|
||||
"Fetch failure with stale 0-behind must return None, not 'up to date'"
|
||||
)
|
||||
assert fetch_kwargs is not None
|
||||
assert fetch_kwargs["stdin"] is banner.subprocess.DEVNULL
|
||||
assert fetch_kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
||||
assert fetch_kwargs["env"]["GCM_INTERACTIVE"] == "Never"
|
||||
|
||||
|
||||
def test_check_via_local_git_fetch_failure_keeps_positive_stale_count(tmp_path, monkeypatch):
|
||||
"""A failed fetch must preserve sound evidence: if the stale origin/main
|
||||
ref already shows HEAD behind, that positive count is still an update
|
||||
signal and must be returned (review #92578)."""
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
def mock_git_stdout(args, *, cwd, timeout=5):
|
||||
if args[:2] == ["remote", "get-url"]:
|
||||
return "https://github.com/NousResearch/hermes-agent.git"
|
||||
if args[:2] == ["rev-parse", "--is-shallow-repository"]:
|
||||
return "false"
|
||||
return None
|
||||
|
||||
failed_proc = MagicMock()
|
||||
failed_proc.returncode = 1
|
||||
failed_proc.stdout = ""
|
||||
failed_proc.stderr = "fatal: could not reach remote"
|
||||
|
||||
stale_behind_proc = MagicMock()
|
||||
stale_behind_proc.returncode = 0
|
||||
stale_behind_proc.stdout = "5"
|
||||
|
||||
def mock_run(args, **kwargs):
|
||||
if args[:2] == ["git", "fetch"]:
|
||||
return failed_proc
|
||||
if args[:2] == ["git", "rev-list"]:
|
||||
return stale_behind_proc
|
||||
raise AssertionError(f"unexpected subprocess.run: {args}")
|
||||
|
||||
monkeypatch.setattr(banner, "_git_stdout", mock_git_stdout)
|
||||
monkeypatch.setattr(banner.subprocess, "run", mock_run)
|
||||
|
||||
result = banner._check_via_local_git(repo_dir)
|
||||
assert result == 5, "Stale positive behind-count must be preserved on fetch failure"
|
||||
|
||||
|
||||
def test_check_via_local_git_fetch_failure_rev_list_error_returns_none(tmp_path, monkeypatch):
|
||||
"""If the stale rev-list itself fails, the check stays inconclusive (None)."""
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
def mock_git_stdout(args, *, cwd, timeout=5):
|
||||
if args[:2] == ["remote", "get-url"]:
|
||||
return "https://github.com/NousResearch/hermes-agent.git"
|
||||
if args[:2] == ["rev-parse", "--is-shallow-repository"]:
|
||||
return "false"
|
||||
return None
|
||||
|
||||
failed_proc = MagicMock()
|
||||
failed_proc.returncode = 1
|
||||
failed_proc.stdout = ""
|
||||
failed_proc.stderr = "fatal: could not reach remote"
|
||||
|
||||
bad_rev_list = MagicMock()
|
||||
bad_rev_list.returncode = 128
|
||||
bad_rev_list.stdout = ""
|
||||
bad_rev_list.stderr = "fatal: ambiguous argument 'HEAD..origin/main'"
|
||||
|
||||
def mock_run(args, **kwargs):
|
||||
if args[:2] == ["git", "fetch"]:
|
||||
return failed_proc
|
||||
if args[:2] == ["git", "rev-list"]:
|
||||
return bad_rev_list
|
||||
raise AssertionError(f"unexpected subprocess.run: {args}")
|
||||
|
||||
monkeypatch.setattr(banner, "_git_stdout", mock_git_stdout)
|
||||
monkeypatch.setattr(banner.subprocess, "run", mock_run)
|
||||
|
||||
result = banner._check_via_local_git(repo_dir)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_check_for_updates_does_not_cache_none(tmp_path, monkeypatch):
|
||||
"""check_for_updates must not cache None results so a transient fetch
|
||||
failure doesn't suppress retries for the full 6-hour cache window (#82166).
|
||||
|
||||
Instead of mocking the full Path resolution chain, we verify the cache-write
|
||||
guard directly: call check_for_updates with a mocked _check_via_local_git
|
||||
that returns None, and confirm no cache file is created.
|
||||
"""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
cache_file = tmp_path / ".update_check"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("HERMES_REVISION", raising=False)
|
||||
|
||||
# Create a fake repo dir so the .git check passes
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
# Mock the internal functions to force the local-git path returning None
|
||||
monkeypatch.setattr(banner, "_check_via_local_git", lambda rd: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.detect_install_method", lambda root: "git"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.get_project_root", lambda: repo_dir
|
||||
)
|
||||
|
||||
# Patch __file__ resolution by monkeypatching the module's Path calls.
|
||||
# check_for_updates does: Path(__file__).parent.parent.resolve()
|
||||
# We intercept by making the resolve() return our fake repo_dir.
|
||||
original_init = Path.__init__
|
||||
|
||||
def patched_path_init(self, *args, **kwargs):
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
# Simpler: just patch the get_hermes_home and the repo_dir resolution
|
||||
# by making check_for_updates find our fake repo via hermes_home fallback.
|
||||
# The code checks Path(__file__).parent.parent/.git first, then falls
|
||||
# back to hermes_home / "hermes-agent". We ensure the fallback hits.
|
||||
# To do this, we make Path(__file__).parent.parent.resolve() return
|
||||
# a path without .git, so it falls through to hermes_home / "hermes-agent".
|
||||
real_resolve = Path.resolve
|
||||
|
||||
def fake_resolve(self, *args, **kwargs):
|
||||
s = str(self)
|
||||
if "banner.py" in s or s.endswith("hermes_cli"):
|
||||
# Return a path that has no .git, forcing the fallback
|
||||
return tmp_path / "no-git-here"
|
||||
return real_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fake_resolve)
|
||||
|
||||
result = banner.check_for_updates()
|
||||
assert result is None
|
||||
|
||||
# The cache file must NOT have been written with a None result
|
||||
assert not cache_file.exists(), "None result must not be cached"
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user