719 lines
27 KiB
Python
719 lines
27 KiB
Python
"""Tests for the platform adapter registry and dynamic Platform enum."""
|
|
|
|
import threading
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.platform_registry import PlatformRegistry, PlatformEntry
|
|
from gateway.config import Platform, GatewayConfig
|
|
from hermes_cli import plugins_loader
|
|
|
|
|
|
# ── Platform enum dynamic members ─────────────────────────────────────────
|
|
|
|
|
|
class TestPlatformEnumDynamic:
|
|
"""Test that Platform enum accepts unknown values for plugin platforms."""
|
|
|
|
|
|
|
|
def test_dynamic_member_case_normalised(self):
|
|
"""Mixed case normalised to lowercase."""
|
|
a = Platform("IRC")
|
|
b = Platform("irc")
|
|
assert a is b
|
|
assert a.value == "irc"
|
|
|
|
def test_dynamic_member_with_hyphens(self):
|
|
"""Registered plugin platforms with hyphens work once registered."""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
entry = PlatformEntry(
|
|
name="my-platform",
|
|
label="My Platform",
|
|
adapter_factory=lambda cfg: MagicMock(),
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
)
|
|
_reg.register(entry)
|
|
try:
|
|
p = Platform("my-platform")
|
|
assert p.value == "my-platform"
|
|
assert p.name == "MY_PLATFORM"
|
|
finally:
|
|
_reg.unregister("my-platform")
|
|
|
|
def test_bundled_manifest_name_alias_resolves_to_directory_member(self):
|
|
"""A bundled platform whose plugin.yaml ``name:`` differs from its directory (a2a vs
|
|
a2a-platform) resolves under the manifest name to the directory-name member (#116180)."""
|
|
import gateway.config as gc
|
|
|
|
gc._Platform__bundled_plugin_names = None # force a rescan of plugins/platforms/
|
|
gc._Platform__bundled_plugin_aliases = None
|
|
try:
|
|
by_dir = Platform("a2a")
|
|
by_manifest = Platform("a2a-platform")
|
|
assert by_manifest is by_dir
|
|
assert by_manifest.value == "a2a"
|
|
finally:
|
|
gc._Platform__bundled_plugin_names = None
|
|
gc._Platform__bundled_plugin_aliases = None
|
|
|
|
def test_config_keeps_platform_written_under_manifest_name(self):
|
|
"""``platforms.<manifest name>:`` in config.yaml is no longer silently dropped (#116180)."""
|
|
import gateway.config as gc
|
|
|
|
gc._Platform__bundled_plugin_names = None
|
|
gc._Platform__bundled_plugin_aliases = None
|
|
try:
|
|
config = GatewayConfig.from_dict({"platforms": {"a2a-platform": {"enabled": True}}})
|
|
assert Platform("a2a") in config.platforms
|
|
assert config.platforms[Platform("a2a")].enabled is True
|
|
finally:
|
|
gc._Platform__bundled_plugin_names = None
|
|
gc._Platform__bundled_plugin_aliases = None
|
|
|
|
def test_alias_never_shadows_a_directory_name(self):
|
|
"""An alias equal to another directory's name is dropped; directory names stay canonical."""
|
|
import gateway.config as gc
|
|
|
|
names, aliases = gc.Platform._scan_bundled_plugin_platforms()
|
|
assert "a2a" in names
|
|
assert set(aliases.values()) <= names
|
|
assert not (set(aliases) & names)
|
|
|
|
|
|
# ── PlatformRegistry ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPlatformRegistry:
|
|
"""Test the PlatformRegistry itself."""
|
|
|
|
def _make_entry(self, name="test", check_ok=True, validate_ok=True, factory_ok=True):
|
|
adapter_mock = MagicMock()
|
|
return PlatformEntry(
|
|
name=name,
|
|
label=name.title(),
|
|
adapter_factory=lambda cfg, _m=adapter_mock: _m if factory_ok else (_ for _ in ()).throw(RuntimeError("factory error")),
|
|
check_fn=lambda: check_ok,
|
|
validate_config=lambda cfg: validate_ok,
|
|
required_env=[],
|
|
source="plugin",
|
|
), adapter_mock
|
|
|
|
def test_register_and_get(self):
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._make_entry("alpha")
|
|
reg.register(entry)
|
|
assert reg.get("alpha") is entry
|
|
assert reg.is_registered("alpha")
|
|
|
|
|
|
def test_unregister(self):
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._make_entry("beta")
|
|
reg.register(entry)
|
|
assert reg.unregister("beta") is True
|
|
assert reg.get("beta") is None
|
|
assert reg.unregister("beta") is False # already gone
|
|
|
|
|
|
def test_create_adapter_no_validate(self):
|
|
"""When validate_config is None, skip validation."""
|
|
reg = PlatformRegistry()
|
|
mock_adapter = MagicMock()
|
|
entry = PlatformEntry(
|
|
name="novalidate",
|
|
label="NoValidate",
|
|
adapter_factory=lambda cfg: mock_adapter,
|
|
check_fn=lambda: True,
|
|
validate_config=None,
|
|
source="plugin",
|
|
)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("novalidate", MagicMock()) is mock_adapter
|
|
|
|
def test_registered_names_includes_deferred_without_materializing(self):
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._make_entry("concrete")
|
|
loader = MagicMock()
|
|
reg.register(entry)
|
|
reg.register_deferred("deferred", loader)
|
|
|
|
assert reg.registered_names() == {"concrete", "deferred"}
|
|
loader.assert_not_called()
|
|
assert reg.get("concrete") is entry
|
|
assert reg.is_registered("deferred")
|
|
|
|
|
|
class TestEnsureDepsFn:
|
|
"""check_fn (PASSIVE probe) vs ensure_deps_fn (ACTIVE installer) split.
|
|
|
|
Regression for #79812: Teams registered its passive probe as check_fn,
|
|
so create_adapter() returned None before connect() could lazy-install —
|
|
the SDK never installed. The inverse wiring (active installer as
|
|
check_fn) made status displays pip-install SDKs as a side effect.
|
|
create_adapter() now runs ensure_deps_fn when check_fn is False.
|
|
"""
|
|
|
|
def _entry(self, name, *, check_fn, ensure_deps_fn=None):
|
|
adapter = MagicMock()
|
|
entry = PlatformEntry(
|
|
name=name,
|
|
label=name.title(),
|
|
adapter_factory=lambda cfg: adapter,
|
|
check_fn=check_fn,
|
|
ensure_deps_fn=ensure_deps_fn,
|
|
source="plugin",
|
|
)
|
|
return entry, adapter
|
|
|
|
def test_deps_present_skips_installer(self):
|
|
"""check_fn True → adapter created, ensure_deps_fn never called."""
|
|
reg = PlatformRegistry()
|
|
installer = MagicMock(return_value=True)
|
|
entry, adapter = self._entry(
|
|
"ready", check_fn=lambda: True, ensure_deps_fn=installer
|
|
)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("ready", MagicMock()) is adapter
|
|
installer.assert_not_called()
|
|
|
|
def test_missing_deps_runs_installer_then_creates(self):
|
|
"""check_fn False + ensure_deps_fn True → install runs, adapter created."""
|
|
reg = PlatformRegistry()
|
|
installer = MagicMock(return_value=True)
|
|
entry, adapter = self._entry(
|
|
"installable", check_fn=lambda: False, ensure_deps_fn=installer
|
|
)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("installable", MagicMock()) is adapter
|
|
installer.assert_called_once()
|
|
|
|
def test_install_failure_returns_none(self):
|
|
"""check_fn False + ensure_deps_fn False → no adapter."""
|
|
reg = PlatformRegistry()
|
|
installer = MagicMock(return_value=False)
|
|
entry, _ = self._entry(
|
|
"broken", check_fn=lambda: False, ensure_deps_fn=installer
|
|
)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("broken", MagicMock()) is None
|
|
installer.assert_called_once()
|
|
|
|
def test_no_installer_missing_deps_returns_none(self):
|
|
"""check_fn False + no ensure_deps_fn → hard block (legacy behavior)."""
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._entry("blocked", check_fn=lambda: False)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("blocked", MagicMock()) is None
|
|
|
|
def test_installer_exception_returns_none(self):
|
|
"""ensure_deps_fn raising is caught, adapter not created."""
|
|
reg = PlatformRegistry()
|
|
|
|
def _boom():
|
|
raise RuntimeError("pip exploded")
|
|
|
|
entry, _ = self._entry(
|
|
"explosive", check_fn=lambda: False, ensure_deps_fn=_boom
|
|
)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("explosive", MagicMock()) is None
|
|
|
|
def test_check_fn_exception_falls_through_to_installer(self):
|
|
"""A raising check_fn is treated as deps-missing, installer still runs."""
|
|
reg = PlatformRegistry()
|
|
|
|
def _bad_probe():
|
|
raise RuntimeError("probe error")
|
|
|
|
installer = MagicMock(return_value=True)
|
|
entry, adapter = self._entry(
|
|
"flaky", check_fn=_bad_probe, ensure_deps_fn=installer
|
|
)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("flaky", MagicMock()) is adapter
|
|
installer.assert_called_once()
|
|
|
|
|
|
# ── GatewayConfig integration ────────────────────────────────────────────
|
|
|
|
|
|
class TestGatewayConfigPluginPlatform:
|
|
"""Test that GatewayConfig parses and validates plugin platforms."""
|
|
|
|
|
|
def test_get_connected_platforms_includes_registered_plugin(self):
|
|
"""Plugin platform with registry entry passes get_connected_platforms."""
|
|
# Register a fake plugin platform
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
test_entry = PlatformEntry(
|
|
name="testplat",
|
|
label="TestPlat",
|
|
adapter_factory=lambda cfg: MagicMock(),
|
|
check_fn=lambda: True,
|
|
validate_config=lambda cfg: bool(cfg.extra.get("token")),
|
|
source="plugin",
|
|
)
|
|
_reg.register(test_entry)
|
|
try:
|
|
data = {
|
|
"platforms": {
|
|
"testplat": {"enabled": True, "extra": {"token": "abc"}},
|
|
}
|
|
}
|
|
cfg = GatewayConfig.from_dict(data)
|
|
connected = cfg.get_connected_platforms()
|
|
connected_values = {p.value for p in connected}
|
|
assert "testplat" in connected_values
|
|
finally:
|
|
_reg.unregister("testplat")
|
|
|
|
|
|
# ── Extended PlatformEntry fields ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ── Cron platform resolution ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ── platforms.py integration ──────────────────────────────────────────
|
|
|
|
|
|
class TestPlatformsMerge:
|
|
"""Test get_all_platforms() merges with registry."""
|
|
|
|
|
|
def test_get_all_platforms_includes_plugin(self):
|
|
from hermes_cli.platforms import get_all_platforms
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="testmerge",
|
|
label="TestMerge",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
emoji="🧪",
|
|
))
|
|
try:
|
|
merged = get_all_platforms()
|
|
assert "testmerge" in merged
|
|
assert "TestMerge" in merged["testmerge"].label
|
|
finally:
|
|
_reg.unregister("testmerge")
|
|
|
|
|
|
# ── apply_yaml_config_fn (PlatformEntry field + load_gateway_config dispatch) ──
|
|
|
|
|
|
|
|
|
|
class TestApplyYamlConfigFnDispatch:
|
|
"""End-to-end dispatch through load_gateway_config().
|
|
|
|
Each test registers a temporary PlatformEntry, writes a config.yaml in
|
|
a tmp HERMES_HOME, calls load_gateway_config(), and asserts the hook
|
|
was invoked correctly. Cleanup unregisters the entry.
|
|
"""
|
|
|
|
def _write_config(self, tmp_path, content: str):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
|
|
return hermes_home
|
|
|
|
def _register_hook(self, name, hook_fn):
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
entry = PlatformEntry(
|
|
name=name,
|
|
label=name.title(),
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
apply_yaml_config_fn=hook_fn,
|
|
)
|
|
_reg.register(entry)
|
|
return _reg
|
|
|
|
|
|
def test_hook_exception_swallowed(self, tmp_path, monkeypatch):
|
|
"""A misbehaving hook never aborts load_gateway_config()."""
|
|
|
|
def _bad_hook(yaml_cfg, platform_cfg):
|
|
raise RuntimeError("plugin author bug")
|
|
|
|
# Also register a well-behaved hook to ensure dispatch continues
|
|
# iterating after a bad one.
|
|
good_called = {"count": 0}
|
|
|
|
def _good_hook(yaml_cfg, platform_cfg):
|
|
good_called["count"] += 1
|
|
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
_reg.register(PlatformEntry(
|
|
name="mybadplat",
|
|
label="MyBad",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
apply_yaml_config_fn=_bad_hook,
|
|
))
|
|
_reg.register(PlatformEntry(
|
|
name="mygoodplat",
|
|
label="MyGood",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
apply_yaml_config_fn=_good_hook,
|
|
))
|
|
try:
|
|
home = self._write_config(
|
|
tmp_path,
|
|
"mybadplat:\n k: v\n"
|
|
"mygoodplat:\n k: v\n",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
# Must not raise.
|
|
from gateway.config import load_gateway_config
|
|
load_gateway_config()
|
|
|
|
assert good_called["count"] == 1
|
|
finally:
|
|
_reg.unregister("mybadplat")
|
|
_reg.unregister("mygoodplat")
|
|
|
|
|
|
|
|
|
|
class TestPluginPlatformSharedKeyBridge:
|
|
"""Plugin-registered platforms get the same shared-key bridging as built-ins.
|
|
|
|
Without this, plugin authors using ``apply_yaml_config_fn`` would have to
|
|
re-implement bridging for every common key (``unauthorized_dm_behavior``,
|
|
``notice_delivery``, ``reply_prefix``, ``require_mention``, ``dm_policy``,
|
|
``allow_from``, etc.) — defeating the hook's whole point of letting
|
|
plugins focus on their *platform-specific* keys.
|
|
"""
|
|
|
|
def _write_config(self, tmp_path, content: str):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
|
|
return hermes_home
|
|
|
|
def test_shared_keys_bridged_for_plugin_platform(self, tmp_path, monkeypatch):
|
|
"""A plugin platform's ``require_mention``/``dm_policy``/etc. flow into
|
|
``PlatformConfig.extra`` without the plugin needing its own bridge."""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="mysharedplat",
|
|
label="MySharedPlat",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
))
|
|
try:
|
|
home = self._write_config(
|
|
tmp_path,
|
|
"mysharedplat:\n"
|
|
" require_mention: true\n"
|
|
" dm_policy: allow\n"
|
|
" reply_prefix: \"→ \"\n"
|
|
" allow_from: [\"alice\", \"bob\"]\n",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from gateway.config import load_gateway_config, Platform
|
|
cfg = load_gateway_config()
|
|
|
|
plat = Platform("mysharedplat")
|
|
assert plat in cfg.platforms
|
|
extra = cfg.platforms[plat].extra
|
|
assert extra.get("require_mention") is True
|
|
assert extra.get("dm_policy") == "allow"
|
|
assert extra.get("reply_prefix") == "→ "
|
|
assert extra.get("allow_from") == ["alice", "bob"]
|
|
finally:
|
|
_reg.unregister("mysharedplat")
|
|
|
|
|
|
class TestPluginEnablementGate:
|
|
"""Plugin platforms must NOT auto-enable on check_fn alone (#31116).
|
|
|
|
When a plugin registers ``is_connected`` (the "did the user actually
|
|
configure credentials" probe), ``load_gateway_config`` must consult it
|
|
before flipping ``enabled = True``. Without this gate, ``check_fn``
|
|
semantics ("the SDK is importable") get conflated with "the user wants
|
|
this platform on", and the gateway tries to connect to e.g. Discord
|
|
with no token — emitting noisy retry-forever errors on every fresh
|
|
install that has the plugin loaded.
|
|
"""
|
|
|
|
def _write_config(self, tmp_path, content: str = ""):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
|
|
return hermes_home
|
|
|
|
def test_plugin_with_is_connected_false_is_NOT_enabled(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""check_fn=True + is_connected=False must NOT enable the platform.
|
|
|
|
Reproduces #31116: Discord plugin loads, its check_fn lazy-installs
|
|
discord.py and returns True, but the user has no DISCORD_BOT_TOKEN.
|
|
Previously this auto-enabled Discord and the gateway spammed
|
|
``ERROR ... [Discord] No bot token configured`` on every reconnect.
|
|
"""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="myunconfiguredplat",
|
|
label="MyUnconfigured",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True, # SDK available
|
|
is_connected=lambda cfg: False, # but user hasn't set credentials
|
|
source="plugin",
|
|
))
|
|
try:
|
|
home = self._write_config(tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from gateway.config import load_gateway_config, Platform
|
|
cfg = load_gateway_config()
|
|
|
|
plat = Platform("myunconfiguredplat")
|
|
# Either absent entirely, or present but explicitly disabled.
|
|
if plat in cfg.platforms:
|
|
assert cfg.platforms[plat].enabled is False, (
|
|
"Plugin with is_connected=False must NOT be auto-enabled"
|
|
)
|
|
finally:
|
|
_reg.unregister("myunconfiguredplat")
|
|
|
|
|
|
def test_is_connected_raises_does_not_enable(self, tmp_path, monkeypatch):
|
|
"""A buggy is_connected must not silently enable the platform.
|
|
|
|
Treat a raising is_connected as "configuration unknown" — refuse to
|
|
enable, log, and move on. Anything else would re-introduce the
|
|
#31116 bug for plugins whose probe has a transient failure.
|
|
"""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
def _bad_probe(cfg):
|
|
raise RuntimeError("plugin bug")
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="mybadprobeplat",
|
|
label="MyBadProbe",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
is_connected=_bad_probe,
|
|
source="plugin",
|
|
))
|
|
try:
|
|
home = self._write_config(tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from gateway.config import load_gateway_config, Platform
|
|
cfg = load_gateway_config()
|
|
|
|
plat = Platform("mybadprobeplat")
|
|
if plat in cfg.platforms:
|
|
assert cfg.platforms[plat].enabled is False
|
|
finally:
|
|
_reg.unregister("mybadprobeplat")
|
|
|
|
|
|
def test_is_connected_failed_gate_does_not_leak_extras(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""When the gate rejects, env-seeded extras must NOT leak onto
|
|
``config.platforms``. A rejected plugin should be invisible, not
|
|
present-but-partially-populated.
|
|
"""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="myrejectedplat",
|
|
label="MyRejected",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
is_connected=lambda cfg: False,
|
|
env_enablement_fn=lambda: {"some_key": "should-not-leak"},
|
|
source="plugin",
|
|
))
|
|
try:
|
|
home = self._write_config(tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from gateway.config import load_gateway_config, Platform
|
|
cfg = load_gateway_config()
|
|
|
|
plat = Platform("myrejectedplat")
|
|
if plat in cfg.platforms:
|
|
assert cfg.platforms[plat].enabled is False
|
|
assert "some_key" not in cfg.platforms[plat].extra, (
|
|
"Rejected plugin's env-seeded extras leaked onto "
|
|
"config.platforms"
|
|
)
|
|
finally:
|
|
_reg.unregister("myrejectedplat")
|
|
|
|
def test_missing_deps_with_installer_still_enables(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""is_connected=True + check_fn=False + ensure_deps_fn set → ENABLED.
|
|
|
|
The install is deferred to ``create_adapter()`` at gateway start
|
|
(#79812). Skipping enablement here would mean a configured platform
|
|
whose SDK isn't installed yet never gets the chance to install it.
|
|
"""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
installer = MagicMock(return_value=True)
|
|
_reg.register(PlatformEntry(
|
|
name="myinstallableplat",
|
|
label="MyInstallable",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: False, # SDK not installed yet
|
|
ensure_deps_fn=installer, # ...but installable on demand
|
|
is_connected=lambda cfg: True, # user configured credentials
|
|
source="plugin",
|
|
))
|
|
try:
|
|
home = self._write_config(tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from gateway.config import load_gateway_config, Platform
|
|
cfg = load_gateway_config()
|
|
|
|
plat = Platform("myinstallableplat")
|
|
assert plat in cfg.platforms and cfg.platforms[plat].enabled, (
|
|
"Configured platform with a registered installer must be "
|
|
"enabled; the install runs at create_adapter() time"
|
|
)
|
|
# Config loading must NOT have run the installer (that's the
|
|
# desktop boot-loop bug — see module docstring of
|
|
# test_startup_no_eager_platform_install.py).
|
|
installer.assert_not_called()
|
|
finally:
|
|
_reg.unregister("myinstallableplat")
|
|
|
|
def test_missing_deps_without_installer_not_enabled(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""is_connected=True + check_fn=False + NO ensure_deps_fn → skipped.
|
|
|
|
Without an installer, missing deps are a hard block — enabling the
|
|
platform would just queue guaranteed connect failures.
|
|
"""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="myhardblockplat",
|
|
label="MyHardBlock",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: False,
|
|
is_connected=lambda cfg: True,
|
|
source="plugin",
|
|
))
|
|
try:
|
|
home = self._write_config(tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from gateway.config import load_gateway_config, Platform
|
|
cfg = load_gateway_config()
|
|
|
|
plat = Platform("myhardblockplat")
|
|
if plat in cfg.platforms:
|
|
assert cfg.platforms[plat].enabled is False
|
|
finally:
|
|
_reg.unregister("myhardblockplat")
|
|
|
|
|
|
class TestMigratedPlatformWiring:
|
|
"""Every lazy-installable bundled platform must register the split:
|
|
a PASSIVE check_fn plus an ACTIVE ensure_deps_fn (#79812).
|
|
|
|
Behavior contract, not a snapshot: asserts the two fields are distinct
|
|
callables (probe != installer), not specific function identities, so
|
|
renames don't churn this test. One discovery pass covers every platform.
|
|
"""
|
|
|
|
_LAZY_INSTALLABLE = (
|
|
"teams", "telegram", "discord", "slack",
|
|
"matrix", "dingtalk", "feishu", "wecom_callback",
|
|
"google_chat",
|
|
)
|
|
|
|
def test_lazy_installable_platforms_have_split_wiring(self):
|
|
from hermes_cli.plugins import discover_plugins
|
|
|
|
discover_plugins()
|
|
from gateway.platform_registry import platform_registry
|
|
|
|
# Materialize deferred loaders (wecom_callback is registered by the
|
|
# "wecom" manifest's loader; a cold get() by its own name misses).
|
|
platform_registry.plugin_entries()
|
|
for platform_name in self._LAZY_INSTALLABLE:
|
|
entry = platform_registry.get(platform_name)
|
|
assert entry is not None, f"{platform_name} not registered"
|
|
assert entry.ensure_deps_fn is not None, (
|
|
f"{platform_name} has a lazy-installable SDK but no "
|
|
"ensure_deps_fn — its deps can never auto-install "
|
|
"(the #79812 deadlock)"
|
|
)
|
|
assert entry.ensure_deps_fn is not entry.check_fn, (
|
|
f"{platform_name} registered the same callable for the passive "
|
|
"probe and the active installer — status displays would "
|
|
"pip-install as a side effect"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("timeout, nested_sees_sibling", [(0.5, False), (0, True)])
|
|
def test_registry_walk_from_plugin_load_worker_does_not_wait_on_parent(monkeypatch, timeout, nested_sees_sibling):
|
|
"""register() re-walking the registry on its deadline worker must not block on its own or a sibling load;
|
|
with the deadline disabled (inline loads) a nested get() must still load the sibling, as on main."""
|
|
monkeypatch.setattr(plugins_loader, "_resolve_plugin_load_timeout", lambda: timeout)
|
|
reg = PlatformRegistry()
|
|
abandoned = []
|
|
nested = []
|
|
|
|
class Ctx:
|
|
def _abandon_load(self):
|
|
abandoned.append(True)
|
|
|
|
discovery_lock = threading.RLock() # like PluginManager._discovery_lock, held while joining the worker
|
|
|
|
def make_loader(name):
|
|
def register():
|
|
reg.plugin_entries()
|
|
if name == "reentrant":
|
|
nested.append(reg.get("sibling") is not None)
|
|
reg.register(PlatformEntry(name=name, label=name, adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True, source="plugin"))
|
|
|
|
def loader():
|
|
with discovery_lock:
|
|
plugins_loader.run_with_load_deadline(name, Ctx(), register)
|
|
return loader
|
|
|
|
for name in ("reentrant", "sibling"):
|
|
reg.register_deferred(name, make_loader(name))
|
|
|
|
assert sorted(e.name for e in reg.plugin_entries()) == ["reentrant", "sibling"]
|
|
assert nested == [nested_sees_sibling]
|
|
assert abandoned == []
|