"""Tests for AppFactoryOrchestrator (LangGraph state machine).""" import asyncio import json import os import tempfile from unittest.mock import AsyncMock, MagicMock, patch import pytest from app_factory.core.graph import AppFactoryOrchestrator, AppFactoryState # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def mock_pm_agent(): agent = AsyncMock() agent.expand_prompt_to_prd = AsyncMock(return_value="# Generated PRD\n\nObjective: Build an app") agent.handle_clarification_request = AsyncMock(return_value="Clarification: use REST API") return agent @pytest.fixture def mock_task_agent(): agent = AsyncMock() agent.parse_prd = AsyncMock(return_value={"tasks": []}) agent.get_unblocked_tasks = AsyncMock(return_value=[ {"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}, {"id": 2, "title": "Task 2", "status": "pending", "dependencies": []}, ]) agent.update_task_status = AsyncMock() agent.get_task_details = AsyncMock(return_value={"id": 1, "title": "Task 1"}) return agent @pytest.fixture def mock_dev_manager(): manager = AsyncMock() manager.execute_with_retry = AsyncMock(return_value={ "status": "success", "output": "Task completed", "files_changed": ["app.py"], "exit_code": 0, }) return manager @pytest.fixture def mock_qa_agent(): agent = AsyncMock() agent.max_retries = 3 agent.review_and_merge = AsyncMock(return_value={ "status": "merged", "commit_sha": "abc123", "review_summary": "LGTM", }) return agent @pytest.fixture def mock_workspace_manager(): manager = AsyncMock() manager.create_worktree = AsyncMock(return_value="/tmp/worktree/task-1") manager.spin_up_clean_room = AsyncMock(return_value=MagicMock(id="container-123")) manager.cleanup_workspace = AsyncMock() return manager @pytest.fixture def mock_observability(): obs = MagicMock() obs.log_state_transition = MagicMock() obs.log_error = MagicMock() return obs @pytest.fixture def orchestrator(mock_pm_agent, mock_task_agent, mock_dev_manager, mock_qa_agent, mock_workspace_manager, mock_observability): return AppFactoryOrchestrator( pm_agent=mock_pm_agent, task_agent=mock_task_agent, dev_manager=mock_dev_manager, qa_agent=mock_qa_agent, workspace_manager=mock_workspace_manager, observability=mock_observability, ) # --------------------------------------------------------------------------- # State Schema Tests # --------------------------------------------------------------------------- class TestStateSchema: def test_state_has_all_required_fields(self): required_fields = { "user_input", "prd", "tasks", "active_tasks", "completed_tasks", "blocked_tasks", "clarification_requests", "global_architecture", "iteration_count", "max_iterations", "errors", } assert required_fields == set(AppFactoryState.__annotations__.keys()) def test_state_can_be_instantiated(self): state: AppFactoryState = { "user_input": "build a todo app", "prd": "", "tasks": [], "active_tasks": {}, "completed_tasks": [], "blocked_tasks": {}, "clarification_requests": [], "global_architecture": "", "iteration_count": 0, "max_iterations": 50, "errors": [], } assert state["user_input"] == "build a todo app" assert state["max_iterations"] == 50 # --------------------------------------------------------------------------- # Graph Construction Tests # --------------------------------------------------------------------------- class TestGraphBuild: def test_graph_builds_without_errors(self): orch = AppFactoryOrchestrator() compiled = orch.build_graph() assert compiled is not None def test_graph_builds_with_all_agents(self, orchestrator): compiled = orchestrator.build_graph() assert compiled is not None def test_graph_builds_with_none_agents(self): orch = AppFactoryOrchestrator( pm_agent=None, task_agent=None, dev_manager=None, qa_agent=None, workspace_manager=None, observability=None, ) compiled = orch.build_graph() assert compiled is not None # --------------------------------------------------------------------------- # Node Tests # --------------------------------------------------------------------------- class TestPMNode: @pytest.mark.asyncio async def test_pm_node_sets_prd(self, orchestrator, mock_pm_agent): state = {"user_input": "build a todo app", "errors": []} result = await orchestrator._pm_node(state) assert "prd" in result assert result["prd"] == "# Generated PRD\n\nObjective: Build an app" mock_pm_agent.expand_prompt_to_prd.assert_called_once_with("build a todo app") @pytest.mark.asyncio async def test_pm_node_handles_no_input(self, orchestrator): state = {"user_input": "", "errors": []} result = await orchestrator._pm_node(state) assert result["prd"] == "" assert any("No user input" in e for e in result["errors"]) @pytest.mark.asyncio async def test_pm_node_handles_agent_error(self, orchestrator, mock_pm_agent): mock_pm_agent.expand_prompt_to_prd.side_effect = RuntimeError("API down") state = {"user_input": "build app", "errors": []} result = await orchestrator._pm_node(state) assert result["prd"] == "" assert any("PM agent error" in e for e in result["errors"]) @pytest.mark.asyncio async def test_pm_node_without_agent(self): orch = AppFactoryOrchestrator() state = {"user_input": "build a todo app", "errors": []} result = await orch._pm_node(state) assert "Mock PRD" in result["prd"] class TestTaskNode: @pytest.mark.asyncio async def test_task_node_populates_tasks(self, orchestrator, mock_task_agent): state = {"prd": "some PRD", "tasks": [], "iteration_count": 0, "max_iterations": 50, "errors": []} result = await orchestrator._task_node(state) assert "tasks" in result assert len(result["tasks"]) == 2 mock_task_agent.parse_prd.assert_called_once() @pytest.mark.asyncio async def test_task_node_increments_iteration(self, orchestrator): state = {"prd": "PRD", "tasks": [], "iteration_count": 5, "max_iterations": 50, "errors": []} result = await orchestrator._task_node(state) assert result["iteration_count"] == 6 @pytest.mark.asyncio async def test_task_node_refreshes_on_subsequent_passes(self, orchestrator, mock_task_agent): state = { "prd": "PRD", "tasks": [{"id": 1, "title": "existing", "status": "pending", "dependencies": []}], "iteration_count": 1, "max_iterations": 50, "errors": [], } result = await orchestrator._task_node(state) mock_task_agent.parse_prd.assert_not_called() mock_task_agent.get_unblocked_tasks.assert_called() @pytest.mark.asyncio async def test_task_node_stops_at_max_iterations(self, orchestrator): state = {"prd": "PRD", "tasks": [], "iteration_count": 49, "max_iterations": 50, "errors": []} result = await orchestrator._task_node(state) assert result["iteration_count"] == 50 assert any("Max iterations" in e for e in result["errors"]) class TestDevDispatchNode: @pytest.mark.asyncio async def test_dev_dispatch_spawns_concurrent_tasks(self, orchestrator, mock_dev_manager, mock_workspace_manager): state = { "tasks": [ {"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}, {"id": 2, "title": "Task 2", "status": "pending", "dependencies": []}, ], "completed_tasks": [], "active_tasks": {}, "errors": [], "clarification_requests": [], "global_architecture": "", } result = await orchestrator._dev_dispatch_node(state) assert "1" in result["completed_tasks"] assert "2" in result["completed_tasks"] assert mock_dev_manager.execute_with_retry.call_count == 2 assert mock_workspace_manager.create_worktree.call_count == 2 @pytest.mark.asyncio async def test_dev_dispatch_handles_needs_clarification(self, orchestrator, mock_dev_manager): mock_dev_manager.execute_with_retry.return_value = { "status": "needs_clarification", "output": "Cannot figure out API format", "files_changed": [], "exit_code": -1, } state = { "tasks": [{"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}], "completed_tasks": [], "active_tasks": {}, "errors": [], "clarification_requests": [], "global_architecture": "", } result = await orchestrator._dev_dispatch_node(state) assert len(result["clarification_requests"]) == 1 assert result["clarification_requests"][0]["task_id"] == "1" @pytest.mark.asyncio async def test_dev_dispatch_skips_completed(self, orchestrator): state = { "tasks": [{"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}], "completed_tasks": ["1"], "active_tasks": {}, "errors": [], "clarification_requests": [], "global_architecture": "", } result = await orchestrator._dev_dispatch_node(state) assert result == {} @pytest.mark.asyncio async def test_dev_dispatch_without_agents(self): orch = AppFactoryOrchestrator() state = { "tasks": [ {"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}, ], "completed_tasks": [], "active_tasks": {}, "errors": [], "clarification_requests": [], "global_architecture": "", } result = await orch._dev_dispatch_node(state) assert "1" in result["completed_tasks"] class TestQANode: @pytest.mark.asyncio async def test_qa_node_processes_results(self, orchestrator, mock_qa_agent, mock_task_agent): state = { "tasks": [{"id": "1", "title": "Task 1"}], "active_tasks": {"1": {"status": "success", "worktree_path": "/tmp/wt"}}, "completed_tasks": ["1"], "errors": [], "clarification_requests": [], "blocked_tasks": {}, } result = await orchestrator._qa_node(state) mock_qa_agent.review_and_merge.assert_called_once() assert result["active_tasks"]["1"]["status"] == "merged" @pytest.mark.asyncio async def test_qa_node_no_tasks_for_qa(self, orchestrator): state = { "tasks": [], "active_tasks": {}, "completed_tasks": [], "errors": [], "clarification_requests": [], "blocked_tasks": {}, } result = await orchestrator._qa_node(state) assert result == {} @pytest.mark.asyncio async def test_qa_node_handles_qa_failure(self, orchestrator, mock_qa_agent): mock_qa_agent.review_and_merge.return_value = { "status": "tests_failed", "retry_count": 3, } state = { "tasks": [{"id": "1", "title": "Task 1"}], "active_tasks": {"1": {"status": "success", "worktree_path": "/tmp/wt"}}, "completed_tasks": ["1"], "errors": [], "clarification_requests": [], "blocked_tasks": {}, } result = await orchestrator._qa_node(state) assert len(result["clarification_requests"]) == 1 # --------------------------------------------------------------------------- # Routing Tests # --------------------------------------------------------------------------- class TestRoutingAfterTasks: def test_routes_to_dev_dispatch_when_unblocked(self, orchestrator): state = { "tasks": [{"id": 1, "status": "pending", "dependencies": []}], "completed_tasks": [], "blocked_tasks": {}, "clarification_requests": [], "iteration_count": 1, "max_iterations": 50, } assert orchestrator._should_continue_after_tasks(state) == "dev_dispatch" def test_routes_to_end_when_all_done(self, orchestrator): state = { "tasks": [{"id": 1, "status": "done", "dependencies": []}], "completed_tasks": ["1"], "blocked_tasks": {}, "clarification_requests": [], "iteration_count": 1, "max_iterations": 50, } assert orchestrator._should_continue_after_tasks(state) == "end" def test_routes_to_clarification_when_blocked(self, orchestrator): state = { "tasks": [{"id": 1, "status": "pending", "dependencies": [2]}], "completed_tasks": [], "blocked_tasks": {"1": "dependency not met"}, "clarification_requests": [], "iteration_count": 1, "max_iterations": 50, } assert orchestrator._should_continue_after_tasks(state) == "clarification" def test_routes_to_end_at_max_iterations(self, orchestrator): state = { "tasks": [{"id": 1, "status": "pending", "dependencies": []}], "completed_tasks": [], "blocked_tasks": {}, "clarification_requests": [], "iteration_count": 50, "max_iterations": 50, } assert orchestrator._should_continue_after_tasks(state) == "end" def test_routes_to_end_when_no_tasks(self, orchestrator): state = { "tasks": [], "completed_tasks": [], "blocked_tasks": {}, "clarification_requests": [], "iteration_count": 1, "max_iterations": 50, } assert orchestrator._should_continue_after_tasks(state) == "end" class TestRoutingAfterQA: def test_routes_to_task_node(self, orchestrator): state = { "clarification_requests": [], "iteration_count": 1, "max_iterations": 50, } assert orchestrator._should_continue_after_qa(state) == "task_node" def test_routes_to_clarification(self, orchestrator): state = { "clarification_requests": [{"question": "What API?"}], "iteration_count": 1, "max_iterations": 50, } assert orchestrator._should_continue_after_qa(state) == "clarification" def test_routes_to_end_at_max_iterations(self, orchestrator): state = { "clarification_requests": [], "iteration_count": 50, "max_iterations": 50, } assert orchestrator._should_continue_after_qa(state) == "end" # --------------------------------------------------------------------------- # Iteration Safety Tests # --------------------------------------------------------------------------- class TestIterationSafety: @pytest.mark.asyncio async def test_iteration_limit_prevents_infinite_loop(self, orchestrator): state = { "prd": "PRD", "tasks": [], "iteration_count": 49, "max_iterations": 50, "errors": [], } result = await orchestrator._task_node(state) assert result["iteration_count"] == 50 assert any("Max iterations" in e for e in result["errors"]) # --------------------------------------------------------------------------- # State Persistence Tests # --------------------------------------------------------------------------- class TestStatePersistence: def test_save_state(self, orchestrator): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "state.json") state = { "user_input": "test", "prd": "PRD content", "tasks": [{"id": 1}], "active_tasks": {}, "completed_tasks": ["1"], "blocked_tasks": {}, "clarification_requests": [], "global_architecture": "", "iteration_count": 5, "max_iterations": 50, "errors": [], } orchestrator.save_state(state, path) assert os.path.exists(path) with open(path) as f: loaded = json.load(f) assert loaded["user_input"] == "test" assert loaded["iteration_count"] == 5 def test_load_state(self, orchestrator): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "state.json") data = {"user_input": "hello", "prd": "PRD", "iteration_count": 3} with open(path, "w") as f: json.dump(data, f) loaded = orchestrator.load_state(path) assert loaded["user_input"] == "hello" assert loaded["iteration_count"] == 3 def test_save_state_creates_directory(self, orchestrator): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "nested", "dir", "state.json") orchestrator.save_state({"user_input": "test"}, path) assert os.path.exists(path) # --------------------------------------------------------------------------- # Clarification Node Tests # --------------------------------------------------------------------------- class TestClarificationNode: @pytest.mark.asyncio async def test_clarification_resolves_requests(self, orchestrator, mock_pm_agent): state = { "clarification_requests": [ {"requesting_agent": "dev", "task_id": "1", "question": "What format?", "context": ""}, ], "blocked_tasks": {"1": "needs clarification"}, "errors": [], } result = await orchestrator._clarification_node(state) assert result["clarification_requests"] == [] assert "1" not in result["blocked_tasks"] mock_pm_agent.handle_clarification_request.assert_called_once() @pytest.mark.asyncio async def test_clarification_handles_empty_requests(self, orchestrator): state = {"clarification_requests": [], "blocked_tasks": {}, "errors": []} result = await orchestrator._clarification_node(state) assert result["clarification_requests"] == [] @pytest.mark.asyncio async def test_clarification_without_agent(self): orch = AppFactoryOrchestrator() state = { "clarification_requests": [{"task_id": "1", "question": "?"}], "blocked_tasks": {"1": "blocked"}, "errors": [], } result = await orch._clarification_node(state) assert result["clarification_requests"] == [] # --------------------------------------------------------------------------- # End-to-End Run Test # --------------------------------------------------------------------------- class TestEndToEnd: @pytest.mark.asyncio async def test_run_executes_end_to_end(self, mock_pm_agent, mock_task_agent, mock_dev_manager, mock_qa_agent, mock_workspace_manager, mock_observability): # After first get_unblocked_tasks returns tasks, the second call returns empty # to terminate the loop. mock_task_agent.get_unblocked_tasks = AsyncMock( side_effect=[ [{"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}], [], # No more tasks - triggers end ] ) orch = AppFactoryOrchestrator( pm_agent=mock_pm_agent, task_agent=mock_task_agent, dev_manager=mock_dev_manager, qa_agent=mock_qa_agent, workspace_manager=mock_workspace_manager, observability=mock_observability, ) with tempfile.TemporaryDirectory() as tmpdir: state_path = os.path.join(tmpdir, "state.json") with patch.object(orch, "save_state") as mock_save: result = await orch.run("build a todo app") assert result["prd"] != "" mock_pm_agent.expand_prompt_to_prd.assert_called_once_with("build a todo app") mock_task_agent.parse_prd.assert_called_once() @pytest.mark.asyncio async def test_run_with_no_agents(self): orch = AppFactoryOrchestrator() with tempfile.TemporaryDirectory() as tmpdir: state_path = os.path.join(tmpdir, "state.json") with patch.object(orch, "save_state"): result = await orch.run("build something") assert "Mock PRD" in result["prd"]