353 lines
13 KiB
Python
353 lines
13 KiB
Python
import io
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
|
|
PLUGIN_PATH = Path(__file__).resolve().parents[1] / "rtk-rewrite" / "__init__.py"
|
|
|
|
|
|
class FakeContext:
|
|
def __init__(self):
|
|
self.hooks = {}
|
|
|
|
def register_hook(self, hook_name, callback):
|
|
self.hooks[hook_name] = callback
|
|
|
|
|
|
class FakeCompletedProcess:
|
|
def __init__(self, returncode=0, stdout="", stderr=""):
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
|
|
|
|
def load_plugin_module(path=PLUGIN_PATH, module_name="rtk_rewrite_plugin"):
|
|
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError(f"Unable to load Hermes plugin from {path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def write_fake_rtk(bin_dir):
|
|
fake_rtk = bin_dir / "rtk"
|
|
fake_rtk.write_text(
|
|
"\n".join(
|
|
[
|
|
f"#!{sys.executable}",
|
|
"import sys",
|
|
"if sys.argv[1:] == ['rewrite', 'git status']:",
|
|
" print('rtk git status')",
|
|
" raise SystemExit(0)",
|
|
"print('unexpected rtk args:', sys.argv[1:], file=sys.stderr)",
|
|
"raise SystemExit(1)",
|
|
"",
|
|
]
|
|
)
|
|
)
|
|
fake_rtk.chmod(fake_rtk.stat().st_mode | stat.S_IXUSR)
|
|
return fake_rtk
|
|
|
|
|
|
class RtkRewritePluginTest(unittest.TestCase):
|
|
def load_callback(self):
|
|
module = load_plugin_module()
|
|
module._rtk_available = None
|
|
module._rtk_missing_warned = False
|
|
ctx = FakeContext()
|
|
|
|
with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"):
|
|
module.register(ctx)
|
|
|
|
self.assertIn("pre_tool_call", ctx.hooks)
|
|
return module, ctx.hooks["pre_tool_call"]
|
|
|
|
def test_missing_rtk_skips_registering_pre_tool_call(self):
|
|
module = load_plugin_module()
|
|
module._rtk_available = None
|
|
module._rtk_missing_warned = False
|
|
ctx = FakeContext()
|
|
|
|
with mock.patch.object(module.shutil, "which", return_value=None):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
module.register(ctx)
|
|
|
|
self.assertNotIn("pre_tool_call", ctx.hooks)
|
|
self.assertEqual(
|
|
"rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n",
|
|
stderr.getvalue(),
|
|
)
|
|
|
|
def test_missing_rtk_warns_only_once(self):
|
|
module = load_plugin_module()
|
|
module._rtk_available = None
|
|
module._rtk_missing_warned = False
|
|
|
|
with mock.patch.object(module.shutil, "which", return_value=None):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
self.assertFalse(module._check_rtk())
|
|
self.assertFalse(module._check_rtk())
|
|
|
|
self.assertEqual(
|
|
"rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n",
|
|
stderr.getvalue(),
|
|
)
|
|
|
|
def test_check_rtk_found_is_quiet(self):
|
|
module = load_plugin_module()
|
|
module._rtk_available = None
|
|
module._rtk_missing_warned = False
|
|
|
|
with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
self.assertTrue(module._check_rtk())
|
|
|
|
self.assertEqual("", stderr.getvalue())
|
|
|
|
def test_check_rtk_caches_result_across_calls(self):
|
|
module = load_plugin_module()
|
|
module._rtk_available = None
|
|
module._rtk_missing_warned = False
|
|
|
|
with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk") as which:
|
|
self.assertTrue(module._check_rtk())
|
|
self.assertTrue(module._check_rtk())
|
|
|
|
which.assert_called_once_with("rtk")
|
|
|
|
def test_rewrite_success_mutates_same_terminal_args_dict(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(
|
|
module.subprocess,
|
|
"run",
|
|
return_value=FakeCompletedProcess(stdout="rtk git status\n"),
|
|
):
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "rtk git status"}, args)
|
|
|
|
def test_rewrite_returncode_three_mutates_same_terminal_args_dict(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(
|
|
module.subprocess,
|
|
"run",
|
|
return_value=FakeCompletedProcess(returncode=3, stdout="rtk git status\n"),
|
|
):
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "rtk git status"}, args)
|
|
|
|
def test_rewrite_returncode_zero_mutates_when_rewrite_changes_command(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(
|
|
module.subprocess,
|
|
"run",
|
|
return_value=FakeCompletedProcess(stdout="rtk git status\n"),
|
|
):
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "rtk git status"}, args)
|
|
|
|
def test_expected_passthrough_returncodes_do_not_warn_or_mutate(self):
|
|
for returncode in (1, 2):
|
|
with self.subTest(returncode=returncode):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(
|
|
module.subprocess,
|
|
"run",
|
|
return_value=FakeCompletedProcess(
|
|
returncode=returncode,
|
|
stdout="rtk git status\n",
|
|
stderr="unexpected stderr",
|
|
),
|
|
):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "git status"}, args)
|
|
self.assertEqual("", stderr.getvalue())
|
|
|
|
def test_unexpected_returncode_warns_with_stderr_details(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(
|
|
module.subprocess,
|
|
"run",
|
|
return_value=FakeCompletedProcess(returncode=4, stdout="rtk git status\n", stderr="bad news"),
|
|
):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "git status"}, args)
|
|
self.assertEqual("rtk: hermes plugin warning: rtk rewrite failed with exit 4: bad news\n", stderr.getvalue())
|
|
|
|
def test_rewrite_timeout_warns_and_preserves_original_command(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
timeout = subprocess.TimeoutExpired(cmd=["rtk", "rewrite", "git status"], timeout=2)
|
|
with mock.patch.object(module.subprocess, "run", side_effect=timeout):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "git status"}, args)
|
|
self.assertEqual("rtk: hermes plugin warning: rtk rewrite timed out\n", stderr.getvalue())
|
|
|
|
def test_file_not_found_preserves_original_command(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(module.subprocess, "run", side_effect=FileNotFoundError):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "git status"}, args)
|
|
self.assertIn("rtk: hermes plugin warning:", stderr.getvalue())
|
|
|
|
def test_unexpected_exception_prints_warning_and_keeps_command(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(module.subprocess, "run", side_effect=RuntimeError("boom")):
|
|
with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "git status"}, args)
|
|
self.assertEqual("rtk: hermes plugin warning: boom\n", stderr.getvalue())
|
|
|
|
def test_non_terminal_tool_is_noop(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(module.subprocess, "run") as run:
|
|
callback(tool_name="read_file", args=args)
|
|
|
|
run.assert_not_called()
|
|
self.assertEqual({"command": "git status"}, args)
|
|
|
|
def test_missing_command_is_noop(self):
|
|
module, callback = self.load_callback()
|
|
args = {}
|
|
|
|
with mock.patch.object(module.subprocess, "run") as run:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
run.assert_not_called()
|
|
self.assertEqual({}, args)
|
|
|
|
def test_non_string_command_is_noop(self):
|
|
module, callback = self.load_callback()
|
|
args = {"command": ["git", "status"]}
|
|
|
|
with mock.patch.object(module.subprocess, "run") as run:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
run.assert_not_called()
|
|
self.assertEqual({"command": ["git", "status"]}, args)
|
|
|
|
def test_empty_command_strings_are_noop(self):
|
|
for command in ("", " ", "\t\n"):
|
|
with self.subTest(command=command):
|
|
module, callback = self.load_callback()
|
|
args = {"command": command}
|
|
|
|
with mock.patch.object(module.subprocess, "run") as run:
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
run.assert_not_called()
|
|
self.assertEqual({"command": command}, args)
|
|
|
|
def test_empty_or_unchanged_rewrite_output_preserves_original_command(self):
|
|
for stdout in ("", "\n", "git status\n"):
|
|
with self.subTest(stdout=stdout):
|
|
module, callback = self.load_callback()
|
|
args = {"command": "git status"}
|
|
|
|
with mock.patch.object(
|
|
module.subprocess,
|
|
"run",
|
|
return_value=FakeCompletedProcess(stdout=stdout),
|
|
):
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "git status"}, args)
|
|
|
|
|
|
class InstalledRtkRewritePluginTest(unittest.TestCase):
|
|
@unittest.skipUnless(shutil.which("cargo"), "cargo is required for installed flow")
|
|
def test_cargo_init_installs_importable_plugin_that_rewrites_with_fake_rtk(self):
|
|
repo_root = Path(__file__).resolve().parents[3]
|
|
self.assertTrue((repo_root / "Cargo.toml").exists(), "repo_root must point at the repository root")
|
|
real_home = Path(os.path.expanduser("~"))
|
|
|
|
with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as bin_dir:
|
|
home_path = Path(home)
|
|
fake_bin = Path(bin_dir)
|
|
write_fake_rtk(fake_bin)
|
|
|
|
env = os.environ.copy()
|
|
env["HOME"] = str(home_path)
|
|
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
|
|
env["RTK_TELEMETRY_DISABLED"] = "1"
|
|
env["CARGO_TERM_COLOR"] = "never"
|
|
env.setdefault("RUSTUP_TOOLCHAIN", "stable")
|
|
if "RUSTUP_HOME" not in env and (real_home / ".rustup").exists():
|
|
env["RUSTUP_HOME"] = str(real_home / ".rustup")
|
|
if "CARGO_HOME" not in env and (real_home / ".cargo").exists():
|
|
env["CARGO_HOME"] = str(real_home / ".cargo")
|
|
env.pop("CLAUDE_CONFIG_DIR", None)
|
|
|
|
result = subprocess.run(
|
|
["cargo", "run", "--quiet", "--", "init", "--agent", "hermes"],
|
|
cwd=repo_root,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
plugin_dir = home_path / ".hermes" / "plugins" / "rtk-rewrite"
|
|
init_path = plugin_dir / "__init__.py"
|
|
manifest_path = plugin_dir / "plugin.yaml"
|
|
self.assertTrue(init_path.exists(), "installed plugin __init__.py must exist")
|
|
self.assertTrue(manifest_path.exists(), "installed plugin.yaml must exist")
|
|
|
|
module = load_plugin_module(init_path, "installed_rtk_rewrite_plugin")
|
|
ctx = FakeContext()
|
|
with mock.patch.dict(os.environ, {"PATH": env["PATH"]}):
|
|
module.register(ctx)
|
|
callback = ctx.hooks["pre_tool_call"]
|
|
|
|
args = {"command": "git status"}
|
|
callback(tool_name="terminal", args=args)
|
|
|
|
self.assertEqual({"command": "rtk git status"}, args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|