first commit

This commit is contained in:
2026-02-25 23:49:54 -05:00
commit 4d097161cb
1775 changed files with 452827 additions and 0 deletions

456
tests/test_main.py Normal file
View 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)