"""Container-level swarm integration tests. These tests require Docker and run against real containers: - dashboard on port 18000 - agent workers scaled via docker compose Run with: FUNCTIONAL_DOCKER=1 pytest tests/functional/test_docker_swarm.py -v Skipped automatically if FUNCTIONAL_DOCKER != "1". """ import subprocess import time from pathlib import Path import pytest httpx = pytest.importorskip("httpx") PROJECT_ROOT = Path(__file__).parent.parent.parent COMPOSE_TEST = PROJECT_ROOT / "docker-compose.test.yml" # Skip all tests in this module if Docker is not available or FUNCTIONAL_DOCKER is not set pytestmark = pytest.mark.skipif( subprocess.run(["which", "docker"], capture_output=True).returncode != 0 or subprocess.run(["which", "docker-compose"], capture_output=True).returncode != 0, reason="Docker or docker-compose not installed", ) def _compose(*args, timeout=60): cmd = ["docker", "compose", "-f", str(COMPOSE_TEST), "-p", "timmy-test", *args] return subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT) ) def _wait_for_agents(dashboard_url, timeout=30, interval=1): """Poll /swarm/agents until at least one agent appears.""" start = time.monotonic() while time.monotonic() - start < timeout: try: resp = httpx.get(f"{dashboard_url}/swarm/agents", timeout=10) if resp.status_code == 200: agents = resp.json().get("agents", []) if agents: return agents except Exception: pass time.sleep(interval) return [] class TestDockerDashboard: """Tests hitting the real dashboard container over HTTP.""" def test_health(self, docker_stack): resp = httpx.get(f"{docker_stack}/health", timeout=10) assert resp.status_code == 200 data = resp.json() assert "status" in data or "ollama" in data def test_index_page(self, docker_stack): resp = httpx.get(docker_stack, timeout=10) assert resp.status_code == 200 assert "text/html" in resp.headers["content-type"] assert "Timmy" in resp.text def test_swarm_status(self, docker_stack): resp = httpx.get(f"{docker_stack}/swarm", timeout=10) assert resp.status_code == 200 def test_spawn_agent_via_api(self, docker_stack): resp = httpx.post( f"{docker_stack}/swarm/spawn", data={"name": "RemoteEcho"}, timeout=10, ) assert resp.status_code == 200 data = resp.json() assert data.get("name") == "RemoteEcho" or "id" in data def test_post_task_via_api(self, docker_stack): resp = httpx.post( f"{docker_stack}/swarm/tasks", data={"description": "Docker test task"}, timeout=10, ) assert resp.status_code == 200 data = resp.json() assert data["description"] == "Docker test task" assert "task_id" in data class TestDockerAgentSwarm: """Tests with real agent containers communicating over the network. These tests scale up agent workers and verify they register, bid on tasks, and get assigned work — all over real HTTP. """ def test_agent_registers_via_http(self, docker_stack): """Scale up one agent worker and verify it appears in the registry.""" # Start one agent result = _compose( "--profile", "agents", "up", "-d", "--scale", "agent=1", timeout=120, ) assert result.returncode == 0, f"Failed to start agent:\n{result.stderr}" # Wait for agent to register via polling _wait_for_agents(docker_stack) resp = httpx.get(f"{docker_stack}/swarm/agents", timeout=10) assert resp.status_code == 200 agents = resp.json()["agents"] agent_names = [a["name"] for a in agents] assert "TestWorker" in agent_names or any("Worker" in n for n in agent_names) # Clean up the agent _compose("--profile", "agents", "down", timeout=30) def test_agent_bids_on_task(self, docker_stack): """Start an agent, post a task, verify the agent bids on it.""" # Start agent result = _compose( "--profile", "agents", "up", "-d", "--scale", "agent=1", timeout=120, ) assert result.returncode == 0 # Wait for agent to register via polling _wait_for_agents(docker_stack) # Post a task — this triggers an auction task_resp = httpx.post( f"{docker_stack}/swarm/tasks", data={"description": "Test bidding flow"}, timeout=10, ) assert task_resp.status_code == 200 task_id = task_resp.json()["task_id"] # Poll until task exists (agent may poll and bid) start = time.monotonic() while time.monotonic() - start < 15: task = httpx.get(f"{docker_stack}/swarm/tasks/{task_id}", timeout=10) if task.status_code == 200: break time.sleep(1) # Check task status — may have been assigned task = httpx.get(f"{docker_stack}/swarm/tasks/{task_id}", timeout=10) assert task.status_code == 200 task_data = task.json() # The task should still exist regardless of bid outcome assert task_data["description"] == "Test bidding flow" _compose("--profile", "agents", "down", timeout=30) def test_multiple_agents(self, docker_stack): """Scale to 3 agents and verify all register.""" result = _compose( "--profile", "agents", "up", "-d", "--scale", "agent=3", timeout=120, ) assert result.returncode == 0 # Wait for agents to register via polling _wait_for_agents(docker_stack) resp = httpx.get(f"{docker_stack}/swarm/agents", timeout=10) agents = resp.json()["agents"] # Should have at least the 3 agents we started (plus possibly Timmy and auto-spawned ones) worker_count = sum(1 for a in agents if "Worker" in a["name"] or "TestWorker" in a["name"]) assert worker_count >= 1 # At least some registered _compose("--profile", "agents", "down", timeout=30)