/ tests / hermes_cli / test_plugin_scanner_recursion.py
test_plugin_scanner_recursion.py
  1  """Tests for PR1 pluggable image gen: scanner recursion, kinds, path keys.
  2  
  3  Covers ``_scan_directory`` recursion into category namespaces
  4  (``plugins/image_gen/openai/``), ``kind`` parsing, path-derived registry
  5  keys, and the new gate logic (bundled backends auto-load; user backends
  6  still opt-in; exclusive kind skipped; unknown kinds → standalone warning).
  7  """
  8  
  9  from __future__ import annotations
 10  
 11  from pathlib import Path
 12  from typing import Any, Dict
 13  
 14  import pytest
 15  import yaml
 16  
 17  from hermes_cli.plugins import PluginManager, PluginManifest
 18  
 19  
 20  # ── Helpers ────────────────────────────────────────────────────────────────
 21  
 22  
 23  def _write_plugin(
 24      root: Path,
 25      segments: list[str],
 26      *,
 27      manifest_extra: Dict[str, Any] | None = None,
 28      register_body: str = "pass",
 29  ) -> Path:
 30      """Create a plugin dir at ``root/<segments...>/`` with plugin.yaml + __init__.py.
 31  
 32      ``segments`` lets tests build both flat (``["my-plugin"]``) and
 33      category-namespaced (``["image_gen", "openai"]``) layouts.
 34      """
 35      plugin_dir = root
 36      for seg in segments:
 37          plugin_dir = plugin_dir / seg
 38      plugin_dir.mkdir(parents=True, exist_ok=True)
 39  
 40      manifest = {
 41          "name": segments[-1],
 42          "version": "0.1.0",
 43          "description": f"Test plugin {'/'.join(segments)}",
 44      }
 45      if manifest_extra:
 46          manifest.update(manifest_extra)
 47      (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest))
 48      (plugin_dir / "__init__.py").write_text(
 49          f"def register(ctx):\n    {register_body}\n"
 50      )
 51      return plugin_dir
 52  
 53  
 54  def _enable(hermes_home: Path, name: str) -> None:
 55      """Append ``name`` to ``plugins.enabled`` in ``<hermes_home>/config.yaml``."""
 56      cfg_path = hermes_home / "config.yaml"
 57      cfg: dict = {}
 58      if cfg_path.exists():
 59          try:
 60              cfg = yaml.safe_load(cfg_path.read_text()) or {}
 61          except Exception:
 62              cfg = {}
 63      plugins_cfg = cfg.setdefault("plugins", {})
 64      enabled = plugins_cfg.setdefault("enabled", [])
 65      if isinstance(enabled, list) and name not in enabled:
 66          enabled.append(name)
 67      cfg_path.write_text(yaml.safe_dump(cfg))
 68  
 69  
 70  # ── Scanner recursion ──────────────────────────────────────────────────────
 71  
 72  
 73  class TestCategoryNamespaceRecursion:
 74      def test_category_namespace_discovered(self, tmp_path, monkeypatch):
 75          """``<root>/image_gen/openai/plugin.yaml`` is discovered with key
 76          ``image_gen/openai`` when the ``image_gen`` parent has no manifest."""
 77          import os
 78          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
 79          user_plugins = hermes_home / "plugins"
 80  
 81          _write_plugin(user_plugins, ["image_gen", "openai"])
 82          _enable(hermes_home, "image_gen/openai")
 83  
 84          mgr = PluginManager()
 85          mgr.discover_and_load()
 86  
 87          assert "image_gen/openai" in mgr._plugins
 88          loaded = mgr._plugins["image_gen/openai"]
 89          assert loaded.manifest.key == "image_gen/openai"
 90          assert loaded.manifest.name == "openai"
 91          assert loaded.enabled is True
 92  
 93      def test_flat_plugin_key_matches_name(self, tmp_path, monkeypatch):
 94          """Flat plugins keep their bare name as the key (back-compat)."""
 95          import os
 96          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
 97          user_plugins = hermes_home / "plugins"
 98  
 99          _write_plugin(user_plugins, ["my-plugin"])
