NODEDC_MISSION_CORE/tests/test_plugin_runtime.py

544 lines
18 KiB
Python

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,
host: str | None,
duration_seconds: float,
compatibility_attestation: CompatibilityAttestationRequest,
) -> dict[str, Any]:
self.calls.append(("live", (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_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,
{
"host": "192.168.1.20",
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"operator_confirmed": True,
},
},
"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,
{},
)
)