from __future__ import annotations import asyncio import json import threading from datetime import UTC, datetime from pathlib import Path from typing import Any from uuid import uuid4 import pytest from missioncore_plugin_sdk.v0alpha2 import ( RuntimeActionInvocation, RuntimeActionResult, RuntimeHandshakeRequest, RuntimeHandshakeResult, RuntimePluginDescriptor, ) from pydantic import ValidationError import k1link.web.device_plugin_composition as plugin_composition from k1link.device_plugins.xgrids_k1.facade import ( ACTION_DISCOVERY_SCAN, ACTION_NETWORK_PROVISION, ACTION_STREAM_START_LIVE, ACTION_STREAM_START_REPLAY, ACTION_STREAM_STOP, ACTION_VIEWER_SETTINGS_UPDATE, XGRIDS_K1_PLUGIN_ID, XGRIDS_K1_PLUGIN_VERSION, CompatibilityAttestationRequest, ConnectRequest, ViewerSettingsRequest, XgridsK1PluginFacade, ) from k1link.web.device_plugin_composition import ( DevicePluginCompositionError, InstalledDevicePluginEnvironment, load_installed_device_plugins, ) from k1link.web.plugin_catalog import DevicePluginCatalog from k1link.web.plugin_runtime import ( DevicePluginDispatcher, DevicePluginRuntimeContribution, InProcessDevicePluginRuntime, PluginActionNotFoundError, PluginExecutionError, PluginNotFoundError, PluginRuntimeCompatibilityError, PluginRuntimeUnavailableError, ) class FakeXgridsService: def __init__(self) -> None: self.calls: list[tuple[str, object]] = [] def state(self) -> dict[str, Any]: self.calls.append(("state", None)) return {"phase": "idle"} async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: self.calls.append(("scan", duration_seconds)) return {"phase": "idle", "devices": []} async def connect(self, request: ConnectRequest) -> dict[str, Any]: self.calls.append(("connect", request)) return {"phase": "connected", "k1_ip": "192.168.1.20"} def start_live( self, project_name: str, host: str | None, duration_seconds: float, compatibility_attestation: CompatibilityAttestationRequest, ) -> dict[str, Any]: self.calls.append( ("live", (project_name, host, duration_seconds, compatibility_attestation)) ) return {"phase": "live"} def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: self.calls.append(("replay", (path, speed, loop))) return {"phase": "replay"} def stop(self) -> dict[str, Any]: self.calls.append(("stop", None)) return {"phase": "idle"} def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: self.calls.append(("viewer", request)) return {"phase": "idle", "viewer_settings": request.model_dump()} def _in_process_runtime( adapter: Any, *, plugin_version: str = XGRIDS_K1_PLUGIN_VERSION, host_api_version: str = "missioncore.nodedc/v1alpha2", close: Any | None = None, activate: bool = True, ) -> InProcessDevicePluginRuntime: runtime = InProcessDevicePluginRuntime( adapter, RuntimePluginDescriptor( plugin_id=adapter.plugin_id, plugin_version=plugin_version, supported_host_api_versions=(host_api_version,), action_ids=tuple(sorted(adapter.action_ids)), ), **({"close": close} if close is not None else {}), ) if activate: runtime.handshake( RuntimeHandshakeRequest( handshake_id=uuid4().hex, plugin_id=adapter.plugin_id, plugin_version=plugin_version, host_api_version=host_api_version, requested_at=datetime.now(UTC), required_action_ids=tuple(sorted(adapter.action_ids)), ) ) return runtime def test_manifest_and_runtime_facade_declare_identical_actions() -> None: repository_root = Path(__file__).resolve().parents[1] manifest = next( candidate for candidate in DevicePluginCatalog(repository_root).manifests() if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID ) assert {action.id for action in manifest.spec.actions} == XgridsK1PluginFacade.action_ids def test_repository_runtime_composition_exactly_matches_catalog() -> None: repository_root = Path(__file__).resolve().parents[1] environment = load_installed_device_plugins(repository_root) try: assert set(environment.dispatcher.action_declarations) == { manifest.metadata.id for manifest in environment.catalog.manifests() } finally: environment.close() def test_composition_closes_a_runtime_that_does_not_match_its_manifest( monkeypatch: pytest.MonkeyPatch, ) -> None: repository_root = Path(__file__).resolve().parents[1] manifest = next( candidate for candidate in DevicePluginCatalog(repository_root).manifests() if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID ) mismatched_manifest = manifest.model_copy( update={"metadata": manifest.metadata.model_copy(update={"id": "example.other-plugin"})} ) closed: list[bool] = [] contribution = DevicePluginRuntimeContribution( runtime=_in_process_runtime( XgridsK1PluginFacade(FakeXgridsService()), close=lambda: closed.append(True), activate=False, ), ) monkeypatch.setattr( plugin_composition, "_load_factory", lambda _: lambda __: contribution, ) with pytest.raises(DevicePluginCompositionError, match="id mismatch"): plugin_composition._load_contribution(repository_root, mismatched_manifest) assert closed == [True] def test_composition_rejects_an_uncorrelated_runtime_handshake( monkeypatch: pytest.MonkeyPatch, ) -> None: class UncorrelatedHandshakeRuntime(InProcessDevicePluginRuntime): def handshake( self, request: RuntimeHandshakeRequest, ) -> RuntimeHandshakeResult: result = super().handshake(request) return result.model_copy(update={"handshake_id": "another-handshake"}) repository_root = Path(__file__).resolve().parents[1] manifest = next( candidate for candidate in DevicePluginCatalog(repository_root).manifests() if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID ) closed: list[bool] = [] adapter = XgridsK1PluginFacade(FakeXgridsService()) runtime = UncorrelatedHandshakeRuntime( adapter, RuntimePluginDescriptor( plugin_id=adapter.plugin_id, plugin_version=XGRIDS_K1_PLUGIN_VERSION, supported_host_api_versions=(manifest.apiVersion,), action_ids=tuple(sorted(adapter.action_ids)), ), close=lambda: closed.append(True), ) monkeypatch.setattr( plugin_composition, "_load_factory", lambda _: lambda __: DevicePluginRuntimeContribution(runtime=runtime), ) with pytest.raises(DevicePluginCompositionError, match="handshake is inconsistent"): plugin_composition._load_contribution(repository_root, manifest) assert closed == [True] def test_composition_loads_and_dispatches_two_synthetic_plugins( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: class SyntheticAdapter: action_ids = frozenset({"state.read"}) def __init__(self, plugin_id: str) -> None: self.plugin_id = plugin_id async def invoke( self, invocation: RuntimeActionInvocation, ) -> dict[str, Any]: assert invocation.plugin_id == self.plugin_id assert invocation.action_id == "state.read" assert invocation.parameters == {} return {"plugin_id": self.plugin_id} closed: list[str] = [] contributions = { f"synthetic:{suffix}": DevicePluginRuntimeContribution( runtime=_in_process_runtime( SyntheticAdapter(f"example.{suffix}"), plugin_version="0.1.0", host_api_version="missioncore.nodedc/v1alpha1", close=lambda suffix=suffix: closed.append(suffix), activate=False, ), ) for suffix in ("first", "second") } for suffix in ("first", "second"): document = { "apiVersion": "missioncore.nodedc/v1alpha1", "kind": "DevicePlugin", "metadata": { "id": f"example.{suffix}", "version": "0.1.0", "displayName": f"Example {suffix}", }, "spec": { "hostApiRange": "v1alpha1", "runtime": { "backendEntrypoint": f"synthetic:{suffix}", "isolation": "transitional-in-process", }, "permissions": [], "actions": [{"id": "state.read", "mutating": False, "secretFields": []}], "models": [ { "id": f"example.{suffix}-model", "vendor": "Example", "displayName": f"Example {suffix}", "category": "Synthetic", "description": "Synthetic runtime composition fixture.", "verified": False, "capabilities": [], "ui": { "slot": "device.connection", "componentKey": f"example.{suffix}.connection", }, } ], }, } manifest_path = tmp_path / "plugins" / suffix / "plugin.manifest.json" manifest_path.parent.mkdir(parents=True) manifest_path.write_text(json.dumps(document), encoding="utf-8") monkeypatch.setattr( plugin_composition, "_load_factory", lambda entrypoint: lambda _: contributions[entrypoint], ) environment = load_installed_device_plugins(tmp_path) try: for suffix in ("first", "second"): state = asyncio.run( environment.dispatcher.invoke(f"example.{suffix}", "state.read", {}) ) assert state == {"plugin_id": f"example.{suffix}"} assert {item["status"] for item in environment.runtime_health} == {"ready"} finally: environment.close() assert closed == ["second", "first"] def test_environment_shutdown_attempts_every_plugin_after_close_failure( tmp_path: Path, ) -> None: closed: list[str] = [] def failing_close() -> None: closed.append("failing") raise RuntimeError("synthetic close failure") environment = InstalledDevicePluginEnvironment( catalog=DevicePluginCatalog(tmp_path), dispatcher=DevicePluginDispatcher([]), legacy_routers=(), _contributions=( DevicePluginRuntimeContribution( runtime=_in_process_runtime( XgridsK1PluginFacade(FakeXgridsService()), close=lambda: closed.append("healthy"), ), ), DevicePluginRuntimeContribution( runtime=_in_process_runtime( XgridsK1PluginFacade(FakeXgridsService()), close=failing_close, ), ), ), ) with pytest.raises(DevicePluginCompositionError, match="failed during shutdown") as raised: environment.close() assert closed == ["failing", "healthy"] assert "synthetic close failure" in " ".join(raised.value.__notes__) def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None: service = FakeXgridsService() dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) state = asyncio.run( dispatcher.invoke( XGRIDS_K1_PLUGIN_ID, ACTION_DISCOVERY_SCAN, {"duration_seconds": 6}, ) ) assert state == {"phase": "idle", "devices": []} assert service.calls == [("scan", 6.0)] def test_runtime_classifies_non_json_plugin_output_as_execution_failure( caplog: pytest.LogCaptureFixture, ) -> None: retained_marker = "must-not-appear-in-the-server-log" class NonJsonAdapter: plugin_id = "example.non-json" action_ids = frozenset({"state.read"}) async def invoke( self, _invocation: RuntimeActionInvocation, ) -> dict[str, Any]: return {"dialogue": {"response_evidence": (retained_marker,)}} dispatcher = DevicePluginDispatcher( [ _in_process_runtime( NonJsonAdapter(), plugin_version="0.1.0", host_api_version="missioncore.nodedc/v1alpha2", ) ] ) with pytest.raises(PluginExecutionError, match="non-JSON"): asyncio.run(dispatcher.invoke("example.non-json", "state.read", {})) assert retained_marker not in caplog.text def test_dispatcher_rejects_unknown_plugin_and_action() -> None: dispatcher = DevicePluginDispatcher( [_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))] ) with pytest.raises(PluginNotFoundError): asyncio.run(dispatcher.invoke("missing.plugin", ACTION_STREAM_STOP, {})) with pytest.raises(PluginActionNotFoundError): asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, "unknown.action", {})) def test_facade_validates_payload_before_calling_service() -> None: service = FakeXgridsService() dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) with pytest.raises(ValidationError): asyncio.run( dispatcher.invoke( XGRIDS_K1_PLUGIN_ID, ACTION_NETWORK_PROVISION, { "device_id": "id", "ssid": "network", "password": "x" * 24, "extra": True, }, ) ) assert service.calls == [] @pytest.mark.parametrize( ("action_id", "payload", "expected_call"), [ ( ACTION_STREAM_START_LIVE, { "project_name": "Plugin runtime test", "host": "192.168.1.20", "compatibility_attestation": { "firmware_version": "3.0.2", "topology": "direct-lan", "verification": "live-device-info", }, }, "live", ), ( ACTION_STREAM_START_REPLAY, {"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False}, "replay", ), (ACTION_STREAM_STOP, {}, "stop"), ( ACTION_VIEWER_SETTINGS_UPDATE, { "point_size": 3, "color_mode": "height", "palette": "viridis", "custom_color": "#102030", "accumulation_seconds": 12, "show_points": True, "show_trajectory": True, "show_grid": False, }, "viewer", ), ], ) def test_facade_preserves_existing_runtime_operations( action_id: str, payload: dict[str, object], expected_call: str, ) -> None: service = FakeXgridsService() dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload)) assert service.calls[0][0] == expected_call def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None: class ThreadAwareService(FakeXgridsService): thread_id: int | None = None def stop(self) -> dict[str, Any]: self.thread_id = threading.get_ident() return super().stop() service = ThreadAwareService() dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) event_loop_thread = threading.get_ident() asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {})) assert service.thread_id is not None assert service.thread_id != event_loop_thread def test_runtime_requires_successful_handshake_before_dispatch() -> None: adapter = XgridsK1PluginFacade(FakeXgridsService()) runtime = _in_process_runtime(adapter, activate=False) dispatcher = DevicePluginDispatcher([runtime]) assert runtime.health().status == "starting" with pytest.raises(PluginRuntimeUnavailableError, match="not ready"): asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {})) with pytest.raises(PluginRuntimeCompatibilityError, match="host API"): runtime.handshake( RuntimeHandshakeRequest( handshake_id="handshake-incompatible", plugin_id=XGRIDS_K1_PLUGIN_ID, plugin_version=XGRIDS_K1_PLUGIN_VERSION, host_api_version="missioncore.nodedc/v1alpha1", requested_at=datetime.now(UTC), required_action_ids=tuple(sorted(adapter.action_ids)), ) ) assert runtime.health().status == "starting" def test_runtime_health_transitions_to_stopped_and_close_is_idempotent() -> None: closed: list[bool] = [] runtime = _in_process_runtime( XgridsK1PluginFacade(FakeXgridsService()), close=lambda: closed.append(True), ) assert runtime.health().status == "ready" runtime.close() runtime.close() assert runtime.health().status == "stopped" assert closed == [True] def test_dispatcher_rejects_uncorrelated_transport_result() -> None: class UncorrelatedRuntime(InProcessDevicePluginRuntime): async def invoke( self, invocation: RuntimeActionInvocation, ) -> RuntimeActionResult: result = await super().invoke(invocation) return result.model_copy(update={"invocation_id": "another-invocation"}) adapter = XgridsK1PluginFacade(FakeXgridsService()) descriptor = RuntimePluginDescriptor( plugin_id=adapter.plugin_id, plugin_version=XGRIDS_K1_PLUGIN_VERSION, supported_host_api_versions=("missioncore.nodedc/v1alpha2",), action_ids=tuple(sorted(adapter.action_ids)), ) runtime = UncorrelatedRuntime(adapter, descriptor) runtime.handshake( RuntimeHandshakeRequest( handshake_id="handshake-correlation", plugin_id=adapter.plugin_id, plugin_version=XGRIDS_K1_PLUGIN_VERSION, host_api_version="missioncore.nodedc/v1alpha2", requested_at=datetime.now(UTC), required_action_ids=descriptor.action_ids, ) ) with pytest.raises(PluginExecutionError, match="uncorrelated"): asyncio.run( DevicePluginDispatcher([runtime]).invoke( XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}, ) )