"""Tests for main.py entry point, error handling, and integration.""" import asyncio import os import signal import sys from datetime import datetime, timezone from io import StringIO from unittest.mock import AsyncMock, MagicMock, patch import pytest from main import ( AppFactoryError, ClarificationTimeout, ConfigurationError, DockerDaemonError, GracefulShutdown, GitError, MCPConnectionError, main, parse_args, print_summary, run_factory, validate_environment, ) # --------------------------------------------------------------------------- # parse_args tests # --------------------------------------------------------------------------- class TestParseArgs: def test_prompt_required(self): with pytest.raises(SystemExit): parse_args([]) def test_prompt_only(self): args = parse_args(["--prompt", "Build a REST API"]) assert args.prompt == "Build a REST API" assert args.repo_path == os.getcwd() assert args.max_concurrent_tasks == 5 assert args.debug is False assert args.dry_run is False def test_all_options(self): args = parse_args([ "--prompt", "Build an app", "--repo-path", "/tmp/project", "--max-concurrent-tasks", "3", "--debug", "--dry-run", ]) assert args.prompt == "Build an app" assert args.repo_path == "/tmp/project" assert args.max_concurrent_tasks == 3 assert args.debug is True assert args.dry_run is True def test_max_concurrent_tasks_default(self): args = parse_args(["--prompt", "test"]) assert args.max_concurrent_tasks == 5 def test_repo_path_default_is_cwd(self): args = parse_args(["--prompt", "test"]) assert args.repo_path == os.getcwd() # --------------------------------------------------------------------------- # validate_environment tests # --------------------------------------------------------------------------- class TestValidateEnvironment: def test_valid_config(self): env = { "ANTHROPIC_API_KEY": "sk-test-key", "LANGSMITH_API_KEY": "ls-key", "LANGSMITH_PROJECT": "my-project", } with patch.dict(os.environ, env, clear=False), \ patch("main.subprocess.run") as mock_run, \ patch("main.shutil.which", return_value="/usr/bin/git"): mock_run.return_value = MagicMock(returncode=0) config = validate_environment() assert config["api_key"] == "sk-test-key" assert config["auth_token"] == "" assert config["langsmith_api_key"] == "ls-key" assert config["langsmith_project"] == "my-project" def test_missing_api_key_still_works(self): """API key is optional (Claude Code OAuth supported).""" with patch.dict(os.environ, {}, clear=True), \ patch("main.subprocess.run") as mock_run, \ patch("main.shutil.which", return_value="/usr/bin/git"): mock_run.return_value = MagicMock(returncode=0) config = validate_environment() assert config["api_key"] == "" assert config["auth_token"] == "" def test_docker_not_running(self): with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}, clear=False), \ patch("main.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1) with pytest.raises(DockerDaemonError, match="not running"): validate_environment() def test_docker_not_found(self): with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}, clear=False), \ patch("main.subprocess.run", side_effect=FileNotFoundError): with pytest.raises(DockerDaemonError, match="not found"): validate_environment() def test_docker_timeout(self): import subprocess as sp with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}, clear=False), \ patch("main.subprocess.run", side_effect=sp.TimeoutExpired("docker", 10)): with pytest.raises(DockerDaemonError, match="not responding"): validate_environment() def test_git_not_found(self): with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}, clear=False), \ patch("main.subprocess.run") as mock_run, \ patch("main.shutil.which", return_value=None): mock_run.return_value = MagicMock(returncode=0) with pytest.raises(GitError, match="git not found"): validate_environment() # --------------------------------------------------------------------------- # print_summary tests # --------------------------------------------------------------------------- class TestPrintSummary: def test_basic_summary(self, capsys): result = { "completed_tasks": ["1", "2"], "tasks": [{"id": 1}, {"id": 2}, {"id": 3}], "errors": [], "iteration_count": 5, } start = datetime.now(timezone.utc) print_summary(result, start) captured = capsys.readouterr().out assert "2 / 3" in captured assert "Iterations" in captured def test_summary_with_errors(self, capsys): result = { "completed_tasks": [], "tasks": [{"id": 1}], "errors": ["Error one", "Error two"], "iteration_count": 1, } start = datetime.now(timezone.utc) print_summary(result, start) captured = capsys.readouterr().out assert "Errors" in captured assert "Error one" in captured def test_summary_truncates_many_errors(self, capsys): result = { "completed_tasks": [], "tasks": [], "errors": [f"Error {i}" for i in range(10)], "iteration_count": 0, } start = datetime.now(timezone.utc) print_summary(result, start) captured = capsys.readouterr().out assert "and 5 more" in captured def test_summary_with_langsmith(self, capsys): result = { "completed_tasks": [], "tasks": [], "errors": [], "iteration_count": 0, } start = datetime.now(timezone.utc) with patch.dict(os.environ, {"LANGSMITH_PROJECT": "test-proj"}): print_summary(result, start) captured = capsys.readouterr().out assert "test-proj" in captured def test_summary_empty_result(self, capsys): result = { "completed_tasks": [], "tasks": [], "errors": [], "iteration_count": 0, } start = datetime.now(timezone.utc) print_summary(result, start) captured = capsys.readouterr().out assert "0 / 0" in captured # --------------------------------------------------------------------------- # GracefulShutdown tests # --------------------------------------------------------------------------- class TestGracefulShutdown: def test_initial_state(self): with patch("main.signal.signal"): gs = GracefulShutdown() assert gs.shutdown_requested is False assert gs.workspace_manager is None def test_registers_signals(self): with patch("main.signal.signal") as mock_signal: gs = GracefulShutdown() calls = [c[0] for c in mock_signal.call_args_list] assert (signal.SIGINT, gs._handler) in calls assert (signal.SIGTERM, gs._handler) in calls def test_first_signal_sets_flag(self): with patch("main.signal.signal"): gs = GracefulShutdown() # Simulate first signal with patch("builtins.print"): gs._handler(signal.SIGINT, None) assert gs.shutdown_requested is True def test_second_signal_force_exits(self): with patch("main.signal.signal"): gs = GracefulShutdown() gs.shutdown_requested = True with patch("builtins.print"), pytest.raises(SystemExit): gs._handler(signal.SIGINT, None) def test_first_signal_triggers_cleanup(self): mock_ws = MagicMock() mock_ws.cleanup_all = AsyncMock() with patch("main.signal.signal"): gs = GracefulShutdown(workspace_manager=mock_ws) # Simulate handler with a running loop loop = asyncio.new_event_loop() async def _run(): with patch("builtins.print"): gs._handler(signal.SIGINT, None) loop.run_until_complete(_run()) loop.close() assert gs.shutdown_requested is True # --------------------------------------------------------------------------- # run_factory tests # --------------------------------------------------------------------------- class TestRunFactory: @pytest.mark.asyncio async def test_initializes_all_components(self): mock_orchestrator_instance = MagicMock() mock_orchestrator_instance.run = AsyncMock(return_value={ "completed_tasks": ["1"], "tasks": [{"id": 1}], "errors": [], "iteration_count": 1, }) args = MagicMock() args.prompt = "Build a REST API" args.repo_path = "/tmp/test-repo" config = { "api_key": "sk-test-key", "auth_token": "", "langsmith_api_key": "", "langsmith_project": "app-factory", } with patch("app_factory.core.observability.ObservabilityManager") as mock_obs, \ patch("app_factory.core.workspace.WorkspaceManager") as mock_ws, \ patch("app_factory.core.architecture_tracker.ArchitectureTracker") as mock_arch, \ patch("app_factory.agents.pm_agent.PMAgent") as mock_pm, \ patch("app_factory.agents.task_agent.TaskMasterAgent") as mock_task, \ patch("app_factory.agents.dev_agent.DevAgentManager") as mock_dev, \ patch("app_factory.agents.qa_agent.QAAgent") as mock_qa, \ patch("app_factory.core.graph.AppFactoryOrchestrator") as mock_orch, \ patch("main.GracefulShutdown", create=True): mock_orch.return_value = mock_orchestrator_instance mock_ws.return_value = MagicMock(docker_client=MagicMock()) result = await run_factory(args, config) assert result["completed_tasks"] == ["1"] mock_obs.assert_called_once() mock_ws.assert_called_once_with(repo_path="/tmp/test-repo") mock_arch.assert_called_once_with( api_key="sk-test-key", auth_token=None, debug=False, observability=mock_obs.return_value, ) mock_pm.assert_called_once_with( api_key="sk-test-key", auth_token=None, debug=False, observability=mock_obs.return_value, ) mock_task.assert_called_once_with(project_root="/tmp/test-repo") mock_dev.assert_called_once() mock_qa.assert_called_once_with( repo_path="/tmp/test-repo", api_key="sk-test-key", auth_token=None, debug=False, observability=mock_obs.return_value, ) mock_orch.assert_called_once() mock_orchestrator_instance.run.assert_awaited_once_with("Build a REST API") # --------------------------------------------------------------------------- # main() integration tests # --------------------------------------------------------------------------- class TestMainEntryPoint: def test_dry_run_validates_without_executing(self): with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment") as mock_validate, \ patch("builtins.print") as mock_print: mock_args.return_value = MagicMock( prompt="test", debug=False, dry_run=True, ) mock_validate.return_value = {"api_key": "sk-test"} main() mock_print.assert_called_with( "Dry-run: configuration is valid. All checks passed." ) def test_configuration_error_exits(self): with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment", side_effect=ConfigurationError("no key")), \ patch("builtins.print"), \ pytest.raises(SystemExit) as exc_info: mock_args.return_value = MagicMock(debug=False, dry_run=False) main() assert exc_info.value.code == 1 def test_docker_error_exits(self): with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment", side_effect=DockerDaemonError("not running")), \ patch("builtins.print"), \ pytest.raises(SystemExit) as exc_info: mock_args.return_value = MagicMock(debug=False, dry_run=False) main() assert exc_info.value.code == 1 def test_git_error_exits(self): with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment", side_effect=GitError("no git")), \ patch("builtins.print"), \ pytest.raises(SystemExit) as exc_info: mock_args.return_value = MagicMock(debug=False, dry_run=False) main() assert exc_info.value.code == 1 def test_clarification_timeout_exits(self): with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment") as mock_validate, \ patch("main.asyncio.run", side_effect=ClarificationTimeout("task 5")), \ patch("builtins.print"), \ pytest.raises(SystemExit) as exc_info: mock_args.return_value = MagicMock( prompt="test", debug=False, dry_run=False, ) mock_validate.return_value = {"api_key": "sk-test"} main() assert exc_info.value.code == 1 def test_generic_exception_exits(self): with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment") as mock_validate, \ patch("main.asyncio.run", side_effect=RuntimeError("boom")), \ patch("builtins.print"), \ pytest.raises(SystemExit) as exc_info: mock_args.return_value = MagicMock( prompt="test", debug=False, dry_run=False, ) mock_validate.return_value = {"api_key": "sk-test"} main() assert exc_info.value.code == 1 def test_debug_flag_sets_logging(self): with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment") as mock_validate, \ patch("main.logging.basicConfig") as mock_logging, \ patch("builtins.print"): mock_args.return_value = MagicMock( prompt="test", debug=True, dry_run=True, ) mock_validate.return_value = {"api_key": "sk-test"} main() mock_logging.assert_called_once() call_kwargs = mock_logging.call_args[1] assert call_kwargs["level"] == 10 # logging.DEBUG def test_successful_run(self): mock_result = { "completed_tasks": ["1"], "tasks": [{"id": 1}], "errors": [], "iteration_count": 3, } with patch("main.load_dotenv"), \ patch("main.parse_args") as mock_args, \ patch("main.validate_environment") as mock_validate, \ patch("main.asyncio.run", return_value=mock_result), \ patch("main.print_summary") as mock_summary: mock_args.return_value = MagicMock( prompt="test", debug=False, dry_run=False, ) mock_validate.return_value = {"api_key": "sk-test"} main() mock_summary.assert_called_once() # Verify the result was passed to print_summary assert mock_summary.call_args[0][0] == mock_result # --------------------------------------------------------------------------- # Exception hierarchy tests # --------------------------------------------------------------------------- class TestExceptionHierarchy: def test_all_exceptions_inherit_from_base(self): assert issubclass(ClarificationTimeout, AppFactoryError) assert issubclass(DockerDaemonError, AppFactoryError) assert issubclass(GitError, AppFactoryError) assert issubclass(MCPConnectionError, AppFactoryError) assert issubclass(ConfigurationError, AppFactoryError) def test_base_inherits_from_exception(self): assert issubclass(AppFactoryError, Exception)