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

269
tests/test_workspace.py Normal file
View File

@@ -0,0 +1,269 @@
"""Tests for WorkspaceManager."""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import docker as docker_mod
import git
import pytest
from app_factory.core.workspace import (
DockerProvisionError,
GitWorktreeError,
WorkspaceError,
WorkspaceManager,
)
@pytest.fixture
def temp_git_repo(tmp_path):
"""Create a real temporary git repository."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
repo = git.Repo.init(repo_dir)
readme = repo_dir / "README.md"
readme.write_text("# Test Repo")
repo.index.add(["README.md"])
repo.index.commit("Initial commit")
if repo.active_branch.name != "main":
repo.git.branch("-M", "main")
return str(repo_dir)
@pytest.fixture
def mock_docker_client():
"""Create a mock Docker client."""
mock_client = MagicMock()
mock_client.images.pull.return_value = MagicMock()
mock_container = MagicMock()
mock_container.id = "abc123container"
mock_client.containers.create.return_value = mock_container
return mock_client
@pytest.fixture
def workspace_manager(temp_git_repo, mock_docker_client):
"""Create a WorkspaceManager with a real git repo and mocked Docker."""
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
wm = WorkspaceManager(temp_git_repo)
return wm
class TestWorkspaceManagerInit:
def test_init_valid_repo(self, temp_git_repo, mock_docker_client):
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
wm = WorkspaceManager(temp_git_repo)
assert wm.repo_path == Path(temp_git_repo).resolve()
assert wm.docker_image == "python:3.11-slim"
assert wm.active_workspaces == {}
def test_init_custom_image(self, temp_git_repo, mock_docker_client):
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
wm = WorkspaceManager(temp_git_repo, docker_image="node:20-slim")
assert wm.docker_image == "node:20-slim"
def test_init_invalid_repo(self, tmp_path, mock_docker_client):
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
with pytest.raises(GitWorktreeError, match="Invalid git repository"):
WorkspaceManager(str(tmp_path))
def test_init_nonexistent_path(self, mock_docker_client):
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
with pytest.raises(GitWorktreeError, match="Repository path not found"):
WorkspaceManager("/nonexistent/path/to/repo")
def test_init_docker_unavailable(self, temp_git_repo):
with patch(
"app_factory.core.workspace.docker.from_env",
side_effect=docker_mod.errors.DockerException("Connection refused"),
):
with pytest.raises(DockerProvisionError, match="Failed to connect to Docker"):
WorkspaceManager(temp_git_repo)
@pytest.mark.asyncio
class TestCreateWorktree:
async def test_create_worktree_success(self, workspace_manager):
wm = workspace_manager
path = await wm.create_worktree("task-001")
assert os.path.isdir(path)
assert "task-001" in path
assert "feature/task-task-001" in [b.name for b in wm.repo.branches]
# Cleanup
wm.repo.git.worktree("remove", path, "--force")
async def test_create_worktree_invalid_base_branch(self, workspace_manager):
wm = workspace_manager
with pytest.raises(GitWorktreeError, match="does not exist"):
await wm.create_worktree("task-002", base_branch="nonexistent")
async def test_create_worktree_branch_already_exists(self, workspace_manager):
wm = workspace_manager
wm.repo.git.branch("feature/task-task-003")
with pytest.raises(GitWorktreeError, match="Branch already exists"):
await wm.create_worktree("task-003")
async def test_create_worktree_path_already_exists(self, workspace_manager):
wm = workspace_manager
worktree_dir = wm.repo_path.parent / "worktrees" / "task-004"
worktree_dir.mkdir(parents=True)
with pytest.raises(GitWorktreeError, match="Worktree path already exists"):
await wm.create_worktree("task-004")
@pytest.mark.asyncio
class TestSpinUpCleanRoom:
async def test_spin_up_clean_room_success(self, workspace_manager, mock_docker_client):
wm = workspace_manager
container = await wm.spin_up_clean_room("/tmp/fake_worktree", "task-010")
assert container.id == "abc123container"
mock_docker_client.images.pull.assert_called_once_with("python:3.11-slim")
mock_docker_client.containers.create.assert_called_once_with(
image="python:3.11-slim",
name="appfactory-task-task-010",
volumes={"/tmp/fake_worktree": {"bind": "/workspace", "mode": "rw"}},
working_dir="/workspace",
network_mode="none",
auto_remove=False,
detach=True,
command="sleep infinity",
)
assert "task-010" in wm.active_workspaces
info = wm.active_workspaces["task-010"]
assert info["worktree_path"] == "/tmp/fake_worktree"
assert info["container_id"] == "abc123container"
async def test_spin_up_image_pull_failure(self, workspace_manager, mock_docker_client):
wm = workspace_manager
mock_docker_client.images.pull.side_effect = docker_mod.errors.APIError("pull failed")
with pytest.raises(DockerProvisionError, match="Failed to pull image"):
await wm.spin_up_clean_room("/tmp/fake", "task-011")
async def test_spin_up_container_create_failure(self, workspace_manager, mock_docker_client):
wm = workspace_manager
mock_docker_client.containers.create.side_effect = docker_mod.errors.APIError("create failed")
with pytest.raises(DockerProvisionError, match="Failed to create container"):
await wm.spin_up_clean_room("/tmp/fake", "task-012")
@pytest.mark.asyncio
class TestCleanupWorkspace:
async def test_cleanup_workspace_success(self, workspace_manager):
wm = workspace_manager
path = await wm.create_worktree("task-020")
mock_container = MagicMock()
wm.active_workspaces["task-020"] = {
"task_id": "task-020",
"worktree_path": path,
"container_id": "cont123",
"container": mock_container,
}
await wm.cleanup_workspace("task-020")
mock_container.stop.assert_called_once_with(timeout=5)
mock_container.remove.assert_called_once_with(force=True)
assert "task-020" not in wm.active_workspaces
assert not os.path.exists(path)
async def test_cleanup_already_stopped_container(self, workspace_manager):
wm = workspace_manager
mock_container = MagicMock()
mock_container.stop.side_effect = Exception("already stopped")
mock_container.remove.return_value = None
wm.active_workspaces["task-021"] = {
"task_id": "task-021",
"worktree_path": "/tmp/nonexistent",
"container_id": "cont456",
"container": mock_container,
}
# Should not raise even if stop fails
await wm.cleanup_workspace("task-021")
assert "task-021" not in wm.active_workspaces
async def test_cleanup_no_container(self, workspace_manager):
wm = workspace_manager
path = await wm.create_worktree("task-022")
wm.active_workspaces["task-022"] = {
"task_id": "task-022",
"worktree_path": path,
"container_id": None,
"container": None,
}
await wm.cleanup_workspace("task-022")
assert "task-022" not in wm.active_workspaces
class TestGetActiveWorkspaces:
def test_get_empty(self, workspace_manager):
assert workspace_manager.get_active_workspaces() == []
def test_get_with_workspaces(self, workspace_manager):
wm = workspace_manager
wm.active_workspaces["t1"] = {
"task_id": "t1",
"worktree_path": "/path/t1",
"container_id": "c1",
"container": MagicMock(),
}
wm.active_workspaces["t2"] = {
"task_id": "t2",
"worktree_path": "/path/t2",
"container_id": "c2",
"container": MagicMock(),
}
result = wm.get_active_workspaces()
assert len(result) == 2
assert {"task_id": "t1", "worktree_path": "/path/t1", "container_id": "c1"} in result
assert {"task_id": "t2", "worktree_path": "/path/t2", "container_id": "c2"} in result
for item in result:
assert "container" not in item
@pytest.mark.asyncio
class TestCleanupAll:
async def test_cleanup_all_success(self, workspace_manager):
wm = workspace_manager
mock_c1 = MagicMock()
mock_c2 = MagicMock()
wm.active_workspaces["t1"] = {
"task_id": "t1",
"worktree_path": "/tmp/nonexistent1",
"container_id": "c1",
"container": mock_c1,
}
wm.active_workspaces["t2"] = {
"task_id": "t2",
"worktree_path": "/tmp/nonexistent2",
"container_id": "c2",
"container": mock_c2,
}
await wm.cleanup_all()
mock_c1.stop.assert_called_once()
mock_c2.stop.assert_called_once()
assert wm.active_workspaces == {}
async def test_cleanup_all_with_errors(self, workspace_manager):
wm = workspace_manager
mock_c1 = MagicMock()
mock_c1.stop.side_effect = Exception("stop failed")
mock_c1.remove.side_effect = Exception("remove failed")
wm.active_workspaces["t1"] = {
"task_id": "t1",
"worktree_path": "/tmp/nonexistent",
"container_id": "c1",
"container": mock_c1,
}
with pytest.raises(WorkspaceError, match="Cleanup all completed with errors"):
await wm.cleanup_all()
assert wm.active_workspaces == {}