Files
hermes-agent/tests/hermes_cli/test_plugins_cmd.py
Siddharth Balyan f3006ebef9 refactor(tests): re-architect tests + fix CI failures (#5946)
* 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
2026-04-07 17:19:07 -07:00

558 lines
20 KiB
Python

"""Tests for hermes_cli.plugins_cmd — the ``hermes plugins`` CLI subcommand."""
from __future__ import annotations
import logging
import os
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from hermes_cli.plugins_cmd import (
_copy_example_files,
_read_manifest,
_repo_name_from_url,
_resolve_git_url,
_sanitize_plugin_name,
plugins_command,
)
# ── _sanitize_plugin_name ─────────────────────────────────────────────────
class TestSanitizePluginName:
"""Reject path-traversal attempts while accepting valid names."""
def test_valid_simple_name(self, tmp_path):
target = _sanitize_plugin_name("my-plugin", tmp_path)
assert target == (tmp_path / "my-plugin").resolve()
def test_valid_name_with_hyphen_and_digits(self, tmp_path):
target = _sanitize_plugin_name("plugin-v2", tmp_path)
assert target.name == "plugin-v2"
def test_rejects_dot_dot(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("../../etc/passwd", tmp_path)
def test_rejects_single_dot_dot(self, tmp_path):
with pytest.raises(ValueError, match="must not reference the plugins directory itself"):
_sanitize_plugin_name("..", tmp_path)
def test_rejects_single_dot(self, tmp_path):
with pytest.raises(ValueError, match="must not reference the plugins directory itself"):
_sanitize_plugin_name(".", tmp_path)
def test_rejects_forward_slash(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("foo/bar", tmp_path)
def test_rejects_backslash(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("foo\\bar", tmp_path)
def test_rejects_absolute_path(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("/etc/passwd", tmp_path)
def test_rejects_empty_name(self, tmp_path):
with pytest.raises(ValueError, match="must not be empty"):
_sanitize_plugin_name("", tmp_path)
# ── _resolve_git_url ──────────────────────────────────────────────────────
class TestResolveGitUrl:
"""Shorthand and full-URL resolution."""
def test_owner_repo_shorthand(self):
url = _resolve_git_url("owner/repo")
assert url == "https://github.com/owner/repo.git"
def test_https_url_passthrough(self):
url = _resolve_git_url("https://github.com/x/y.git")
assert url == "https://github.com/x/y.git"
def test_ssh_url_passthrough(self):
url = _resolve_git_url("git@github.com:x/y.git")
assert url == "git@github.com:x/y.git"
def test_http_url_passthrough(self):
url = _resolve_git_url("http://example.com/repo.git")
assert url == "http://example.com/repo.git"
def test_file_url_passthrough(self):
url = _resolve_git_url("file:///tmp/repo")
assert url == "file:///tmp/repo"
def test_invalid_single_word_raises(self):
with pytest.raises(ValueError, match="Invalid plugin identifier"):
_resolve_git_url("justoneword")
def test_invalid_three_parts_raises(self):
with pytest.raises(ValueError, match="Invalid plugin identifier"):
_resolve_git_url("a/b/c")
# ── _repo_name_from_url ──────────────────────────────────────────────────
class TestRepoNameFromUrl:
"""Extract plugin directory name from Git URLs."""
def test_https_with_dot_git(self):
assert (
_repo_name_from_url("https://github.com/owner/my-plugin.git") == "my-plugin"
)
def test_https_without_dot_git(self):
assert _repo_name_from_url("https://github.com/owner/my-plugin") == "my-plugin"
def test_trailing_slash(self):
assert _repo_name_from_url("https://github.com/owner/repo/") == "repo"
def test_ssh_style(self):
assert _repo_name_from_url("git@github.com:owner/repo.git") == "repo"
def test_ssh_protocol(self):
assert _repo_name_from_url("ssh://git@github.com/owner/repo.git") == "repo"
# ── plugins_command dispatch ──────────────────────────────────────────────
class TestPluginsCommandDispatch:
"""Verify alias routing in plugins_command()."""
def _make_args(self, action, **extras):
args = MagicMock()
args.plugins_action = action
for k, v in extras.items():
setattr(args, k, v)
return args
@patch("hermes_cli.plugins_cmd.cmd_remove")
def test_rm_alias(self, mock_remove):
args = self._make_args("rm", name="some-plugin")
plugins_command(args)
mock_remove.assert_called_once_with("some-plugin")
@patch("hermes_cli.plugins_cmd.cmd_remove")
def test_uninstall_alias(self, mock_remove):
args = self._make_args("uninstall", name="some-plugin")
plugins_command(args)
mock_remove.assert_called_once_with("some-plugin")
@patch("hermes_cli.plugins_cmd.cmd_list")
def test_ls_alias(self, mock_list):
args = self._make_args("ls")
plugins_command(args)
mock_list.assert_called_once()
@patch("hermes_cli.plugins_cmd.cmd_toggle")
def test_none_falls_through_to_toggle(self, mock_toggle):
args = self._make_args(None)
plugins_command(args)
mock_toggle.assert_called_once()
@patch("hermes_cli.plugins_cmd.cmd_install")
def test_install_dispatches(self, mock_install):
args = self._make_args("install", identifier="owner/repo", force=False)
plugins_command(args)
mock_install.assert_called_once_with("owner/repo", force=False)
@patch("hermes_cli.plugins_cmd.cmd_update")
def test_update_dispatches(self, mock_update):
args = self._make_args("update", name="foo")
plugins_command(args)
mock_update.assert_called_once_with("foo")
@patch("hermes_cli.plugins_cmd.cmd_remove")
def test_remove_dispatches(self, mock_remove):
args = self._make_args("remove", name="bar")
plugins_command(args)
mock_remove.assert_called_once_with("bar")
# ── _read_manifest ────────────────────────────────────────────────────────
class TestReadManifest:
"""Manifest reading edge cases."""
def test_valid_yaml(self, tmp_path):
manifest = {"name": "cool-plugin", "version": "1.0.0"}
(tmp_path / "plugin.yaml").write_text(yaml.dump(manifest))
result = _read_manifest(tmp_path)
assert result["name"] == "cool-plugin"
assert result["version"] == "1.0.0"
def test_missing_file_returns_empty(self, tmp_path):
result = _read_manifest(tmp_path)
assert result == {}
def test_invalid_yaml_returns_empty_and_logs(self, tmp_path, caplog):
(tmp_path / "plugin.yaml").write_text(": : : bad yaml [[[")
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins_cmd"):
result = _read_manifest(tmp_path)
assert result == {}
assert any("Failed to read plugin.yaml" in r.message for r in caplog.records)
def test_empty_file_returns_empty(self, tmp_path):
(tmp_path / "plugin.yaml").write_text("")
result = _read_manifest(tmp_path)
assert result == {}
# ── cmd_install tests ─────────────────────────────────────────────────────────
class TestCmdInstall:
"""Test the install command."""
def test_install_requires_identifier(self):
from hermes_cli.plugins_cmd import cmd_install
import argparse
with pytest.raises(SystemExit):
cmd_install("")
@patch("hermes_cli.plugins_cmd._resolve_git_url")
def test_install_validates_identifier(self, mock_resolve):
from hermes_cli.plugins_cmd import cmd_install
mock_resolve.side_effect = ValueError("Invalid identifier")
with pytest.raises(SystemExit) as exc_info:
cmd_install("invalid")
assert exc_info.value.code == 1
@patch("hermes_cli.plugins_cmd._display_after_install")
@patch("hermes_cli.plugins_cmd.shutil.move")
@patch("hermes_cli.plugins_cmd.shutil.rmtree")
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd._read_manifest")
@patch("hermes_cli.plugins_cmd.subprocess.run")
def test_install_rejects_manifest_name_pointing_at_plugins_root(
self,
mock_run,
mock_read_manifest,
mock_plugins_dir,
mock_rmtree,
mock_move,
mock_display_after_install,
tmp_path,
):
from hermes_cli.plugins_cmd import cmd_install
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
mock_plugins_dir.return_value = plugins_dir
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
mock_read_manifest.return_value = {"name": "."}
with pytest.raises(SystemExit) as exc_info:
cmd_install("owner/repo", force=True)
assert exc_info.value.code == 1
assert plugins_dir not in [call.args[0] for call in mock_rmtree.call_args_list]
mock_move.assert_not_called()
mock_display_after_install.assert_not_called()
# ── cmd_update tests ─────────────────────────────────────────────────────────
class TestCmdUpdate:
"""Test the update command."""
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd.subprocess.run")
def test_update_git_pull_success(self, mock_run, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_update
mock_plugins_dir_val = MagicMock()
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_target = MagicMock()
mock_target.exists.return_value = True
mock_target.__truediv__ = lambda self, x: MagicMock(
exists=MagicMock(return_value=True)
)
mock_sanitize.return_value = mock_target
mock_run.return_value = MagicMock(returncode=0, stdout="Updated", stderr="")
cmd_update("test-plugin")
mock_run.assert_called_once()
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
def test_update_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_update
mock_plugins_dir_val = MagicMock()
mock_plugins_dir_val.iterdir.return_value = []
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_target = MagicMock()
mock_target.exists.return_value = False
mock_sanitize.return_value = mock_target
with pytest.raises(SystemExit) as exc_info:
cmd_update("nonexistent-plugin")
assert exc_info.value.code == 1
# ── cmd_remove tests ─────────────────────────────────────────────────────────
class TestCmdRemove:
"""Test the remove command."""
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd.shutil.rmtree")
def test_remove_deletes_plugin(self, mock_rmtree, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_remove
mock_plugins_dir.return_value = MagicMock()
mock_target = MagicMock()
mock_target.exists.return_value = True
mock_sanitize.return_value = mock_target
cmd_remove("test-plugin")
mock_rmtree.assert_called_once_with(mock_target)
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
def test_remove_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_remove
mock_plugins_dir_val = MagicMock()
mock_plugins_dir_val.iterdir.return_value = []
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_target = MagicMock()
mock_target.exists.return_value = False
mock_sanitize.return_value = mock_target
with pytest.raises(SystemExit) as exc_info:
cmd_remove("nonexistent-plugin")
assert exc_info.value.code == 1
# ── cmd_list tests ─────────────────────────────────────────────────────────
class TestCmdList:
"""Test the list command."""
@patch("hermes_cli.plugins_cmd._plugins_dir")
def test_list_empty_plugins_dir(self, mock_plugins_dir):
from hermes_cli.plugins_cmd import cmd_list
mock_plugins_dir_val = MagicMock()
mock_plugins_dir_val.iterdir.return_value = []
mock_plugins_dir.return_value = mock_plugins_dir_val
cmd_list()
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd._read_manifest")
def test_list_with_plugins(self, mock_read_manifest, mock_plugins_dir):
from hermes_cli.plugins_cmd import cmd_list
mock_plugins_dir_val = MagicMock()
mock_plugin_dir = MagicMock()
mock_plugin_dir.name = "test-plugin"
mock_plugin_dir.is_dir.return_value = True
mock_plugin_dir.__truediv__ = lambda self, x: MagicMock(
exists=MagicMock(return_value=False)
)
mock_plugins_dir_val.iterdir.return_value = [mock_plugin_dir]
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_read_manifest.return_value = {"name": "test-plugin", "version": "1.0.0"}
cmd_list()
# ── _copy_example_files tests ─────────────────────────────────────────────────
class TestCopyExampleFiles:
"""Test example file copying."""
def test_copies_example_files(self, tmp_path):
from hermes_cli.plugins_cmd import _copy_example_files
from unittest.mock import MagicMock
console = MagicMock()
# Create example file
example_file = tmp_path / "config.yaml.example"
example_file.write_text("key: value")
_copy_example_files(tmp_path, console)
# Should have created the file
assert (tmp_path / "config.yaml").exists()
console.print.assert_called()
def test_skips_existing_files(self, tmp_path):
from hermes_cli.plugins_cmd import _copy_example_files
from unittest.mock import MagicMock
console = MagicMock()
# Create both example and real file
example_file = tmp_path / "config.yaml.example"
example_file.write_text("key: value")
real_file = tmp_path / "config.yaml"
real_file.write_text("existing: true")
_copy_example_files(tmp_path, console)
# Should NOT have overwritten
assert real_file.read_text() == "existing: true"
def test_handles_copy_error_gracefully(self, tmp_path):
from hermes_cli.plugins_cmd import _copy_example_files
from unittest.mock import MagicMock, patch
console = MagicMock()
# Create example file
example_file = tmp_path / "config.yaml.example"
example_file.write_text("key: value")
# Mock shutil.copy2 to raise an error
with patch(
"hermes_cli.plugins_cmd.shutil.copy2",
side_effect=OSError("Permission denied"),
):
# Should not raise, just warn
_copy_example_files(tmp_path, console)
# Should have printed a warning
assert any("Warning" in str(c) for c in console.print.call_args_list)
class TestPromptPluginEnvVars:
"""Tests for _prompt_plugin_env_vars."""
def test_skips_when_no_requires_env(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock
console = MagicMock()
_prompt_plugin_env_vars({}, console)
console.print.assert_not_called()
def test_skips_already_set_vars(self, monkeypatch):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
with patch("hermes_cli.config.get_env_value", return_value="already-set"):
_prompt_plugin_env_vars({"requires_env": ["MY_KEY"]}, console)
# No prompt should appear — all vars are set
console.print.assert_not_called()
def test_prompts_for_missing_var_simple_format(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
manifest = {
"name": "test_plugin",
"requires_env": ["MY_API_KEY"],
}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("builtins.input", return_value="sk-test-123"), \
patch("hermes_cli.config.save_env_value") as mock_save:
_prompt_plugin_env_vars(manifest, console)
mock_save.assert_called_once_with("MY_API_KEY", "sk-test-123")
def test_prompts_for_missing_var_rich_format(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
manifest = {
"name": "langfuse_tracing",
"requires_env": [
{
"name": "LANGFUSE_PUBLIC_KEY",
"description": "Public key",
"url": "https://langfuse.com",
"secret": False,
},
],
}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("builtins.input", return_value="pk-lf-123"), \
patch("hermes_cli.config.save_env_value") as mock_save:
_prompt_plugin_env_vars(manifest, console)
mock_save.assert_called_once_with("LANGFUSE_PUBLIC_KEY", "pk-lf-123")
# Should show url hint
printed = " ".join(str(c) for c in console.print.call_args_list)
assert "langfuse.com" in printed
def test_secret_uses_getpass(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
manifest = {
"name": "test",
"requires_env": [{"name": "SECRET_KEY", "secret": True}],
}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("getpass.getpass", return_value="s3cret") as mock_gp, \
patch("hermes_cli.config.save_env_value"):
_prompt_plugin_env_vars(manifest, console)
mock_gp.assert_called_once()
def test_empty_input_skips(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
manifest = {"name": "test", "requires_env": ["OPTIONAL_VAR"]}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("builtins.input", return_value=""), \
patch("hermes_cli.config.save_env_value") as mock_save:
_prompt_plugin_env_vars(manifest, console)
mock_save.assert_not_called()
def test_keyboard_interrupt_skips_gracefully(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
manifest = {"name": "test", "requires_env": ["KEY1", "KEY2"]}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("builtins.input", side_effect=KeyboardInterrupt), \
patch("hermes_cli.config.save_env_value") as mock_save:
_prompt_plugin_env_vars(manifest, console)
# Should not crash, and not save anything
mock_save.assert_not_called()