first commit
This commit is contained in:
456
tests/test_main.py
Normal file
456
tests/test_main.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user