"""pytest fixtures for gammaray-mcp bridge tests. All paths are configurable via environment variables. run_tests.sh is the canonical configuration point — it sets sensible defaults and exports them before calling pytest. If you prefer to run pytest directly, set these: GAMMARAY_BIN — path to the gammaray probe binary GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries QML_RUNNER — path to the QML runtime executable TEST_QML — path to the test QML application BRIDGE_EXE — path to the bridge executable BRIDGE_RUN_SCRIPT — path to bridge/run.sh """ import os import socket import subprocess import time from pathlib import Path import pytest from mcp_client import McpClient PROJECT_ROOT = Path(__file__).resolve().parent.parent # All paths read from env vars, with sensible project-local defaults. GAMMARAY_BIN = Path(os.environ.get( "GAMMARAY_BIN", str(PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"), )) GAMMARAY_LIB_DIR = os.environ.get( "GAMMARAY_LIB_DIR", str(PROJECT_ROOT / "install-prefix" / "lib"), ) QML_RUNNER = Path(os.environ.get( "QML_RUNNER", "/usr/lib/qt6/bin/qml", )) TEST_QML = Path(os.environ.get( "TEST_QML", str(PROJECT_ROOT / "tests" / "testapp" / "main.qml"), )) BRIDGE_EXE = Path(os.environ.get( "BRIDGE_EXE", str(PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"), )) BRIDGE_RUN_SCRIPT = Path(os.environ.get( "BRIDGE_RUN_SCRIPT", str(PROJECT_ROOT / "bridge" / "run.sh"), )) def pytest_addoption(parser): parser.addoption( "--no-probe", action="store_true", default=False, help="Skip tests that need a running probe", ) parser.addoption( "--probe-timeout", type=int, default=15, help="Seconds to wait for probe to be ready", ) def pytest_collection_modifyitems(config, items): if config.getoption("--no-probe"): skip_probe = pytest.mark.skip(reason="Skipping probe-dependent tests (--no-probe)") for item in items: if "probe" in item.keywords: item.add_marker(skip_probe) @pytest.fixture(scope="session") def bridge_path() -> Path: if not BRIDGE_EXE.exists(): pytest.skip(f"Bridge not built at {BRIDGE_EXE}") return BRIDGE_EXE @pytest.fixture(scope="session") def run_script_path() -> Path: if not BRIDGE_RUN_SCRIPT.exists(): pytest.skip(f"run.sh not found at {BRIDGE_RUN_SCRIPT}") return BRIDGE_RUN_SCRIPT def _probe_accepting_connections(timeout: int = 20) -> bool: """Check if the probe is accepting connections via raw TCP.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: s = socket.create_connection(("127.0.0.1", 11732), timeout=2) s.close() return True except (OSError, socket.timeout): time.sleep(1) return False @pytest.fixture(scope="session") def probe_process(request) -> subprocess.Popen | None: """Start a GammaRay probe process (session-scoped, one per entire test run).""" if request.config.getoption("--no-probe"): return None if not GAMMARAY_BIN.exists(): pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}") if not TEST_QML.exists(): pytest.skip(f"Test QML file not found at {TEST_QML}") if not QML_RUNNER.exists(): pytest.skip(f"QML runner not found at {QML_RUNNER}") env = os.environ.copy() env["QT_QPA_PLATFORM"] = "offscreen" env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR subprocess.run( ["systemctl", "--user", "stop", "gammaray-probe-test.service"], capture_output=True, timeout=5, ) subprocess.run( ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"], capture_output=True, timeout=5, ) result = subprocess.run( [ "systemd-run", "--user", "--no-block", "--unit=gammaray-probe-test", "--same-dir", "--working-directory", str(TEST_QML.parent), "-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}", "-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}", str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", "--injector", "preload", str(QML_RUNNER), str(TEST_QML), ], capture_output=True, text=True, timeout=10, ) timeout = request.config.getoption("--probe-timeout") if not _probe_accepting_connections(timeout): # Fallback: direct spawn proc = subprocess.Popen( [str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", "--injector", "preload", str(QML_RUNNER), str(TEST_QML)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if not _probe_accepting_connections(timeout): pytest.fail(f"Probe did not become ready within {timeout}s") else: proc = subprocess.Popen(["true"]) def cleanup(): subprocess.run( ["systemctl", "--user", "stop", "gammaray-probe-test.service"], capture_output=True, timeout=5, ) subprocess.run( ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"], capture_output=True, timeout=5, ) request.addfinalizer(cleanup) return proc @pytest.fixture(scope="module") def bridge(run_script_path) -> McpClient: """Start a bridge instance per module, reused across tests in the module.""" c = McpClient(bridge_path=run_script_path) c.initialize() yield c try: c.call_tool("disconnectProbe", timeout=5) except Exception: pass c.close() time.sleep(0.2) @pytest.fixture(scope="module") def connected_bridge(bridge, probe_process) -> McpClient: """Bridge connected to a running probe (module-scoped, reused across tests).""" if probe_process is None: pytest.skip("No probe available (try --no-probe to skip probe-dependent tests)") deadline = time.monotonic() + 30 last_error = None while time.monotonic() < deadline: result = bridge.call_tool("connectProbeDefault") if isinstance(result, dict) and result.get("connected"): return bridge last_error = result time.sleep(1) raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")