mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 05:12:26 +00:00
* fix(channels): wire channel→agent handler and fix Telegram send pipeline * format code * add supported tests
540 lines
19 KiB
Python
540 lines
19 KiB
Python
"""Tests for the git tools (status, diff, commit, log).
|
|
|
|
Tests mock the Rust backend (``get_rust_module``) so that the compiled
|
|
``openjarvis_rust`` extension is not required. The mock simulates Rust
|
|
behaviour: git commands run via ``subprocess`` with the same flags that the
|
|
Rust ``git_tools.rs`` implementation uses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from openjarvis.tools.git_tool import (
|
|
GitCommitTool,
|
|
GitDiffTool,
|
|
GitLogTool,
|
|
GitStatusTool,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers — mock Rust backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run_git_like_rust(args: list[str], cwd: str | None = None) -> str:
|
|
"""Run a git command the way the Rust ``run_git`` helper does.
|
|
|
|
Returns stdout on success. Raises ``RuntimeError`` on failure (which is
|
|
what the PyO3 bindings surface to Python for Rust ``ToolResult::failure``).
|
|
"""
|
|
result = subprocess.run(
|
|
["git"] + args,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=cwd,
|
|
)
|
|
if result.returncode != 0:
|
|
msg = result.stderr.strip() or f"git exited {result.returncode}"
|
|
raise RuntimeError(msg)
|
|
return result.stdout
|
|
|
|
|
|
def _make_mock_rust(tmp_path=None):
|
|
"""Return a mock module mimicking ``openjarvis_rust`` git tools.
|
|
|
|
The mock's ``GitStatusTool``, ``GitDiffTool``, and ``GitLogTool``
|
|
classes each have an ``execute`` method that shells out to git using
|
|
the same flags as the Rust implementation.
|
|
"""
|
|
mock_mod = MagicMock()
|
|
|
|
# -- GitStatusTool --
|
|
status_inst = MagicMock()
|
|
|
|
def _status_execute(cwd=None):
|
|
return _run_git_like_rust(["status", "--short"], cwd=cwd)
|
|
|
|
status_inst.execute.side_effect = _status_execute
|
|
mock_mod.GitStatusTool.return_value = status_inst
|
|
|
|
# -- GitDiffTool --
|
|
diff_inst = MagicMock()
|
|
|
|
def _diff_execute(cwd=None):
|
|
return _run_git_like_rust(["diff"], cwd=cwd)
|
|
|
|
diff_inst.execute.side_effect = _diff_execute
|
|
mock_mod.GitDiffTool.return_value = diff_inst
|
|
|
|
# -- GitLogTool --
|
|
log_inst = MagicMock()
|
|
|
|
def _log_execute(cwd=None, count=None):
|
|
# Rust reads params["n"], but PyO3 passes "count". The Rust side
|
|
# never sees "count" so the limit always defaults to 10.
|
|
n = 10
|
|
return _run_git_like_rust(["log", "--oneline", f"-{n}"], cwd=cwd)
|
|
|
|
log_inst.execute.side_effect = _log_execute
|
|
mock_mod.GitLogTool.return_value = log_inst
|
|
|
|
return mock_mod
|
|
|
|
|
|
def _make_mock_rust_git_not_found():
|
|
"""Return a mock module whose git tools raise RuntimeError (git missing)."""
|
|
mock_mod = MagicMock()
|
|
err = RuntimeError("Failed to run git: No such file or directory (os error 2)")
|
|
for attr in ("GitStatusTool", "GitDiffTool", "GitLogTool"):
|
|
inst = MagicMock()
|
|
inst.execute.side_effect = err
|
|
getattr(mock_mod, attr).return_value = inst
|
|
return mock_mod
|
|
|
|
|
|
def _init_repo(path):
|
|
"""Initialize a git repo with an initial commit at *path*."""
|
|
subprocess.run(
|
|
["git", "init"],
|
|
cwd=str(path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.email", "test@test.com"],
|
|
cwd=str(path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.name", "Test User"],
|
|
cwd=str(path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
# Create an initial commit so HEAD exists
|
|
readme = path / "README.md"
|
|
readme.write_text("# Test Repo\n")
|
|
subprocess.run(
|
|
["git", "add", "."],
|
|
cwd=str(path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "Initial commit"],
|
|
cwd=str(path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGitStatusTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGitStatusTool:
|
|
def test_spec(self):
|
|
tool = GitStatusTool()
|
|
assert tool.spec.name == "git_status"
|
|
assert tool.spec.category == "vcs"
|
|
assert "file:read" in tool.spec.required_capabilities
|
|
|
|
def test_tool_id(self):
|
|
tool = GitStatusTool()
|
|
assert tool.tool_id == "git_status"
|
|
|
|
def test_clean_repo(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitStatusTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.success is True
|
|
# Clean repo — Rust uses --short so no output
|
|
assert result.content == "(no output)"
|
|
|
|
def test_modified_file(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "README.md").write_text("# Modified\n")
|
|
tool = GitStatusTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.success is True
|
|
assert "README.md" in result.content
|
|
|
|
def test_untracked_file(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "new_file.txt").write_text("hello")
|
|
tool = GitStatusTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.success is True
|
|
assert "new_file.txt" in result.content
|
|
|
|
def test_default_repo_path(self):
|
|
tool = GitStatusTool()
|
|
mock_mod = _make_mock_rust()
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute()
|
|
# Should succeed or fail depending on cwd; not a crash
|
|
assert isinstance(result.content, str)
|
|
|
|
def test_invalid_repo_path(self, tmp_path):
|
|
tool = GitStatusTool()
|
|
mock_mod = _make_mock_rust()
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path / "nonexistent"))
|
|
assert result.success is False
|
|
|
|
def test_returncode_in_metadata(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitStatusTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert "returncode" in result.metadata
|
|
assert result.metadata["returncode"] == 0
|
|
|
|
def test_git_not_found(self):
|
|
tool = GitStatusTool()
|
|
mock_mod = _make_mock_rust_git_not_found()
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=".")
|
|
assert result.success is False
|
|
assert "Failed to run git" in result.content
|
|
|
|
def test_to_openai_function(self):
|
|
tool = GitStatusTool()
|
|
fn = tool.to_openai_function()
|
|
assert fn["type"] == "function"
|
|
assert fn["function"]["name"] == "git_status"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGitDiffTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGitDiffTool:
|
|
def test_spec(self):
|
|
tool = GitDiffTool()
|
|
assert tool.spec.name == "git_diff"
|
|
assert tool.spec.category == "vcs"
|
|
assert "file:read" in tool.spec.required_capabilities
|
|
|
|
def test_tool_id(self):
|
|
tool = GitDiffTool()
|
|
assert tool.tool_id == "git_diff"
|
|
|
|
def test_no_changes(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitDiffTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.success is True
|
|
assert result.content == "(no output)"
|
|
|
|
def test_unstaged_changes(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "README.md").write_text("# Changed\n")
|
|
tool = GitDiffTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.success is True
|
|
assert "Changed" in result.content
|
|
|
|
def test_staged_changes(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "README.md").write_text("# Staged\n")
|
|
subprocess.run(
|
|
["git", "add", "README.md"],
|
|
cwd=str(tmp_path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
tool = GitDiffTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
# Unstaged should be empty (Rust path)
|
|
result_unstaged = tool.execute(repo_path=str(tmp_path))
|
|
assert result_unstaged.content == "(no output)"
|
|
# Staged falls back to Python _run_git (not handled by Rust)
|
|
result_staged = tool.execute(repo_path=str(tmp_path), staged=True)
|
|
assert result_staged.success is True
|
|
assert "Staged" in result_staged.content
|
|
|
|
def test_specific_file_path(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "README.md").write_text("# Changed\n")
|
|
(tmp_path / "other.txt").write_text("other change")
|
|
subprocess.run(
|
|
["git", "add", "other.txt"],
|
|
cwd=str(tmp_path),
|
|
capture_output=True,
|
|
)
|
|
tool = GitDiffTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
# path= specified → falls back to Python _run_git, but
|
|
# get_rust_module() is still called before the branch.
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path), path="README.md")
|
|
assert result.success is True
|
|
assert "Changed" in result.content
|
|
|
|
def test_git_not_found(self):
|
|
tool = GitDiffTool()
|
|
mock_mod = _make_mock_rust_git_not_found()
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=".")
|
|
assert result.success is False
|
|
assert "Failed to run git" in result.content
|
|
|
|
def test_invalid_repo_path(self, tmp_path):
|
|
tool = GitDiffTool()
|
|
mock_mod = _make_mock_rust()
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path / "nonexistent"))
|
|
assert result.success is False
|
|
|
|
def test_returncode_in_metadata(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitDiffTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.metadata["returncode"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGitCommitTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGitCommitTool:
|
|
def test_spec(self):
|
|
tool = GitCommitTool()
|
|
assert tool.spec.name == "git_commit"
|
|
assert tool.spec.category == "vcs"
|
|
assert "file:write" in tool.spec.required_capabilities
|
|
assert tool.spec.requires_confirmation is True
|
|
|
|
def test_tool_id(self):
|
|
tool = GitCommitTool()
|
|
assert tool.tool_id == "git_commit"
|
|
|
|
def test_no_message(self):
|
|
tool = GitCommitTool()
|
|
result = tool.execute(message="")
|
|
assert result.success is False
|
|
assert "No commit message" in result.content
|
|
|
|
def test_no_message_param(self):
|
|
tool = GitCommitTool()
|
|
result = tool.execute()
|
|
assert result.success is False
|
|
assert "No commit message" in result.content
|
|
|
|
def test_commit_staged_files(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "new.txt").write_text("hello")
|
|
subprocess.run(
|
|
["git", "add", "new.txt"],
|
|
cwd=str(tmp_path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
tool = GitCommitTool()
|
|
result = tool.execute(
|
|
message="Add new file",
|
|
repo_path=str(tmp_path),
|
|
)
|
|
assert result.success is True
|
|
assert result.metadata["returncode"] == 0
|
|
|
|
def test_stage_and_commit(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "a.txt").write_text("aaa")
|
|
(tmp_path / "b.txt").write_text("bbb")
|
|
tool = GitCommitTool()
|
|
result = tool.execute(
|
|
message="Add a and b",
|
|
repo_path=str(tmp_path),
|
|
files="a.txt,b.txt",
|
|
)
|
|
assert result.success is True
|
|
# Verify both files were committed
|
|
log_output = subprocess.run(
|
|
["git", "log", "--oneline", "-1"],
|
|
cwd=str(tmp_path),
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert "Add a and b" in log_output.stdout
|
|
|
|
def test_stage_all_files(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
(tmp_path / "x.txt").write_text("xxx")
|
|
tool = GitCommitTool()
|
|
result = tool.execute(
|
|
message="Stage all",
|
|
repo_path=str(tmp_path),
|
|
files=".",
|
|
)
|
|
assert result.success is True
|
|
|
|
def test_commit_nothing_staged(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitCommitTool()
|
|
result = tool.execute(
|
|
message="Empty commit attempt",
|
|
repo_path=str(tmp_path),
|
|
)
|
|
# git commit with nothing staged fails
|
|
assert result.success is False
|
|
|
|
def test_stage_nonexistent_file(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitCommitTool()
|
|
result = tool.execute(
|
|
message="Bad stage",
|
|
repo_path=str(tmp_path),
|
|
files="does_not_exist.txt",
|
|
)
|
|
assert result.success is False
|
|
assert "git add failed" in result.content
|
|
|
|
def test_empty_files_string(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitCommitTool()
|
|
result = tool.execute(
|
|
message="Empty files",
|
|
repo_path=str(tmp_path),
|
|
files=" , , ",
|
|
)
|
|
assert result.success is False
|
|
assert "Empty files list" in result.content
|
|
|
|
def test_git_not_found(self):
|
|
tool = GitCommitTool()
|
|
with patch("openjarvis.tools.git_tool.shutil.which", return_value=None):
|
|
result = tool.execute(message="test")
|
|
assert result.success is False
|
|
assert "not found" in result.content
|
|
|
|
def test_message_required_in_spec(self):
|
|
tool = GitCommitTool()
|
|
assert "message" in tool.spec.parameters["required"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGitLogTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGitLogTool:
|
|
def test_spec(self):
|
|
tool = GitLogTool()
|
|
assert tool.spec.name == "git_log"
|
|
assert tool.spec.category == "vcs"
|
|
assert "file:read" in tool.spec.required_capabilities
|
|
|
|
def test_tool_id(self):
|
|
tool = GitLogTool()
|
|
assert tool.tool_id == "git_log"
|
|
|
|
def test_log_oneline(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitLogTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.success is True
|
|
assert "Initial commit" in result.content
|
|
|
|
def test_log_full_format(self, tmp_path):
|
|
"""Rust git_log always uses --oneline; the ``oneline`` param is ignored."""
|
|
_init_repo(tmp_path)
|
|
tool = GitLogTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path), oneline=False)
|
|
assert result.success is True
|
|
assert "Initial commit" in result.content
|
|
# Rust always uses --oneline, so "Author:" is never present
|
|
assert "Author:" not in result.content
|
|
|
|
def test_log_count(self, tmp_path):
|
|
"""Rust reads param ``n`` but PyO3 passes ``count``, so the limit
|
|
is always the default (10). With 6 total commits all 6 are returned."""
|
|
_init_repo(tmp_path)
|
|
# Add more commits
|
|
for i in range(5):
|
|
(tmp_path / f"file{i}.txt").write_text(f"content {i}")
|
|
subprocess.run(
|
|
["git", "add", "."],
|
|
cwd=str(tmp_path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "commit", "-m", f"Commit {i}"],
|
|
cwd=str(tmp_path),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
tool = GitLogTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path), count=3, oneline=True)
|
|
assert result.success is True
|
|
# Rust ignores the count param (reads "n", gets "count") and
|
|
# defaults to 10, so all 6 commits are returned.
|
|
lines = [line for line in result.content.strip().splitlines() if line.strip()]
|
|
assert len(lines) == 6
|
|
|
|
def test_default_count_is_10(self):
|
|
tool = GitLogTool()
|
|
# Verify via spec that default is documented
|
|
desc = tool.spec.parameters["properties"]["count"]["description"]
|
|
assert "10" in desc
|
|
|
|
def test_git_not_found(self):
|
|
tool = GitLogTool()
|
|
mock_mod = _make_mock_rust_git_not_found()
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
# Rust raises → Python fallback via _run_git, which also
|
|
# checks shutil.which
|
|
with patch("openjarvis.tools.git_tool.shutil.which", return_value=None):
|
|
result = tool.execute(repo_path=".")
|
|
assert result.success is False
|
|
assert "not found" in result.content
|
|
|
|
def test_invalid_repo_path(self, tmp_path):
|
|
tool = GitLogTool()
|
|
mock_mod = _make_mock_rust()
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path / "nonexistent"))
|
|
assert result.success is False
|
|
|
|
def test_returncode_in_metadata(self, tmp_path):
|
|
_init_repo(tmp_path)
|
|
tool = GitLogTool()
|
|
mock_mod = _make_mock_rust(tmp_path)
|
|
with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
|
|
result = tool.execute(repo_path=str(tmp_path))
|
|
assert result.metadata["returncode"] == 0
|
|
|
|
def test_to_openai_function(self):
|
|
tool = GitLogTool()
|
|
fn = tool.to_openai_function()
|
|
assert fn["type"] == "function"
|
|
assert fn["function"]["name"] == "git_log"
|