"""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