fix(plugin): bind adapter to real Hermes gateway API; add contract-test gate
- Resolve the Hermes gateway API lazily via get_adapter_class() so the adapter
always inherits the real gateway.platforms.base.BasePlatformAdapter instead
of the ImportError fallback stub (root cause of the AttributeError:
'set_message_handler' crash at gateway startup).
- Pass Platform('meshtastic') enum to super().__init__ and align
connect(*, is_reconnect=False) -> bool with the gateway contract.
- Split pure formatting utils into formatting.py; remove the stub fallback.
- Add tests/contract (L0-L3): real-base binding, ABC instantiation, gateway
run.py wiring replay, signature and event-shape conformance, run against
hermes-agent pinned to the deployed image source (v2026.8.31).
- Add scripts/contract-test.sh and a contract-test CI job; release publish
now requires contract tests to pass.
- Untrack stray __pycache__ artifacts.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,230 @@
|
||||
"""Contract tests: hermes-meshtastic vs the REAL Hermes gateway API.
|
||||
|
||||
These tests exist so adapter development converges WITHOUT deploying: they
|
||||
replay the gateway's adapter bootstrap against the actual ``hermes-agent``
|
||||
package (pinned to the deployed image tag, see ``scripts/contract-test.sh``)
|
||||
and fail loudly on any API drift that used to surface only as a gateway
|
||||
crash after release.
|
||||
|
||||
Runtime ordering modelled here (verified against ``gateway/run.py`` and
|
||||
``gateway/config.py``):
|
||||
1. plugin discovery runs the plugin's ``register(ctx)`` (registers the
|
||||
platform in ``gateway.platform_registry``),
|
||||
2. the gateway later instantiates the adapter via the registry entry and
|
||||
wires it: ``set_message_handler`` -> ... -> ``connect()``,
|
||||
3. ``Platform("meshtastic")`` (auto-created enum member) is only valid
|
||||
AFTER registration — the enum's ``_missing_`` queries the registry.
|
||||
|
||||
Layers covered
|
||||
--------------
|
||||
L0 The adapter subclasses the REAL ``gateway.platforms.base.BasePlatformAdapter``
|
||||
(regression for the fallback-stub bug that caused the
|
||||
``AttributeError: ... 'set_message_handler'`` crash at ``gateway/run.py``).
|
||||
L0b ABC instantiation gate — construction with a real ``PlatformConfig`` fails
|
||||
if any abstract member is missing or the platform enum is misused.
|
||||
L1 Wiring replay — every ``set_*`` call ``gateway/run.py`` makes at startup
|
||||
must exist and accept a handler.
|
||||
L2 Signature conformance — ``connect``/``send`` match the gateway contract.
|
||||
L3 Event shape — the inbound ``MessageEvent``/``SessionSource`` construction
|
||||
must be valid against the real gateway types.
|
||||
|
||||
Skip policy: skipped when ``hermes-agent`` is not installed so the standalone
|
||||
unit suite (CI without Hermes) stays green. Set ``HERMES_CONTRACT_REQUIRED=1``
|
||||
to turn absence into a hard failure — the dedicated contract CI job sets this
|
||||
so the gate can never silently skip.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
|
||||
except ImportError: # pragma: no cover - exercised when hermes-agent is absent
|
||||
if os.environ.get("HERMES_CONTRACT_REQUIRED"):
|
||||
raise AssertionError(
|
||||
"hermes-agent is required for contract tests (HERMES_CONTRACT_REQUIRED=1). "
|
||||
"Run `scripts/contract-test.sh` or the CI contract job."
|
||||
)
|
||||
pytest.skip(
|
||||
"hermes-agent not installed — contract tests skipped (scripts/contract-test.sh)",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
import hermes_meshtastic # noqa: F401,E402 (package must import without gateway side effects)
|
||||
from hermes_meshtastic import adapter as plugin_adapter # noqa: E402
|
||||
|
||||
MINIMAL_CONFIG = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"host": "127.0.0.1", "port": 1, "channel_index": 1},
|
||||
)
|
||||
|
||||
# Exactly the set_* wiring gateway/run.py performs right before connect()
|
||||
# (observed at the crash site: adapter.set_message_handler(...) etc.).
|
||||
GATEWAY_WIRING_SETTERS = [
|
||||
"set_message_handler",
|
||||
"set_fatal_error_handler",
|
||||
"set_session_store",
|
||||
"set_busy_session_handler",
|
||||
"set_topic_recovery_fn",
|
||||
"set_authorization_check",
|
||||
"set_platform_event_handler",
|
||||
"set_reaction_handler",
|
||||
]
|
||||
|
||||
_REGISTER_KWARGS = {}
|
||||
|
||||
|
||||
class _Ctx:
|
||||
"""Minimal ctx mirroring how discovery calls the plugin's register()."""
|
||||
|
||||
def register_platform(self, **kwargs):
|
||||
_REGISTER_KWARGS.update(kwargs)
|
||||
|
||||
|
||||
def _noop(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def registered_entry():
|
||||
"""Replay discovery: run register(), then register the PlatformEntry.
|
||||
|
||||
Mirrors how gateway/run.py later instantiates the adapter from the
|
||||
registry — and why Platform("meshtastic") is valid only after this.
|
||||
"""
|
||||
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||
|
||||
hermes_meshtastic.register(_Ctx())
|
||||
kw = dict(_REGISTER_KWARGS)
|
||||
assert kw.get("name") == "meshtastic"
|
||||
|
||||
if not platform_registry.is_registered("meshtastic"):
|
||||
entry = PlatformEntry(
|
||||
name=kw["name"],
|
||||
label=kw["label"],
|
||||
adapter_factory=kw["adapter_factory"],
|
||||
check_fn=kw["check_fn"],
|
||||
required_env=[],
|
||||
max_message_length=kw.get("max_message_length", 0),
|
||||
emoji=kw.get("emoji", "🔌"),
|
||||
platform_hint=kw.get("platform_hint", ""),
|
||||
)
|
||||
platform_registry.register(entry)
|
||||
return platform_registry.get("meshtastic")
|
||||
|
||||
|
||||
def _make_adapter(entry=None):
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
if entry is None:
|
||||
entry = platform_registry.get("meshtastic")
|
||||
assert entry is not None, "meshtastic platform not registered (fixture order broken)"
|
||||
return entry.adapter_factory(MINIMAL_CONFIG)
|
||||
|
||||
|
||||
# --- L0: real base binding -------------------------------------------------
|
||||
|
||||
|
||||
def test_adapter_subclasses_real_gateway_base():
|
||||
"""Regression: adapter must bind the real base, never a local stub."""
|
||||
cls = plugin_adapter.get_adapter_class()
|
||||
assert inspect.isclass(cls)
|
||||
assert issubclass(cls, BasePlatformAdapter)
|
||||
assert cls.__mro__[1].__module__ == "gateway.platforms.base"
|
||||
|
||||
|
||||
def test_abstract_members_are_implemented():
|
||||
"""ABC gate: BasePlatformAdapter requires connect/send/disconnect/get_chat_info."""
|
||||
cls = plugin_adapter.get_adapter_class()
|
||||
for member in BasePlatformAdapter.__abstractmethods__:
|
||||
impl = getattr(cls, member, None)
|
||||
assert callable(impl), f"adapter missing abstract member: {member}"
|
||||
|
||||
|
||||
# --- L0b: instantiation against the real contract --------------------------
|
||||
|
||||
|
||||
def test_adapter_instantiates_with_real_platform_config(registered_entry):
|
||||
adapter = _make_adapter(registered_entry)
|
||||
assert isinstance(adapter, BasePlatformAdapter)
|
||||
# super().__init__ must receive the Platform enum (auto-created member),
|
||||
# not a bare string — the gateway calls Platform methods on adapter.platform.
|
||||
assert isinstance(adapter.platform, Platform)
|
||||
assert adapter.platform.value == "meshtastic"
|
||||
|
||||
|
||||
def test_registered_entry_carries_radio_limits(registered_entry):
|
||||
assert registered_entry.max_message_length == plugin_adapter.MAX_LORA_MESSAGE_LENGTH
|
||||
assert registered_entry.emoji == "📻"
|
||||
|
||||
|
||||
# --- L1: gateway wiring replay ---------------------------------------------
|
||||
|
||||
|
||||
def test_adapter_survives_gateway_wiring(registered_entry):
|
||||
"""Replay run.py's startup wiring; any missing setter crashes the gateway."""
|
||||
adapter = _make_adapter(registered_entry)
|
||||
for setter in GATEWAY_WIRING_SETTERS:
|
||||
fn = getattr(adapter, setter, None)
|
||||
assert callable(fn), (
|
||||
f"adapter missing callable '{setter}' — gateway startup crashes here "
|
||||
"(the original set_message_handler AttributeError)"
|
||||
)
|
||||
fn(_noop)
|
||||
# run.py also pokes these attributes directly after wiring.
|
||||
adapter._busy_text_mode = False
|
||||
|
||||
|
||||
# --- L2: signature conformance ----------------------------------------------
|
||||
|
||||
|
||||
def test_connect_signature_matches_gateway_contract():
|
||||
cls = plugin_adapter.get_adapter_class()
|
||||
sig = inspect.signature(cls.connect)
|
||||
assert inspect.iscoroutinefunction(cls.connect)
|
||||
assert "is_reconnect" in sig.parameters, "connect() must accept is_reconnect kwarg"
|
||||
param = sig.parameters["is_reconnect"]
|
||||
assert param.kind == inspect.Parameter.KEYWORD_ONLY
|
||||
assert param.default is False
|
||||
|
||||
|
||||
def test_send_signature_matches_gateway_contract():
|
||||
base_sig = inspect.signature(BasePlatformAdapter.send)
|
||||
cls = plugin_adapter.get_adapter_class()
|
||||
sig = inspect.signature(cls.send)
|
||||
base_params = [p for p in base_sig.parameters if p != "self"]
|
||||
own_params = [p for p in sig.parameters if p != "self"]
|
||||
assert own_params == base_params, (
|
||||
f"send() params {own_params} drift from gateway contract {base_params}"
|
||||
)
|
||||
assert inspect.iscoroutinefunction(cls.send)
|
||||
|
||||
|
||||
# --- L3: inbound event shape against real types -----------------------------
|
||||
|
||||
|
||||
def test_inbound_event_construction_is_valid(registered_entry):
|
||||
"""The exact source/event the adapter builds on receive must type-check."""
|
||||
adapter = _make_adapter(registered_entry)
|
||||
source = adapter.build_source(
|
||||
chat_id="!abcd1234",
|
||||
chat_name="Meshtastic !abcd1234",
|
||||
chat_type="dm",
|
||||
user_id="!abcd1234",
|
||||
user_name="!abcd1234",
|
||||
message_id="42",
|
||||
)
|
||||
event = MessageEvent(
|
||||
text="hello over LoRa",
|
||||
message_type=MessageType.TEXT,
|
||||
user_id="!abcd1234",
|
||||
user_name="!abcd1234",
|
||||
source=source,
|
||||
raw_message={"id": 42},
|
||||
message_id="42",
|
||||
)
|
||||
assert event.source.platform.value == "meshtastic"
|
||||
assert event.text == "hello over LoRa"
|
||||
Reference in New Issue
Block a user