Files
wehub-resource-sync db1d565b64
Integration Tests / melodic (push) Has been cancelled
Integration Tests / noetic (push) Has been cancelled
Integration Tests / humble (push) Has been cancelled
Integration Tests / jazzy (push) Has been cancelled
Ruff Lint & Format / ruff (push) Has been cancelled
Sync main to develop / Check if sync is needed (push) Has been cancelled
Sync main to develop / Sync main to develop (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:23 +08:00

159 lines
5.1 KiB
Python

"""Integration test fixtures: Docker lifecycle and WebSocket access."""
import os
import socket
import subprocess
import time
import warnings
from pathlib import Path
import pytest
from fastmcp import FastMCP
from ros_mcp.resources import register_all_resources
from ros_mcp.tools import register_all_tools
from ros_mcp.utils.rosapi_types import detect_rosapi_types
from ros_mcp.utils.websocket import WebSocketManager
COMPOSE_DIR = Path(__file__).parent
ROSBRIDGE_PORT = 9090
_DOCKERFILE_MAP = {
"melodic": "Dockerfile.ros1-melodic",
"noetic": "Dockerfile.ros1-noetic",
"humble": "Dockerfile.ros2-humble",
"jazzy": "Dockerfile.ros2-jazzy",
}
_CONTAINER_NAME_MAP = {
"melodic": "integration-ros-melodic",
"noetic": "integration-ros-noetic",
"humble": "integration-ros2-humble",
"jazzy": "integration-ros2-jazzy",
}
def docker_available() -> bool:
try:
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _wait_for_rosbridge(port: int = ROSBRIDGE_PORT, timeout: float = 30) -> None:
"""Poll rosbridge TCP port with a raw socket probe."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=2):
return
except OSError:
time.sleep(1)
raise TimeoutError(f"Rosbridge not ready after {timeout}s on port {port}")
def pytest_addoption(parser):
parser.addoption(
"--ros-distro",
default="humble",
choices=list(_DOCKERFILE_MAP.keys()),
help="ROS distro to test against (default: humble)",
)
parser.addoption(
"--skip-compose",
action="store_true",
default=False,
help="Skip Docker compose up/down — assume container is already running",
)
def pytest_configure(config):
config.addinivalue_line("markers", "integration: integration tests requiring Docker + ROS")
@pytest.fixture(scope="session", autouse=True)
def require_docker():
if not docker_available():
pytest.skip("Docker is not available")
@pytest.fixture(scope="session")
def ros_distro(request):
"""The selected ROS distro name."""
return request.config.getoption("--ros-distro")
@pytest.fixture(scope="session")
def compose_up(require_docker, ros_distro, request):
"""Start docker-compose with the selected ROS distro, yield, then tear down.
If --skip-compose is set, assume the container is already running externally.
"""
if request.config.getoption("--skip-compose"):
yield
return
dockerfile = _DOCKERFILE_MAP[ros_distro]
container_name = _CONTAINER_NAME_MAP[ros_distro]
compose_file = str(COMPOSE_DIR / "docker-compose.yml")
env = {**os.environ, "ROS_DOCKERFILE": dockerfile, "ROS_CONTAINER_NAME": container_name}
try:
result = subprocess.run(
["docker", "compose", "-f", compose_file, "up", "--build", "-d", "--wait"],
timeout=300,
capture_output=True,
env=env,
)
if result.returncode != 0:
stderr = result.stderr.decode() if result.stderr else "(no output)"
pytest.fail(f"docker compose up failed (exit {result.returncode}):\n{stderr}")
yield
finally:
down = subprocess.run(
["docker", "compose", "-f", compose_file, "down", "--volumes", "--remove-orphans"],
timeout=60,
capture_output=True,
env=env,
)
if down.returncode != 0:
warnings.warn(f"docker compose down failed (exit {down.returncode})")
@pytest.fixture(scope="session")
def ws(compose_up):
"""WebSocketManager connected to the rosbridge container, with version detected."""
ws_manager = WebSocketManager("127.0.0.1", ROSBRIDGE_PORT, default_timeout=5.0)
_wait_for_rosbridge(ROSBRIDGE_PORT, timeout=30)
detect_rosapi_types(ws_manager)
return ws_manager
@pytest.fixture(scope="session")
def tools(ws):
"""MCP tool functions registered against the live ws_manager.
Returns a dict mapping tool name to its callable, e.g.:
tools["connect_to_robot"]() → dict
tools["get_nodes"]() → dict
"""
mcp = FastMCP("test-ros-mcp")
register_all_tools(mcp, ws, rosbridge_ip="127.0.0.1", rosbridge_port=ROSBRIDGE_PORT)
return {name: tool.fn for name, tool in mcp._tool_manager._tools.items()}
@pytest.fixture(scope="session")
def resources(ws):
"""MCP resource read-functions registered against the live ws_manager.
Returns a dict mapping resource URI to its zero-arg callable. Each callable
returns a JSON string, e.g.:
json.loads(resources["ros-mcp://ros-metadata/all"]()) → dict
Reaches into FastMCP's private resource registry, mirroring how the ``tools``
fixture above reads ``_tool_manager._tools`` — kept consistent on purpose.
"""
mcp = FastMCP("test-ros-mcp")
register_all_resources(mcp, ws)
return {uri: res.fn for uri, res in mcp._resource_manager._resources.items()}