first commit
This commit is contained in:
279
tests/test_dev_agent.py
Normal file
279
tests/test_dev_agent.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Tests for DevAgentManager."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
|
||||
import pexpect
|
||||
import pytest
|
||||
|
||||
from app_factory.agents.dev_agent import DevAgentManager, PROMPT_TEMPLATE_PATH
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(mock_docker_client):
|
||||
return DevAgentManager(docker_client=mock_docker_client, max_retries=3, timeout=60)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_task():
|
||||
return {
|
||||
"task_id": "42",
|
||||
"title": "Implement login endpoint",
|
||||
"description": "Create a POST /login endpoint with JWT",
|
||||
"details": "Use bcrypt for password hashing, return JWT token",
|
||||
"testStrategy": "Unit test auth flow, integration test endpoint",
|
||||
}
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_init_with_client(self, mock_docker_client):
|
||||
mgr = DevAgentManager(docker_client=mock_docker_client, max_retries=5, timeout=900)
|
||||
assert mgr.docker_client is mock_docker_client
|
||||
assert mgr.max_retries == 5
|
||||
assert mgr.timeout == 900
|
||||
assert mgr._retry_counts == {}
|
||||
|
||||
def test_init_defaults(self, mock_docker_client):
|
||||
mgr = DevAgentManager(docker_client=mock_docker_client)
|
||||
assert mgr.max_retries == 3
|
||||
assert mgr.timeout == 1800
|
||||
|
||||
def test_init_creates_docker_client_from_env(self):
|
||||
mock_client = MagicMock()
|
||||
mock_docker = MagicMock()
|
||||
mock_docker.from_env.return_value = mock_client
|
||||
with patch.dict("sys.modules", {"docker": mock_docker}):
|
||||
mgr = DevAgentManager()
|
||||
assert mgr.docker_client is mock_client
|
||||
|
||||
|
||||
class TestPrepareTaskPrompt:
|
||||
def test_includes_all_fields(self, manager, sample_task):
|
||||
prompt = manager.prepare_task_prompt(sample_task, global_arch="Microservice arch")
|
||||
assert "42" in prompt
|
||||
assert "Implement login endpoint" in prompt
|
||||
assert "Create a POST /login endpoint with JWT" in prompt
|
||||
assert "Use bcrypt for password hashing" in prompt
|
||||
assert "Unit test auth flow" in prompt
|
||||
assert "Microservice arch" in prompt
|
||||
|
||||
def test_without_global_arch(self, manager, sample_task):
|
||||
prompt = manager.prepare_task_prompt(sample_task)
|
||||
assert "No architecture context provided." in prompt
|
||||
assert "42" in prompt
|
||||
assert "Implement login endpoint" in prompt
|
||||
|
||||
def test_with_empty_global_arch(self, manager, sample_task):
|
||||
prompt = manager.prepare_task_prompt(sample_task, global_arch="")
|
||||
assert "No architecture context provided." in prompt
|
||||
|
||||
def test_uses_id_fallback(self, manager):
|
||||
task = {"id": "99", "title": "Fallback task", "description": "desc"}
|
||||
prompt = manager.prepare_task_prompt(task)
|
||||
assert "99" in prompt
|
||||
|
||||
def test_template_file_exists(self):
|
||||
assert PROMPT_TEMPLATE_PATH.exists(), f"Template not found at {PROMPT_TEMPLATE_PATH}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestExecuteTask:
|
||||
async def test_success_path(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.before = "Created src/auth.py\nModified tests/test_auth.py\n2 passed"
|
||||
mock_child.exitstatus = 0
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child):
|
||||
result = await manager.execute_task(sample_task, "container123", worktree)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["exit_code"] == 0
|
||||
assert "src/auth.py" in result["files_changed"]
|
||||
assert isinstance(result["output"], str)
|
||||
|
||||
async def test_failure_path(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.before = "Error: compilation failed\n1 failed"
|
||||
mock_child.exitstatus = 1
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child):
|
||||
result = await manager.execute_task(sample_task, "container123", worktree)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["exit_code"] == 1
|
||||
|
||||
async def test_timeout(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.expect.side_effect = pexpect.TIMEOUT("timed out")
|
||||
mock_child.close.return_value = None
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child):
|
||||
result = await manager.execute_task(sample_task, "container123", worktree)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["output"] == "timeout"
|
||||
assert result["exit_code"] == -1
|
||||
assert result["files_changed"] == []
|
||||
|
||||
async def test_writes_prompt_file(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.before = "done"
|
||||
mock_child.exitstatus = 0
|
||||
|
||||
written_content = None
|
||||
|
||||
original_open = open
|
||||
|
||||
def capturing_open(path, *args, **kwargs):
|
||||
nonlocal written_content
|
||||
if str(path).endswith(".task_prompt.txt") and "w" in (args[0] if args else ""):
|
||||
result = original_open(path, *args, **kwargs)
|
||||
# We'll check the file exists during execution
|
||||
return result
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child):
|
||||
await manager.execute_task(sample_task, "cid", worktree)
|
||||
|
||||
# Prompt file should be cleaned up after execution
|
||||
prompt_path = os.path.join(worktree, ".task_prompt.txt")
|
||||
assert not os.path.exists(prompt_path), "Prompt file should be cleaned up"
|
||||
|
||||
async def test_spawns_correct_command(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.before = ""
|
||||
mock_child.exitstatus = 0
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child) as mock_spawn:
|
||||
await manager.execute_task(sample_task, "abc123", worktree)
|
||||
|
||||
mock_spawn.assert_called_once_with(
|
||||
"docker exec abc123 claude --print --prompt-file /workspace/.task_prompt.txt",
|
||||
timeout=60,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def test_none_exitstatus_treated_as_error(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.before = "output"
|
||||
mock_child.exitstatus = None
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child):
|
||||
result = await manager.execute_task(sample_task, "cid", worktree)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["exit_code"] == -1
|
||||
|
||||
|
||||
class TestParseClaudeOutput:
|
||||
def test_extracts_files_changed(self, manager):
|
||||
output = "Created src/auth.py\nModified tests/test_auth.py\nUpdated config.json"
|
||||
result = manager.parse_claude_output(output)
|
||||
assert "src/auth.py" in result["files_changed"]
|
||||
assert "tests/test_auth.py" in result["files_changed"]
|
||||
assert "config.json" in result["files_changed"]
|
||||
|
||||
def test_extracts_test_results(self, manager):
|
||||
output = "Results: 5 passed, 2 failed"
|
||||
result = manager.parse_claude_output(output)
|
||||
assert result["test_results"]["passed"] == 5
|
||||
assert result["test_results"]["failed"] == 2
|
||||
|
||||
def test_extracts_errors(self, manager):
|
||||
output = "Error: could not import module\nFAILED: assertion mismatch"
|
||||
result = manager.parse_claude_output(output)
|
||||
assert len(result["errors"]) >= 1
|
||||
|
||||
def test_empty_output(self, manager):
|
||||
result = manager.parse_claude_output("")
|
||||
assert result["files_changed"] == []
|
||||
assert result["test_results"] == {}
|
||||
assert result["errors"] == []
|
||||
|
||||
def test_no_test_results(self, manager):
|
||||
output = "Created app.py\nDone."
|
||||
result = manager.parse_claude_output(output)
|
||||
assert result["test_results"] == {}
|
||||
|
||||
def test_deduplicates_files(self, manager):
|
||||
output = "Created app.py\nEditing app.py"
|
||||
result = manager.parse_claude_output(output)
|
||||
assert result["files_changed"].count("app.py") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestExecuteWithRetry:
|
||||
async def test_succeeds_on_first_try(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.before = "All good\n3 passed"
|
||||
mock_child.exitstatus = 0
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child):
|
||||
result = await manager.execute_with_retry(sample_task, "cid", worktree)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert manager.get_retry_count("42") == 1
|
||||
|
||||
async def test_succeeds_after_failures(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
|
||||
# First two calls fail, third succeeds
|
||||
fail_child = MagicMock()
|
||||
fail_child.before = "Error: build failed"
|
||||
fail_child.exitstatus = 1
|
||||
|
||||
success_child = MagicMock()
|
||||
success_child.before = "All good"
|
||||
success_child.exitstatus = 0
|
||||
|
||||
with patch(
|
||||
"app_factory.agents.dev_agent.pexpect.spawn",
|
||||
side_effect=[fail_child, fail_child, success_child],
|
||||
):
|
||||
result = await manager.execute_with_retry(sample_task, "cid", worktree)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert manager.get_retry_count("42") == 3
|
||||
|
||||
async def test_max_retries_exceeded(self, manager, sample_task, tmp_path):
|
||||
worktree = str(tmp_path)
|
||||
mock_child = MagicMock()
|
||||
mock_child.before = "Error: persistent failure"
|
||||
mock_child.exitstatus = 1
|
||||
|
||||
with patch("app_factory.agents.dev_agent.pexpect.spawn", return_value=mock_child):
|
||||
result = await manager.execute_with_retry(sample_task, "cid", worktree)
|
||||
|
||||
assert result["status"] == "needs_clarification"
|
||||
assert manager.get_retry_count("42") == 3
|
||||
|
||||
|
||||
class TestRetryCounters:
|
||||
def test_get_retry_count_default(self, manager):
|
||||
assert manager.get_retry_count("unknown") == 0
|
||||
|
||||
def test_get_retry_count_after_set(self, manager):
|
||||
manager._retry_counts["task-1"] = 2
|
||||
assert manager.get_retry_count("task-1") == 2
|
||||
|
||||
def test_reset_retry_count(self, manager):
|
||||
manager._retry_counts["task-1"] = 3
|
||||
manager.reset_retry_count("task-1")
|
||||
assert manager.get_retry_count("task-1") == 0
|
||||
|
||||
def test_reset_nonexistent_task(self, manager):
|
||||
# Should not raise
|
||||
manager.reset_retry_count("nonexistent")
|
||||
assert manager.get_retry_count("nonexistent") == 0
|
||||
Reference in New Issue
Block a user