100          _enable(hermes_home, "my-plugin")
101  
102          mgr = PluginManager()
103          mgr.discover_and_load()
104  
105          assert "my-plugin" in mgr._plugins
106          assert mgr._plugins["my-plugin"].manifest.key == "my-plugin"
107  
108      def test_depth_cap_two(self, tmp_path, monkeypatch):
109          """Plugins nested three levels deep are not discovered.
110  
111          ``<root>/a/b/c/plugin.yaml`` should NOT be picked up — cap is
112          two segments.
113          """
114          import os
115          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
116          user_plugins = hermes_home / "plugins"
117  
118          _write_plugin(user_plugins, ["a", "b", "c"])
119  
120          mgr = PluginManager()
121          mgr.discover_and_load()
122  
123          non_bundled = [
124              k for k, p in mgr._plugins.items()
125              if p.manifest.source != "bundled"
126          ]
127          assert non_bundled == []
128  
129      def test_category_dir_with_manifest_is_leaf(self, tmp_path, monkeypatch):
130          """If ``image_gen/plugin.yaml`` exists, ``image_gen`` itself IS the
131          plugin and its children are ignored."""
132          import os
133          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
134          user_plugins = hermes_home / "plugins"
135  
136          # parent has a manifest → stop recursing
137          _write_plugin(user_plugins, ["image_gen"])
138          # child also has a manifest — should NOT be found because we stop
139          # at the parent.
140          _write_plugin(user_plugins, ["image_gen", "openai"])
141          _enable(hermes_home, "image_gen")
142          _enable(hermes_home, "image_gen/openai")
143  
144          mgr = PluginManager()
145          mgr.discover_and_load()
146  
147          # The bundled plugins/image_gen/openai/ exists in the repo — filter
148          # it out so we're only asserting on the user-dir layout.
149          user_plugins_in_registry = {
150              k for k, p in mgr._plugins.items() if p.manifest.source != "bundled"
151          }
152          assert "image_gen" in user_plugins_in_registry
153          assert "image_gen/openai" not in user_plugins_in_registry
154  
155  
156  # ── Kind parsing ───────────────────────────────────────────────────────────
157  
158  
159  class TestKindField:
160      def test_default_kind_is_standalone(self, tmp_path, monkeypatch):
161          import os
162          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
163          _write_plugin(hermes_home / "plugins", ["p1"])
164          _enable(hermes_home, "p1")
165  
166          mgr = PluginManager()
167          mgr.discover_and_load()
168  
169          assert mgr._plugins["p1"].manifest.kind == "standalone"
170  
171      @pytest.mark.parametrize("kind", ["backend", "exclusive", "standalone"])
172      def test_valid_kinds_parsed(self, kind, tmp_path, monkeypatch):
173          import os
174          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
175          _write_plugin(
176              hermes_home / "plugins",
177              ["p1"],
178              manifest_extra={"kind": kind},
179          )
180          # Not all kinds auto-load, but manifest should parse.
181          _enable(hermes_home, "p1")
182  
183          mgr = PluginManager()
184          mgr.discover_and_load()
185  
186          assert "p1" in mgr._plugins
187          assert mgr._plugins["p1"].manifest.kind == kind
188  
189      def test_unknown_kind_falls_back_to_standalone(self, tmp_path, monkeypatch, caplog):
190          import os
191          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
192          _write_plugin(
193              hermes_home / "plugins",
194              ["p1"],
195              manifest_extra={"kind": "bogus"},
196          )
197          _enable(hermes_home, "p1")
198  
199          with caplog.at_level("WARNING"):
200              mgr = PluginManager()
201              mgr.discover_and_load()
202  
203          assert mgr._plugins["p1"].manifest.kind == "standalone"
204          assert any(
205              "unknown kind" in rec.getMessage() for rec in caplog.records
206          )
207  
208  
209  # ── Gate logic ─────────────────────────────────────────────────────────────
210  
211  
212  class TestBackendGate:
213      def test_user_backend_still_gated_by_enabled(self, tmp_path, monkeypatch):
214          """User-installed ``kind: backend`` plugins still require opt-in —
215          they're not trusted by default."""
216          import os
217          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
218          user_plugins = hermes_home / "plugins"
219  
220          _write_plugin(
221              user_plugins,
222              ["image_gen", "fancy"],
223              manifest_extra={"kind": "backend"},
224          )
225          # Do NOT opt in.
226  
227          mgr = PluginManager()
228          mgr.discover_and_load()
229  
230          loaded = mgr._plugins["image_gen/fancy"]
231          assert loaded.enabled is False
232          assert "not enabled" in (loaded.error or "")
233  
234      def test_user_backend_loads_when_enabled(self, tmp_path, monkeypatch):
235          import os
236          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
237          user_plugins = hermes_home / "plugins"
238  
239          _write_plugin(
240              user_plugins,
241              ["image_gen", "fancy"],
242              manifest_extra={"kind": "backend"},
243          )
244          _enable(hermes_home, "image_gen/fancy")
245  
246          mgr = PluginManager()
247          mgr.discover_and_load()
248  
249          assert mgr._plugins["image_gen/fancy"].enabled is True
250  
251      def test_exclusive_kind_skipped(self, tmp_path, monkeypatch):
252          """``kind: exclusive`` plugins are recorded but not loaded — the
253          category's own discovery system handles them (memory today)."""
254          import os
255          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
256          _write_plugin(
257              hermes_home / "plugins",
258              ["some-backend"],
259              manifest_extra={"kind": "exclusive"},
260          )
261          _enable(hermes_home, "some-backend")
262  
263          mgr = PluginManager()
264          mgr.discover_and_load()
265  
266          loaded = mgr._plugins["some-backend"]
267          assert loaded.enabled is False
268          assert "exclusive" in (loaded.error or "")
269  
270  
271  # ── Bundled backend auto-load (integration with real bundled plugin) ────────
272  
273  
274  class TestBundledBackendAutoLoad:
275      def test_bundled_image_gen_openai_autoloads(self, tmp_path, monkeypatch):
276          """The bundled ``plugins/image_gen/openai/`` plugin loads without
277          any opt-in — it's ``kind: backend`` and shipped in-repo."""
278          import os
279          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
280  
281          mgr = PluginManager()
282          mgr.discover_and_load()
283  
284          assert "image_gen/openai" in mgr._plugins
285          loaded = mgr._plugins["image_gen/openai"]
286          assert loaded.manifest.source == "bundled"
287          assert loaded.manifest.kind == "backend"
288          assert loaded.enabled is True, f"error: {loaded.error}"
289  
290  
291  # ── PluginContext.register_image_gen_provider ───────────────────────────────
292  
293  
294  class TestRegisterImageGenProvider:
295      def test_accepts_valid_provider(self, tmp_path, monkeypatch):
296          from agent import image_gen_registry
297          from agent.image_gen_provider import ImageGenProvider
298  
299          image_gen_registry._reset_for_tests()
300  
301          class FakeProvider(ImageGenProvider):
302              @property
303              def name(self) -> str:
304                  return "fake-test"
305  
306              def generate(self, prompt, aspect_ratio="landscape", **kw):
307                  return {"success": True, "image": "test://fake"}
308  
309          import os
310          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
311          plugin_dir = _write_plugin(
312              hermes_home / "plugins",
313              ["my-img-plugin"],
314              register_body=(
315                  "from agent.image_gen_provider import ImageGenProvider\n"
316                  "    class P(ImageGenProvider):\n"
317                  "        @property\n"
318                  "        def name(self): return 'fake-ctx'\n"
319                  "        def generate(self, prompt, aspect_ratio='landscape', **kw):\n"
320                  "            return {'success': True, 'image': 'x://y'}\n"
321                  "    ctx.register_image_gen_provider(P())"
322              ),
323          )
324          _enable(hermes_home, "my-img-plugin")
325  
326          mgr = PluginManager()
327          mgr.discover_and_load()
328  
329          assert mgr._plugins["my-img-plugin"].enabled is True
330          assert image_gen_registry.get_provider("fake-ctx") is not None
331  
332          image_gen_registry._reset_for_tests()
333  
334      def test_rejects_non_provider(self, tmp_path, monkeypatch, caplog):
335          from agent import image_gen_registry
336  
337          image_gen_registry._reset_for_tests()
338  
339          import os
340          hermes_home = Path(os.environ["HERMES_HOME"])  # set by hermetic conftest fixture
341          _write_plugin(
342              hermes_home / "plugins",
343              ["bad-img-plugin"],
344              register_body="ctx.register_image_gen_provider('not a provider')",
345          )
346          _enable(hermes_home, "bad-img-plugin")
347  
348          with caplog.at_level("WARNING"):
349              mgr = PluginManager()
350              mgr.discover_and_load()
351  
352          # Plugin loaded (register returned normally) but nothing was
353          # registered in the provider registry.
354          assert mgr._plugins["bad-img-plugin"].enabled is True
355          assert image_gen_registry.get_provider("not a provider") is None
356  
357          image_gen_registry._reset_for_tests()