"""Shared fixtures: temp XDG dirs, scriptable fake QMP client, in-memory MCP client.""" import asyncio import inspect import json from types import SimpleNamespace import pytest from fastmcp import Client from mcqemu.server import mcp @pytest.fixture def dirs(tmp_path, monkeypatch): state = tmp_path / "state" run = tmp_path / "run" state.mkdir() run.mkdir() monkeypatch.setenv("MCQEMU_STATE_DIR", str(state)) monkeypatch.setenv("MCQEMU_RUNTIME_DIR", str(run)) return SimpleNamespace(state=state, run=run) def write_registry(dirs, *records) -> None: """Pre-seed the registry file before a Client connects (lifespan loads it).""" (dirs.state / "vms.json").write_text(json.dumps({r.name: r.to_dict() for r in records})) @pytest.fixture async def client(dirs): async with Client(mcp) as c: yield c def result_data(result): # Empty-list results carry no text content block; .data covers both shapes. if result.data is not None: return result.data return json.loads(result.content[0].text) class FakeQMPClient: """Stands in for qemu.qmp.QMPClient. Script it via class attributes: responses: dict command -> canned reply, Exception to raise, or callable(args) events_to_emit: list of event dicts fed to the events queue connect_error: exception raised from connect() calls: recorded (command, arguments) tuples """ responses: dict = {} events_to_emit: list = [] connect_error: Exception | None = None calls: list = [] @classmethod def reset(cls): cls.responses = {} cls.events_to_emit = [] cls.connect_error = None cls.calls = [] def __init__(self, name: str): self.name = name self.await_greeting = True self.negotiate = True self._events: asyncio.Queue = asyncio.Queue() async def connect(self, address): if FakeQMPClient.connect_error is not None: raise FakeQMPClient.connect_error for event in FakeQMPClient.events_to_emit: self._events.put_nowait(event) async def disconnect(self): pass async def execute(self, command, arguments=None): FakeQMPClient.calls.append((command, arguments)) reply = FakeQMPClient.responses.get(command, {}) if isinstance(reply, Exception): raise reply if callable(reply): reply = reply(arguments) # Awaitable replies let a test simulate an agent that stops answering. if inspect.isawaitable(reply): reply = await reply return reply @property def events(self): return self._events @pytest.fixture def fake_qmp(monkeypatch): FakeQMPClient.reset() monkeypatch.setattr("mcqemu.qmp.QMPClient", FakeQMPClient) monkeypatch.setattr("mcqemu.qga.QMPClient", FakeQMPClient) return FakeQMPClient # Every module that imported pid_alive into its own namespace needs patching, # or a "dead" fixture leaves one module still seeing live processes. _PID_ALIVE_REFS = ( "mcqemu.registry.pid_alive", "mcqemu.qmp.pid_alive", "mcqemu.tools.lifecycle.pid_alive", "mcqemu.tools.sandbox.pid_alive", ) @pytest.fixture def all_pids_dead(monkeypatch): for ref in _PID_ALIVE_REFS: monkeypatch.setattr(ref, lambda pid: False) @pytest.fixture def all_pids_alive(monkeypatch): for ref in _PID_ALIVE_REFS: monkeypatch.setattr(ref, lambda pid: bool(pid))