forked from Rockachopa/Timmy-time-dashboard
Test fixtures that create temporary git repos now set commit.gpgsign=false to avoid failures in environments with global commit signing configured. The permission error test is skipped when running as root since file permissions don't apply to the root user. https://claude.ai/code/session_018u1fAx2GihSGctYS64tD4H
403 lines
13 KiB
Python
403 lines
13 KiB
Python
"""Tests for Self-Edit MCP Tool.
|
|
|
|
Tests the complete self-edit workflow with mocked dependencies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from creative.tools.self_edit import (
|
|
MAX_FILES_PER_COMMIT,
|
|
MAX_RETRIES,
|
|
PROTECTED_FILES,
|
|
EditPlan,
|
|
SelfEditResult,
|
|
SelfEditTool,
|
|
register_self_edit_tool,
|
|
self_edit_tool,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_repo():
|
|
"""Create a temporary git repository."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
repo_path = Path(tmpdir)
|
|
|
|
# Initialize git
|
|
import subprocess
|
|
subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
|
|
subprocess.run(
|
|
["git", "config", "user.email", "test@test.com"],
|
|
cwd=repo_path, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.name", "Test"],
|
|
cwd=repo_path, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "commit.gpgsign", "false"],
|
|
cwd=repo_path, check=True, capture_output=True,
|
|
)
|
|
|
|
# Create src structure
|
|
src_path = repo_path / "src" / "myproject"
|
|
src_path.mkdir(parents=True)
|
|
|
|
(src_path / "__init__.py").write_text("")
|
|
(src_path / "app.py").write_text('''
|
|
"""Main application."""
|
|
|
|
def hello():
|
|
return "Hello"
|
|
''')
|
|
|
|
# Create tests
|
|
tests_path = repo_path / "tests"
|
|
tests_path.mkdir()
|
|
(tests_path / "test_app.py").write_text('''
|
|
"""Tests for app."""
|
|
from myproject.app import hello
|
|
|
|
def test_hello():
|
|
assert hello() == "Hello"
|
|
''')
|
|
|
|
# Initial commit
|
|
subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "Initial"],
|
|
cwd=repo_path, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "branch", "-M", "main"],
|
|
cwd=repo_path, check=True, capture_output=True,
|
|
)
|
|
|
|
yield repo_path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_settings():
|
|
"""Mock settings to enable self-modification."""
|
|
with patch('creative.tools.self_edit.settings') as mock_settings:
|
|
mock_settings.self_modify_enabled = True
|
|
yield mock_settings
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm():
|
|
"""Create mock LLM adapter."""
|
|
mock = AsyncMock()
|
|
mock.chat.return_value = MagicMock(
|
|
content="""APPROACH: Add error handling
|
|
FILES_TO_MODIFY: src/myproject/app.py
|
|
FILES_TO_CREATE:
|
|
TESTS_TO_ADD: tests/test_app.py
|
|
EXPLANATION: Wrap function in try/except"""
|
|
)
|
|
return mock
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditToolBasics:
|
|
"""Basic functionality tests."""
|
|
|
|
async def test_initialization(self, temp_repo):
|
|
"""Should initialize with services."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
assert tool.repo_path == temp_repo
|
|
assert tool.git is not None
|
|
assert tool.indexer is not None
|
|
assert tool.journal is not None
|
|
assert tool.reflection is not None
|
|
|
|
async def test_preflight_checks_clean_repo(self, temp_repo):
|
|
"""Should pass preflight on clean repo."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
assert await tool._preflight_checks() is True
|
|
|
|
async def test_preflight_checks_dirty_repo(self, temp_repo):
|
|
"""Should fail preflight on dirty repo."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
# Make uncommitted change
|
|
(temp_repo / "dirty.txt").write_text("dirty")
|
|
|
|
assert await tool._preflight_checks() is False
|
|
|
|
async def test_preflight_checks_wrong_branch(self, temp_repo):
|
|
"""Should fail preflight when not on main."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
# Create and checkout feature branch
|
|
import subprocess
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "feature"],
|
|
cwd=temp_repo, check=True, capture_output=True,
|
|
)
|
|
|
|
assert await tool._preflight_checks() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditToolPlanning:
|
|
"""Edit planning tests."""
|
|
|
|
async def test_plan_edit_with_llm(self, temp_repo, mock_llm):
|
|
"""Should generate plan using LLM."""
|
|
tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
|
|
await tool._ensure_indexed()
|
|
|
|
plan = await tool._plan_edit(
|
|
task_description="Add error handling",
|
|
relevant_files=["src/myproject/app.py"],
|
|
similar_attempts=[],
|
|
)
|
|
|
|
assert isinstance(plan, EditPlan)
|
|
assert plan.approach == "Add error handling"
|
|
assert "src/myproject/app.py" in plan.files_to_modify
|
|
|
|
async def test_plan_edit_without_llm(self, temp_repo):
|
|
"""Should generate fallback plan without LLM."""
|
|
tool = SelfEditTool(repo_path=temp_repo, llm_adapter=None)
|
|
await tool._ensure_indexed()
|
|
|
|
plan = await tool._plan_edit(
|
|
task_description="Add feature",
|
|
relevant_files=["src/myproject/app.py"],
|
|
similar_attempts=[],
|
|
)
|
|
|
|
assert isinstance(plan, EditPlan)
|
|
assert len(plan.files_to_modify) > 0
|
|
|
|
async def test_plan_respects_max_files(self, temp_repo, mock_llm):
|
|
"""Plan should respect MAX_FILES_PER_COMMIT."""
|
|
tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
|
|
await tool._ensure_indexed()
|
|
|
|
# Mock LLM to return many files
|
|
mock_llm.chat.return_value = MagicMock(
|
|
content="FILES_TO_MODIFY: " + ",".join([f"file{i}.py" for i in range(10)])
|
|
)
|
|
|
|
plan = await tool._plan_edit(
|
|
task_description="Test",
|
|
relevant_files=[f"file{i}.py" for i in range(10)],
|
|
similar_attempts=[],
|
|
)
|
|
|
|
assert len(plan.files_to_modify) <= MAX_FILES_PER_COMMIT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditToolValidation:
|
|
"""Safety constraint validation tests."""
|
|
|
|
async def test_validate_plan_too_many_files(self, temp_repo):
|
|
"""Should reject plan with too many files."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
plan = EditPlan(
|
|
approach="Test",
|
|
files_to_modify=[f"file{i}.py" for i in range(MAX_FILES_PER_COMMIT + 1)],
|
|
files_to_create=[],
|
|
tests_to_add=[],
|
|
explanation="Test",
|
|
)
|
|
|
|
assert tool._validate_plan(plan) is False
|
|
|
|
async def test_validate_plan_protected_file(self, temp_repo):
|
|
"""Should reject plan modifying protected files."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
plan = EditPlan(
|
|
approach="Test",
|
|
files_to_modify=["src/tools/self_edit.py"],
|
|
files_to_create=[],
|
|
tests_to_add=[],
|
|
explanation="Test",
|
|
)
|
|
|
|
assert tool._validate_plan(plan) is False
|
|
|
|
async def test_validate_plan_valid(self, temp_repo):
|
|
"""Should accept valid plan."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
plan = EditPlan(
|
|
approach="Test",
|
|
files_to_modify=["src/myproject/app.py"],
|
|
files_to_create=[],
|
|
tests_to_add=[],
|
|
explanation="Test",
|
|
)
|
|
|
|
assert tool._validate_plan(plan) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditToolExecution:
|
|
"""Edit execution tests."""
|
|
|
|
async def test_strip_code_fences(self, temp_repo):
|
|
"""Should strip markdown code fences."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
content = "```python\ndef test(): pass\n```"
|
|
result = tool._strip_code_fences(content)
|
|
|
|
assert "```" not in result
|
|
assert "def test(): pass" in result
|
|
|
|
async def test_parse_list(self, temp_repo):
|
|
"""Should parse comma-separated lists."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
assert tool._parse_list("a, b, c") == ["a", "b", "c"]
|
|
assert tool._parse_list("none") == []
|
|
assert tool._parse_list("") == []
|
|
assert tool._parse_list("N/A") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditToolIntegration:
|
|
"""Integration tests with mocked dependencies."""
|
|
|
|
async def test_successful_edit_flow(self, temp_repo, mock_llm):
|
|
"""Test complete successful edit flow."""
|
|
tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
|
|
|
|
# Mock Aider to succeed
|
|
with patch.object(tool, '_aider_available', return_value=False):
|
|
with patch.object(tool, '_execute_direct_edit') as mock_exec:
|
|
mock_exec.return_value = {
|
|
"success": True,
|
|
"test_output": "1 passed",
|
|
}
|
|
|
|
result = await tool.execute("Add error handling")
|
|
|
|
assert result.success is True
|
|
assert result.attempt_id is not None
|
|
|
|
async def test_failed_edit_with_rollback(self, temp_repo, mock_llm):
|
|
"""Test failed edit with rollback."""
|
|
tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
|
|
|
|
# Mock execution to always fail
|
|
with patch.object(tool, '_execute_edit') as mock_exec:
|
|
mock_exec.return_value = {
|
|
"success": False,
|
|
"error": "Tests failed",
|
|
"test_output": "1 failed",
|
|
}
|
|
|
|
result = await tool.execute("Add broken feature")
|
|
|
|
assert result.success is False
|
|
assert result.attempt_id is not None
|
|
assert "failed" in result.message.lower() or "retry" in result.message.lower()
|
|
|
|
async def test_preflight_failure(self, temp_repo):
|
|
"""Should fail early if preflight checks fail."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
# Make repo dirty
|
|
(temp_repo / "dirty.txt").write_text("dirty")
|
|
|
|
result = await tool.execute("Some task")
|
|
|
|
assert result.success is False
|
|
assert "pre-flight" in result.message.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditMCPRegistration:
|
|
"""MCP tool registration tests."""
|
|
|
|
async def test_register_self_edit_tool(self):
|
|
"""Should register with MCP registry."""
|
|
mock_registry = MagicMock()
|
|
mock_llm = AsyncMock()
|
|
|
|
register_self_edit_tool(mock_registry, mock_llm)
|
|
|
|
mock_registry.register.assert_called_once()
|
|
call_args = mock_registry.register.call_args
|
|
|
|
assert call_args.kwargs["name"] == "self_edit"
|
|
assert call_args.kwargs["requires_confirmation"] is True
|
|
assert "self_coding" in call_args.kwargs["category"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditGlobalTool:
|
|
"""Global tool instance tests."""
|
|
|
|
async def test_self_edit_tool_singleton(self, temp_repo):
|
|
"""Should use singleton pattern."""
|
|
from creative.tools import self_edit as self_edit_module
|
|
|
|
# Reset singleton
|
|
self_edit_module._self_edit_tool = None
|
|
|
|
# First call should initialize
|
|
with patch.object(SelfEditTool, '__init__', return_value=None) as mock_init:
|
|
mock_init.return_value = None
|
|
|
|
with patch.object(SelfEditTool, 'execute') as mock_execute:
|
|
mock_execute.return_value = SelfEditResult(
|
|
success=True,
|
|
message="Test",
|
|
)
|
|
|
|
await self_edit_tool("Test task")
|
|
|
|
mock_init.assert_called_once()
|
|
mock_execute.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSelfEditErrorHandling:
|
|
"""Error handling tests."""
|
|
|
|
async def test_exception_handling(self, temp_repo):
|
|
"""Should handle exceptions gracefully."""
|
|
tool = SelfEditTool(repo_path=temp_repo)
|
|
|
|
# Mock preflight to raise exception
|
|
with patch.object(tool, '_preflight_checks', side_effect=Exception("Unexpected")):
|
|
result = await tool.execute("Test task")
|
|
|
|
assert result.success is False
|
|
assert "exception" in result.message.lower()
|
|
|
|
async def test_llm_failure_fallback(self, temp_repo, mock_llm):
|
|
"""Should fallback when LLM fails."""
|
|
tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
|
|
await tool._ensure_indexed()
|
|
|
|
# Mock LLM to fail
|
|
mock_llm.chat.side_effect = Exception("LLM timeout")
|
|
|
|
plan = await tool._plan_edit(
|
|
task_description="Test",
|
|
relevant_files=["src/app.py"],
|
|
similar_attempts=[],
|
|
)
|
|
|
|
# Should return fallback plan
|
|
assert isinstance(plan, EditPlan)
|
|
assert len(plan.files_to_modify) > 0
|