Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
"""Tests for /update gateway slash command.
|
||||
|
||||
Tests both the _handle_update_command handler (spawns update process) and
|
||||
the _send_update_notification startup hook (sends results after restart).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_event(text="/update", platform=Platform.TELEGRAM,
|
||||
user_id="12345", chat_id="67890", thread_id=None):
|
||||
"""Build a MessageEvent for testing."""
|
||||
source = SessionSource(
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
user_name="testuser",
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return MessageEvent(text=text, source=source)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Create a bare GatewayRunner without calling __init__."""
|
||||
from gateway.run import GatewayRunner
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._voice_mode = {}
|
||||
return runner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _handle_update_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleUpdateCommand:
|
||||
"""Tests for GatewayRunner._handle_update_command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_managed_install_returns_package_manager_guidance(self, monkeypatch):
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
|
||||
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
assert "managed by Homebrew" in result
|
||||
assert "brew upgrade hermes-agent" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_git_directory(self, tmp_path):
|
||||
"""Returns an error when .git does not exist."""
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
# Point _hermes_home to tmp_path and project_root to a dir without .git
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
with patch("gateway.run._hermes_home", tmp_path), \
|
||||
patch("gateway.run.Path") as MockPath:
|
||||
# Path(__file__).parent.parent.resolve() -> fake_root
|
||||
MockPath.return_value = MagicMock()
|
||||
MockPath.__truediv__ = Path.__truediv__
|
||||
# Easier: just patch the __file__ resolution in the method
|
||||
pass
|
||||
|
||||
# Simpler approach — mock at method level using a wrapper
|
||||
runner = _make_runner()
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path):
|
||||
# The handler does Path(__file__).parent.parent.resolve()
|
||||
# We need to make project_root / '.git' not exist.
|
||||
# Since Path(__file__) resolves to the real gateway/run.py,
|
||||
# project_root will be the real hermes-agent dir (which HAS .git).
|
||||
# Patch Path to control this.
|
||||
original_path = Path
|
||||
|
||||
class FakePath(type(Path())):
|
||||
pass
|
||||
|
||||
# Actually, simplest: just patch the specific file attr
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
(fake_root / "gateway").mkdir(parents=True)
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
|
||||
with patch("gateway.run.__file__", fake_file):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
assert "Not a git repository" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_hermes_binary(self, tmp_path):
|
||||
"""Returns error when hermes is not on PATH and hermes_cli is not importable."""
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
# Create project dir WITH .git
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", return_value=None), \
|
||||
patch("importlib.util.find_spec", return_value=None):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
assert "Could not locate" in result
|
||||
assert "hermes update" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_to_sys_executable(self, tmp_path):
|
||||
"""Falls back to sys.executable -m hermes_cli.main when hermes not on PATH."""
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
mock_popen = MagicMock()
|
||||
fake_spec = MagicMock()
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", return_value=None), \
|
||||
patch("importlib.util.find_spec", return_value=fake_spec), \
|
||||
patch("subprocess.Popen", mock_popen):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
assert "Starting Hermes update" in result
|
||||
call_args = mock_popen.call_args[0][0]
|
||||
# The update_cmd uses sys.executable -m hermes_cli.main
|
||||
joined = " ".join(call_args) if isinstance(call_args, list) else call_args
|
||||
assert "hermes_cli.main" in joined or "bash" in call_args[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_hermes_bin_prefers_which(self, tmp_path):
|
||||
"""_resolve_hermes_bin returns argv parts from shutil.which when available."""
|
||||
from gateway.run import _resolve_hermes_bin
|
||||
|
||||
with patch("shutil.which", return_value="/custom/path/hermes"):
|
||||
result = _resolve_hermes_bin()
|
||||
|
||||
assert result == ["/custom/path/hermes"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_hermes_bin_fallback(self):
|
||||
"""_resolve_hermes_bin falls back to sys.executable argv when which fails."""
|
||||
import sys
|
||||
from gateway.run import _resolve_hermes_bin
|
||||
|
||||
fake_spec = MagicMock()
|
||||
with patch("shutil.which", return_value=None), \
|
||||
patch("importlib.util.find_spec", return_value=fake_spec):
|
||||
result = _resolve_hermes_bin()
|
||||
|
||||
assert result == [sys.executable, "-m", "hermes_cli.main"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_hermes_bin_returns_none_when_both_fail(self):
|
||||
"""_resolve_hermes_bin returns None when both strategies fail."""
|
||||
from gateway.run import _resolve_hermes_bin
|
||||
|
||||
with patch("shutil.which", return_value=None), \
|
||||
patch("importlib.util.find_spec", return_value=None):
|
||||
result = _resolve_hermes_bin()
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writes_pending_marker(self, tmp_path):
|
||||
"""Writes .update_pending.json with correct platform and chat info."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(platform=Platform.TELEGRAM, chat_id="99999")
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", side_effect=lambda x: "/usr/bin/hermes" if x == "hermes" else "/usr/bin/setsid"), \
|
||||
patch("subprocess.Popen"):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
pending_path = hermes_home / ".update_pending.json"
|
||||
assert pending_path.exists()
|
||||
data = json.loads(pending_path.read_text())
|
||||
assert data["platform"] == "telegram"
|
||||
assert data["chat_id"] == "99999"
|
||||
assert "timestamp" in data
|
||||
assert not (hermes_home / ".update_exit_code").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writes_pending_marker_with_thread_id(self, tmp_path):
|
||||
"""Persists thread_id so update notifications can route back to the thread."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="99999",
|
||||
thread_id="777",
|
||||
)
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", side_effect=lambda x: "/usr/bin/hermes" if x == "hermes" else "/usr/bin/setsid"), \
|
||||
patch("subprocess.Popen"):
|
||||
await runner._handle_update_command(event)
|
||||
|
||||
data = json.loads((hermes_home / ".update_pending.json").read_text())
|
||||
assert data["thread_id"] == "777"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawns_setsid(self, tmp_path):
|
||||
"""Uses setsid when available."""
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
mock_popen = MagicMock()
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
|
||||
patch("subprocess.Popen", mock_popen):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
# Verify setsid was used
|
||||
call_args = mock_popen.call_args[0][0]
|
||||
assert call_args[0] == "/usr/bin/setsid"
|
||||
assert call_args[1] == "bash"
|
||||
assert ".update_exit_code" in call_args[-1]
|
||||
assert "Starting Hermes update" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_when_no_setsid(self, tmp_path):
|
||||
"""Falls back to start_new_session=True when setsid is not available."""
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
mock_popen = MagicMock()
|
||||
|
||||
def which_no_setsid(x):
|
||||
if x == "hermes":
|
||||
return "/usr/bin/hermes"
|
||||
if x == "setsid":
|
||||
return None
|
||||
return None
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", side_effect=which_no_setsid), \
|
||||
patch("subprocess.Popen", mock_popen):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
# Verify plain bash -c fallback (no nohup, no setsid)
|
||||
call_args = mock_popen.call_args[0][0]
|
||||
assert call_args[0] == "bash"
|
||||
assert "nohup" not in call_args[2]
|
||||
assert ".update_exit_code" in call_args[2]
|
||||
# start_new_session=True should be in kwargs
|
||||
call_kwargs = mock_popen.call_args[1]
|
||||
assert call_kwargs.get("start_new_session") is True
|
||||
assert "Starting Hermes update" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_popen_failure_cleans_up(self, tmp_path):
|
||||
"""Cleans up pending file and returns error on Popen failure."""
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
|
||||
patch("subprocess.Popen", side_effect=OSError("spawn failed")):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
assert "Failed to start update" in result
|
||||
# Pending file should be cleaned up
|
||||
assert not (hermes_home / ".update_pending.json").exists()
|
||||
assert not (hermes_home / ".update_exit_code").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_user_friendly_message(self, tmp_path):
|
||||
"""The success response is user-friendly."""
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
|
||||
patch("subprocess.Popen"):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
assert "stream progress" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send_update_notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSendUpdateNotification:
|
||||
"""Tests for GatewayRunner._send_update_notification."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_pending_file_is_noop(self, tmp_path):
|
||||
"""Does nothing when no pending file exists."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
# Should not raise
|
||||
await runner._send_update_notification()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defers_notification_while_update_still_running(self, tmp_path):
|
||||
"""Returns False and keeps marker files when the update has not exited yet."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending_path = hermes_home / ".update_pending.json"
|
||||
pending_path.write_text(json.dumps({
|
||||
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
|
||||
}))
|
||||
(hermes_home / ".update_output.txt").write_text("still running")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
result = await runner._send_update_notification()
|
||||
|
||||
assert result is False
|
||||
mock_adapter.send.assert_not_called()
|
||||
assert pending_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovers_from_claimed_pending_file(self, tmp_path):
|
||||
"""A claimed pending file from a crashed notifier is still deliverable."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
claimed_path = hermes_home / ".update_pending.claimed.json"
|
||||
claimed_path.write_text(json.dumps({
|
||||
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
|
||||
}))
|
||||
(hermes_home / ".update_output.txt").write_text("done")
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
result = await runner._send_update_notification()
|
||||
|
||||
assert result is True
|
||||
mock_adapter.send.assert_called_once()
|
||||
assert not claimed_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_notification_with_output(self, tmp_path):
|
||||
"""Sends update output to the correct platform and chat."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
# Write pending marker
|
||||
pending = {
|
||||
"platform": "telegram",
|
||||
"chat_id": "67890",
|
||||
"user_id": "12345",
|
||||
"timestamp": "2026-03-04T21:00:00",
|
||||
}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text(
|
||||
"→ Found 3 new commit(s)\n✓ Code updated!\n✓ Update complete!"
|
||||
)
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
# Mock the adapter
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
mock_adapter.send.assert_called_once()
|
||||
call_args = mock_adapter.send.call_args
|
||||
assert call_args[0][0] == "67890" # chat_id
|
||||
assert "Update complete" in call_args[0][1] or "update finished" in call_args[0][1].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_notification_with_thread_metadata(self, tmp_path):
|
||||
"""Final update notification preserves thread metadata when present."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {
|
||||
"platform": "telegram",
|
||||
"chat_id": "67890",
|
||||
"thread_id": "777",
|
||||
"user_id": "12345",
|
||||
}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text("done")
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
assert mock_adapter.send.call_args.kwargs["metadata"] == {"thread_id": "777"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_ansi_codes(self, tmp_path):
|
||||
"""ANSI escape codes are removed from output."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text(
|
||||
"\x1b[32m✓ Code updated!\x1b[0m\n\x1b[1mDone\x1b[0m"
|
||||
)
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
sent_text = mock_adapter.send.call_args[0][1]
|
||||
assert "\x1b[" not in sent_text
|
||||
assert "Code updated" in sent_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncates_long_output(self, tmp_path):
|
||||
"""Output longer than 3500 chars is truncated."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text("x" * 5000)
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
sent_text = mock_adapter.send.call_args[0][1]
|
||||
# Should start with truncation marker
|
||||
assert "…" in sent_text
|
||||
# Total message should not be absurdly long
|
||||
assert len(sent_text) < 4500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_failure_message_when_update_fails(self, tmp_path):
|
||||
"""Non-zero exit codes produce a failure notification with captured output."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text("Traceback: boom")
|
||||
(hermes_home / ".update_exit_code").write_text("1")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
result = await runner._send_update_notification()
|
||||
|
||||
assert result is True
|
||||
sent_text = mock_adapter.send.call_args[0][1]
|
||||
assert "update failed" in sent_text.lower()
|
||||
assert "Traceback: boom" in sent_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_generic_message_when_no_output(self, tmp_path):
|
||||
"""Sends a success message even if the output file is missing."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
# No .update_output.txt created
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
sent_text = mock_adapter.send.call_args[0][1]
|
||||
assert "finished successfully" in sent_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleans_up_files_after_notification(self, tmp_path):
|
||||
"""Both marker and output files are deleted after notification."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending_path = hermes_home / ".update_pending.json"
|
||||
output_path = hermes_home / ".update_output.txt"
|
||||
exit_code_path = hermes_home / ".update_exit_code"
|
||||
pending_path.write_text(json.dumps({
|
||||
"platform": "telegram", "chat_id": "111", "user_id": "222",
|
||||
}))
|
||||
output_path.write_text("✓ Done")
|
||||
exit_code_path.write_text("0")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
assert not pending_path.exists()
|
||||
assert not output_path.exists()
|
||||
assert not exit_code_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleans_up_on_error(self, tmp_path):
|
||||
"""Files are cleaned up even if notification fails."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending_path = hermes_home / ".update_pending.json"
|
||||
output_path = hermes_home / ".update_output.txt"
|
||||
exit_code_path = hermes_home / ".update_exit_code"
|
||||
pending_path.write_text(json.dumps({
|
||||
"platform": "telegram", "chat_id": "111", "user_id": "222",
|
||||
}))
|
||||
output_path.write_text("✓ Done")
|
||||
exit_code_path.write_text("0")
|
||||
|
||||
# Adapter send raises
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send.side_effect = RuntimeError("network error")
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
# Files should still be cleaned up (finally block)
|
||||
assert not pending_path.exists()
|
||||
assert not output_path.exists()
|
||||
assert not exit_code_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_corrupt_pending_file(self, tmp_path):
|
||||
"""Gracefully handles a malformed pending JSON file."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending_path = hermes_home / ".update_pending.json"
|
||||
pending_path.write_text("{corrupt json!!")
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
# Should not raise
|
||||
await runner._send_update_notification()
|
||||
|
||||
# File should be cleaned up
|
||||
assert not pending_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_adapter_for_platform(self, tmp_path):
|
||||
"""Does not crash if the platform adapter is not connected."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
|
||||
pending_path = hermes_home / ".update_pending.json"
|
||||
output_path = hermes_home / ".update_output.txt"
|
||||
exit_code_path = hermes_home / ".update_exit_code"
|
||||
pending_path.write_text(json.dumps(pending))
|
||||
output_path.write_text("Done")
|
||||
exit_code_path.write_text("0")
|
||||
|
||||
# Only telegram adapter available, but pending says discord
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
# send should not have been called (wrong platform)
|
||||
mock_adapter.send.assert_not_called()
|
||||
# Files should still be cleaned up
|
||||
assert not pending_path.exists()
|
||||
assert not exit_code_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /update in help and known_commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateInHelp:
|
||||
"""Verify /update appears in help text and known commands set."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_in_help_output(self):
|
||||
"""The /help output includes /update."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(text="/help")
|
||||
result = await runner._handle_help_command(event)
|
||||
assert "/update" in result
|
||||
|
||||
def test_update_is_known_command(self):
|
||||
"""The /update command is in the help text (proxy for _known_commands)."""
|
||||
# _known_commands is local to _handle_message, so we verify by
|
||||
# checking the help output includes it.
|
||||
from gateway.run import GatewayRunner
|
||||
import inspect
|
||||
source = inspect.getsource(GatewayRunner._handle_message)
|
||||
assert '"update"' in source
|
||||
Reference in New Issue
Block a user