"""Tests for plugins/__init__.py — namespace isolation for sibling modules and startup-time collision detection (feedBack#33). The plugin loader used to insert each plugin directory onto `sys.path`, which made bare `import sibling` fall through Python's per-name cache in `sys.modules`. Two plugins shipping a same-named top-level module (`extractor.py`, `util.py`, …) would step on each other. The loader now exposes `context['load_sibling'](name)` that loads the sibling under a namespaced module name `plugin_.` (with `.` in plugin_id bijectively encoded — `_` -> `_5f_`, `.` -> `_2e_`), plus a warning at startup so existing colliding plugins are visible. """ import contextlib import concurrent.futures import importlib import json import logging import sys from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @contextlib.contextmanager def capture_logger(caplog, logger_name, level=logging.WARNING): """Context manager that attaches *caplog.handler* directly to the named logger for the duration of the ``with`` block, then restores its original level and ``propagate`` flag. Bypasses pytest's default root-logger attachment so tests that set ``propagate=False`` on their logger still capture records even when the ``feedBack`` hierarchy hasn't set up handlers yet.""" logger = logging.getLogger(logger_name) orig_level, orig_propagate = logger.level, logger.propagate logger.addHandler(caplog.handler) logger.setLevel(level) logger.propagate = False try: yield logger finally: logger.removeHandler(caplog.handler) logger.setLevel(orig_level) logger.propagate = orig_propagate # Bare module names that this test module pre-populates into # sys.modules to simulate the bare-import path. Saved/restored by # the reset_plugin_state fixture so they don't leak to other test # files. Codex / Copilot review on PR for feedBack#33. _BARE_NAMES_USED = ("util", "extractor") @pytest.fixture() def reset_plugin_state(monkeypatch): """Clear loader module-level state and restore on teardown. Saves and restores: * `plugins.LOADED_PLUGINS` * any `plugin_*` keys we add to `sys.modules` * the bare names this module simulates (`util`, `extractor`) * `sys.path` — `plugins.load_plugins()` mutates it Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration (via monkeypatch) so a CI env that pre-sets it can't leak real user plugins into a tmp_path-driven test. Per-module locks are owned by the standard import system (`importlib._bootstrap._module_locks`) and are not our responsibility to reset. """ monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False) plugins = importlib.import_module("plugins") saved_loaded = list(plugins.LOADED_PLUGINS) saved_pending = dict(plugins.PENDING_PLUGINS) saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")} saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules} saved_path = list(sys.path) plugins.LOADED_PLUGINS.clear() plugins.PENDING_PLUGINS.clear() for k in list(sys.modules): if k.startswith("plugin_") or k in _BARE_NAMES_USED: del sys.modules[k] try: yield plugins finally: plugins.LOADED_PLUGINS.clear() plugins.LOADED_PLUGINS.extend(saved_loaded) plugins.PENDING_PLUGINS.clear() plugins.PENDING_PLUGINS.update(saved_pending) for k in list(sys.modules): if k.startswith("plugin_") or k in _BARE_NAMES_USED: del sys.modules[k] sys.modules.update(saved_modules) sys.modules.update(saved_bare) sys.path[:] = saved_path def _make_plugin(plugin_root, plugin_id, *, sibling_files=None, routes_body=None): """Create a minimal plugin directory under `plugin_root`. `sibling_files` is a dict of `{module_name: file_body}` written as `{module_name}.py` next to routes. `routes_body` is the contents of routes.py — defaults to a no-op `setup` so the plugin loads cleanly. """ plugin_dir = plugin_root / plugin_id plugin_dir.mkdir(parents=True) (plugin_dir / "plugin.json").write_text( json.dumps({"id": plugin_id, "name": plugin_id, "routes": "routes.py"}) ) (plugin_dir / "routes.py").write_text( routes_body if routes_body is not None else "def setup(app, ctx):\n pass\n" ) for name, body in (sibling_files or {}).items(): (plugin_dir / f"{name}.py").write_text(body) return plugin_dir def _run_load_plugins(plugins, app, tmp_path, context=None): """Drive load_plugins against a tmp plugin root, restoring module state on the way out so each test is isolated.""" saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = tmp_path try: plugins.load_plugins(app, context if context is not None else {}) finally: plugins.PLUGINS_DIR = saved_dir def test_load_sibling_returns_per_plugin_namespaced_modules(tmp_path, reset_plugin_state): """Two plugins shipping `extractor.py` with different exports must each see their OWN file via load_sibling — no cross-contamination.""" plugins = reset_plugin_state _make_plugin( tmp_path, "alpha", sibling_files={"extractor": "MANIFEST_DIR = 'alpha-manifest'\n"}, routes_body=( "def setup(app, ctx):\n" " extractor = ctx['load_sibling']('extractor')\n" " app.state.alpha_manifest = extractor.MANIFEST_DIR\n" ), ) _make_plugin( tmp_path, "beta", sibling_files={"extractor": "BETA_VALUE = 42\n"}, routes_body=( "def setup(app, ctx):\n" " extractor = ctx['load_sibling']('extractor')\n" " app.state.beta_value = extractor.BETA_VALUE\n" ), ) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() _run_load_plugins(plugins, fake_app, tmp_path) assert fake_app.state.alpha_manifest == "alpha-manifest" assert fake_app.state.beta_value == 42 # The two extractors are namespaced into distinct sys.modules # entries. `.` separates id and name to disambiguate when either # contains underscores. alpha_mod = sys.modules["plugin_alpha.extractor"] beta_mod = sys.modules["plugin_beta.extractor"] assert alpha_mod is not beta_mod assert getattr(alpha_mod, "MANIFEST_DIR", None) == "alpha-manifest" assert getattr(beta_mod, "BETA_VALUE", None) == 42 # Negative cross-check: alpha's extractor must NOT carry beta's exports. assert not hasattr(alpha_mod, "BETA_VALUE") assert not hasattr(beta_mod, "MANIFEST_DIR") def test_register_library_provider_context_is_scoped_to_plugin_id(tmp_path, reset_plugin_state): plugins = reset_plugin_state captured = [] _make_plugin( tmp_path, "remote_source", routes_body=( "def setup(app, ctx):\n" " provider = {'id': 'remote:source', 'label': 'Remote Source', 'capabilities': ['library.read']}\n" " ctx['register_library_provider'](provider)\n" ), ) def register_library_provider(provider, *, replace=False, owner_plugin_id=None): captured.append({"provider": provider, "replace": replace, "owner_plugin_id": owner_plugin_id}) return provider _run_load_plugins( plugins, type("FakeApp", (), {})(), tmp_path, context={"register_library_provider": register_library_provider}, ) assert captured == [{ "provider": {"id": "remote:source", "label": "Remote Source", "capabilities": ["library.read"]}, "replace": False, "owner_plugin_id": "remote_source", }] loaded = next(entry for entry in plugins.LOADED_PLUGINS if entry["id"] == "remote_source") assert not any(entry.get("capability") == "library" for entry in loaded["compatibility_shims"]) def test_load_sibling_caches_repeat_calls(tmp_path, reset_plugin_state): """Two `load_sibling('util')` calls within the same plugin return the identical module object — no double exec_module.""" plugins = reset_plugin_state _make_plugin( tmp_path, "cached", sibling_files={"util": "INSTANCE = object()\n"}, routes_body=( "def setup(app, ctx):\n" " a = ctx['load_sibling']('util')\n" " b = ctx['load_sibling']('util')\n" " app.state.same = a is b\n" " app.state.instance = a.INSTANCE\n" ), ) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() _run_load_plugins(plugins, fake_app, tmp_path) assert fake_app.state.same is True assert fake_app.state.instance is sys.modules["plugin_cached.util"].INSTANCE def test_load_sibling_missing_module_raises_import_error(tmp_path, reset_plugin_state): plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "bare") with pytest.raises(ImportError): plugins._load_plugin_sibling("bare", plugin_dir, "does_not_exist") def test_load_sibling_rejects_traversal_and_suffix(tmp_path, reset_plugin_state): """The helper takes a bare module name; reject anything that could traverse paths or carry a redundant .py suffix.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "p") # Reject: # - empty / non-string (bare module name required) # - path traversal (`/`, `\`, `../`) # - redundant `.py` suffix # - any `.` (used as separator in the cache key — would # otherwise allow ambiguous keys) for bad in ("", "../etc", "sub/util", "util.py", "pkg.helper", 123, None): with pytest.raises((ValueError, TypeError)): plugins._load_plugin_sibling("p", plugin_dir, bad) def test_collision_warning_fires_for_shared_module_name(tmp_path, reset_plugin_state, caplog): """Two plugins both shipping extractor.py must trigger the warning.""" plugins = reset_plugin_state _make_plugin(tmp_path, "rs1extract", sibling_files={"extractor": "X = 1\n"}) _make_plugin(tmp_path, "discextract", sibling_files={"extractor": "Y = 2\n"}) with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) assert "Module-name collision" in caplog.text assert "'extractor' (module)" in caplog.text assert "rs1extract" in caplog.text assert "discextract" in caplog.text def test_collision_warning_silent_when_names_unique(tmp_path, reset_plugin_state, caplog): plugins = reset_plugin_state _make_plugin(tmp_path, "alpha", sibling_files={"alpha_helper": "A = 1\n"}) _make_plugin(tmp_path, "beta", sibling_files={"beta_helper": "B = 2\n"}) with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) assert "Module-name collision" not in caplog.text def test_collision_warning_excludes_routes_and_dunders(tmp_path, reset_plugin_state, caplog): """routes.py is already namespaced by the loader; __init__.py belongs to a plugin that opted into being a package and namespaces itself. Neither should trip the collision warning even when both plugins ship one.""" plugins = reset_plugin_state p1 = _make_plugin(tmp_path, "one", sibling_files={"unique_one": "V = 1\n"}) p2 = _make_plugin(tmp_path, "two", sibling_files={"unique_two": "V = 2\n"}) (p1 / "__init__.py").write_text("") (p2 / "__init__.py").write_text("") with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) assert "Module-name collision" not in caplog.text def test_collision_warning_dedupes_per_plugin(tmp_path, reset_plugin_state, caplog): """A single plugin shipping BOTH `extractor.py` and `extractor/__init__.py` is a supported intra-plugin layout (load_sibling deterministically prefers the package form, matching CPython's import precedence). The warning must NOT count it as a 2-plugin collision and emit a bogus message listing the same plugin id twice. Codex round 5.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "lonely") (plugin_dir / "extractor.py").write_text("FROM = 'file'\n") pkg_dir = plugin_dir / "extractor" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text("FROM = 'package'\n") with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) # Only one plugin is involved, so no cross-plugin warning fires. assert "Module-name collision" not in caplog.text def test_collision_warning_still_fires_when_two_plugins_each_have_both_forms( tmp_path, reset_plugin_state, caplog ): """Two plugins each shipping both forms of `extractor` IS a real cross-plugin collision and must be reported. Codex round 5 sanity check on the dedup logic.""" plugins = reset_plugin_state for pid in ("alpha", "beta"): plugin_dir = _make_plugin(tmp_path, pid) (plugin_dir / "extractor.py").write_text(f"OWNER = '{pid}-file'\n") pkg_dir = plugin_dir / "extractor" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text(f"OWNER = '{pid}-package'\n") with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) collision_records = [r for r in caplog.records if "Module-name collision" in r.getMessage()] assert len(collision_records) == 1 warning = collision_records[0].getMessage() assert "alpha" in warning assert "beta" in warning # The warning text should list each plugin id ONCE, even though # both plugins ship two forms of `extractor`. assert warning.count("'alpha'") == 1 assert warning.count("'beta'") == 1 # Both forms reported in the kind label. assert "module/package" in warning def test_load_sibling_does_not_alias_bare_imported_package(tmp_path, reset_plugin_state): """A bare-imported package keeps `__package__` and `__spec__.name` as the un-namespaced bare name, so lazy relative imports inside it would still resolve through the global cache. To avoid that, load_sibling does NOT reuse a bare-imported package — it re-executes under the namespaced spec instead. Two copies of the package coexist (one bare, one namespaced); module-level state diverges. This is documented as the trade-off; the alternative would silently leak submodule cross-loads. Codex round 5.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "pkgsafe") pkg_dir = plugin_dir / "extractor" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text("MARK = object()\n") (pkg_dir / "child.py").write_text("FROM = 'leaf'\n") # Pre-populate sys.modules['extractor'] as if a bare import had # already pulled in the package. spec = importlib.util.spec_from_file_location( "extractor", str(pkg_dir / "__init__.py"), submodule_search_locations=[str(pkg_dir)], ) bare_pkg = importlib.util.module_from_spec(spec) sys.modules["extractor"] = bare_pkg spec.loader.exec_module(bare_pkg) bare_mark = bare_pkg.MARK # load_sibling re-executes under the namespaced spec rather # than aliasing the bare package. via_helper = plugins._load_plugin_sibling("pkgsafe", plugin_dir, "extractor") assert via_helper is not bare_pkg # Different MARK objects confirm the namespaced version was # actually re-executed. assert via_helper.MARK is not bare_mark # The namespaced submodule resolves through the namespaced # package, NOT through `extractor.child`. child = importlib.import_module("plugin_pkgsafe.extractor.child") assert child.FROM == "leaf" def test_load_sibling_handles_dotted_plugin_id_via_escape(tmp_path, reset_plugin_state): """Plugins with reverse-DNS-style ids (`foo.bar`) must still be able to use load_sibling — the helper escapes `.` in the plugin_id portion of the cache key so the synthetic parent package is still well-formed. Spotted across codex review rounds on PR for feedBack#33.""" plugins = reset_plugin_state plugin_dir = tmp_path / "rdns" plugin_dir.mkdir() (plugin_dir / "util.py").write_text("VALUE = 'reverse-dns'\n") util = plugins._load_plugin_sibling("com.example.foo", plugin_dir, "util") assert util.VALUE == "reverse-dns" # The cache key uses the bijectively-encoded form so it doesn't # fight with Python's package resolution. `.` -> `_2e_`. assert "plugin_com_2e_example_2e_foo.util" in sys.modules assert sys.modules["plugin_com_2e_example_2e_foo.util"] is util def test_load_sibling_rejects_empty_plugin_id(tmp_path, reset_plugin_state): """Empty / non-string plugin_id is still rejected — the helper needs SOMETHING to namespace under.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "valid_id", sibling_files={"util": "X = 1\n"}) for bad in ("", None, 123): with pytest.raises((ValueError, TypeError)): plugins._load_plugin_sibling(bad, plugin_dir, "util") def test_load_sibling_exposes_child_as_parent_attribute(tmp_path, reset_plugin_state): """After load_sibling caches a child, Python's package-style relative imports (`from . import sibling`, `from .. import sibling`) need to find the child as an ATTRIBUTE on the parent package — not just in sys.modules. The standard import machinery sets that attribute; load_sibling must mimic the behavior. Codex round 9.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "expose") (plugin_dir / "extractor.py").write_text("VAL = 'extr'\n") # Another sibling does `from . import extractor` — pure # attribute lookup on the synthetic parent. (plugin_dir / "consumer.py").write_text( "from . import extractor\n" "GOT = extractor.VAL\n" ) # Load the consumer first; while it's executing, the # `from . import extractor` triggers extractor's import # through the parent package's __path__. After it loads, # extractor must be visible as an attribute on the parent. consumer = plugins._load_plugin_sibling("expose", plugin_dir, "consumer") assert consumer.GOT == "extr" parent = sys.modules["plugin_expose"] assert hasattr(parent, "extractor") assert parent.extractor is sys.modules["plugin_expose.extractor"] # And consumer is exposed on the parent the same way. assert hasattr(parent, "consumer") assert parent.consumer is consumer def test_load_sibling_supports_relative_imports_between_siblings(tmp_path, reset_plugin_state): """A sibling loaded via load_sibling that does `from .shared import X` (relative import to another top-level sibling) must resolve. The synthetic parent's __path__ points at the plugin directory so the import machinery can find sibling files via the standard relative-import path. Codex round 7.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "rel") (plugin_dir / "shared.py").write_text("SHARED_VALUE = 'shared'\n") (plugin_dir / "extractor.py").write_text( "from .shared import SHARED_VALUE\n" "RE_EXPORT = SHARED_VALUE\n" ) extractor = plugins._load_plugin_sibling("rel", plugin_dir, "extractor") assert extractor.RE_EXPORT == "shared" # The relatively-imported sibling is registered under the # namespaced key, NOT polluted into the global `shared` slot # (collision risk with other plugins' `shared.py`). assert "plugin_rel.shared" in sys.modules def test_load_sibling_package_relative_import_to_outside_sibling(tmp_path, reset_plugin_state): """A package-form sibling whose __init__.py does `from ..shared import X` reaches the parent and finds another sibling. Verifies the package + parent-__path__ wiring works end-to-end. Codex round 7.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "pkgrel") (plugin_dir / "shared.py").write_text("VAL = 42\n") pkg_dir = plugin_dir / "extractor" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text( "from ..shared import VAL\n" "VALUE = VAL\n" ) extractor = plugins._load_plugin_sibling("pkgrel", plugin_dir, "extractor") assert extractor.VALUE == 42 def test_load_sibling_package_relative_import_works(tmp_path, reset_plugin_state): """A package-form sibling whose __init__.py uses `from .child import X` must load. Without registering the synthetic parent package `plugin_` in sys.modules first, Python can't resolve the relative import. Codex round 3 caught this.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "relpkg") pkg_dir = plugin_dir / "extractor" pkg_dir.mkdir() (pkg_dir / "child.py").write_text("CHILD_VALUE = 99\n") (pkg_dir / "__init__.py").write_text( "from .child import CHILD_VALUE\n" "RE_EXPORT = CHILD_VALUE\n" ) extractor = plugins._load_plugin_sibling("relpkg", plugin_dir, "extractor") assert extractor.RE_EXPORT == 99 # Parent package was registered as a synthetic ModuleType. assert "plugin_relpkg" in sys.modules def test_load_sibling_does_not_alias_bare_imported_file_module(tmp_path, reset_plugin_state): """Mixed migration: bare `import util` already cached this plugin's util.py under the global `util` name. load_sibling does NOT alias the bare module into the namespaced cache — it re-executes under the namespaced spec. The bare module's `__package__` / `__name__` / `__spec__` would otherwise stay set to the un-namespaced bare name, and any later relative import inside util.py (`from .shared import X` in a function body) would route through the bare global cache, undoing the isolation. Trade-off: module-level state in util splits across two copies until the plugin removes its bare imports. Spotted by codex review on PR for feedBack#33 round 8.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "mixmig") util_path = plugin_dir / "util.py" util_path.write_text("MARK = object()\n") spec = importlib.util.spec_from_file_location("util", str(util_path)) bare_mod = importlib.util.module_from_spec(spec) sys.modules["util"] = bare_mod spec.loader.exec_module(bare_mod) bare_mark = bare_mod.MARK via_helper = plugins._load_plugin_sibling("mixmig", plugin_dir, "util") # Different module objects — the helper re-executed instead # of aliasing. assert via_helper is not bare_mod assert via_helper.MARK is not bare_mark # Namespaced key has the namespaced object; bare key still has # the bare-imported object. assert sys.modules["plugin_mixmig.util"] is via_helper assert sys.modules["util"] is bare_mod # Critically, via_helper has the correct namespaced metadata so # later relative imports inside it would route through the # synthetic parent. assert via_helper.__name__ == "plugin_mixmig.util" assert via_helper.__package__ == "plugin_mixmig" def test_safe_plugin_id_encoding_is_collision_free(reset_plugin_state): """Distinct plugin_ids must always map to distinct encoded forms. The previous `.` -> `_x2e_` (only when `.` was present) was not bijective: ids `foo.bar` and `foo_x2e_bar` both produced `foo_x2e_bar`. With the bijective `_` -> `_5f_`, `.` -> `_2e_` encoding (in that order), no two distinct plugin_ids map to the same output. Copilot review on PR #105 round 3.""" plugins = reset_plugin_state samples = [ "foo", "foo_bar", "foo.bar", "foo_2e_bar", "foo_5f_bar", "foo_5f_2e_5f_bar", "com.example.foo", "com_example_foo", "com_2e_example_2e_foo", "", # empty edge — empty maps to empty, distinct from all others "_", ".", "._", "_.", ] encoded = [plugins._safe_plugin_id_for_module_name(s) for s in samples] # Bijective: distinct inputs -> distinct outputs. assert len(set(encoded)) == len(samples), dict(zip(samples, encoded)) def test_load_plugins_skips_non_string_id(tmp_path, reset_plugin_state, caplog): """A malformed manifest with a non-string id (e.g. number) is skipped with a clear message rather than crashing later inside `_safe_plugin_id_for_module_name`'s `.replace()` call. Copilot review on PR #105 round 3.""" plugins = reset_plugin_state bad_dir = tmp_path / "bad" bad_dir.mkdir() (bad_dir / "plugin.json").write_text('{"id": 42, "name": "bad"}') _make_plugin(tmp_path, "good", sibling_files={"util": "X = 1\n"}) fake_app = type("FakeApp", (), {})() with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, fake_app, tmp_path) assert "must be a string" in caplog.text assert "int" in caplog.text # type name surfaced loaded_ids = {p["id"] for p in plugins.LOADED_PLUGINS} assert 42 not in loaded_ids assert "good" in loaded_ids def test_load_plugins_warns_on_falsy_non_string_id(tmp_path, reset_plugin_state, caplog): """`{"id": 0}` and `{"id": []}` are falsy non-strings. The type-check must run BEFORE the falsy-empty check so the user gets the explicit "must be a string" warning instead of a silent skip. Copilot review on PR #105 round 4.""" plugins = reset_plugin_state for i, bad_value in enumerate(("0", "[]", "false")): # JSON literals bad_dir = tmp_path / f"bad{i}" bad_dir.mkdir() (bad_dir / "plugin.json").write_text(f'{{"id": {bad_value}, "name": "x"}}') with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) # Each malformed manifest produces a "must be a string" warning; # none are silently dropped. assert caplog.text.count("must be a string") == 3 assert "int" in caplog.text # for {"id": 0} assert "list" in caplog.text # for {"id": []} assert "bool" in caplog.text # for {"id": false} def test_load_plugins_escapes_dotted_id_in_routes_module_name(tmp_path, reset_plugin_state): """A plugin with a reverse-DNS id like `com.example.foo` must have its routes module registered under a `.`-free name, or Python would treat the cache key as a dotted package path and set `__package__` to an unintended parent (relative imports in routes.py would then resolve against something else entirely). The same `.` -> `_2e_` encoding used by load_sibling now applies to routes too. Copilot review on PR #105 round 2.""" plugins = reset_plugin_state plugin_dir = tmp_path / "rdns_routes" plugin_dir.mkdir() (plugin_dir / "plugin.json").write_text( json.dumps({"id": "com.example.foo", "name": "rdns", "routes": "routes.py"}) ) (plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n app.state.routes_loaded = True\n" ) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() _run_load_plugins(plugins, fake_app, tmp_path) assert fake_app.state.routes_loaded is True # Routes module is registered under the escaped name and is a # single identifier-shaped key — NOT a dotted path that Python # would try to resolve as a real package. assert "plugin_com_2e_example_2e_foo_routes" in sys.modules routes_mod = sys.modules["plugin_com_2e_example_2e_foo_routes"] # __package__ is empty (top-level module), not a dotted parent. assert (routes_mod.__package__ or "") == "" def test_load_sibling_parent_registration_is_atomic(tmp_path, reset_plugin_state): """Two threads loading DIFFERENT siblings for the same plugin must agree on the synthetic parent. If they each constructed a fresh ModuleType and assigned to sys.modules[parent_name] without coordination, the second assignment could replace the first — and child attributes already attached to the first parent would disappear, breaking `from . import sibling`. setdefault makes the registration atomic. Copilot round 2.""" import threading plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "atomic") # Two slow siblings so the threads have time to overlap. (plugin_dir / "alpha.py").write_text("import time\ntime.sleep(0.05)\nVALUE = 'a'\n") (plugin_dir / "beta.py").write_text("import time\ntime.sleep(0.05)\nVALUE = 'b'\n") errors: list = [] def worker(name): try: plugins._load_plugin_sibling("atomic", plugin_dir, name) except BaseException as e: # pragma: no cover errors.append(e) threads = [ threading.Thread(target=worker, args=("alpha",)), threading.Thread(target=worker, args=("beta",)), ] for t in threads: t.start() for t in threads: t.join() assert not errors parent = sys.modules["plugin_atomic"] # Both children are exposed as attributes on the SAME parent — # neither was lost to a parent-replacement race. assert hasattr(parent, "alpha") assert hasattr(parent, "beta") assert parent.alpha.VALUE == "a" assert parent.beta.VALUE == "b" def test_load_sibling_concurrent_first_call_returns_fully_initialized(tmp_path, reset_plugin_state): """Two threads racing on the same first-time load_sibling call should both receive a fully-initialized module object — neither can observe the half-built module that's briefly registered in sys.modules between `module_from_spec` and the end of `exec_module`. The per-module lock added in round 8 enforces this.""" import threading plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "racy") # The sibling's __init__ does meaningful work BEFORE setting # `READY = True`, so a partially-initialized module would lack # the attribute even though the module object exists in # sys.modules. (plugin_dir / "slow.py").write_text( "import time\n" "time.sleep(0.05)\n" "READY = True\n" "VALUE = 'done'\n" ) results: list = [] errors: list = [] def worker(): try: mod = plugins._load_plugin_sibling("racy", plugin_dir, "slow") results.append((mod, getattr(mod, "READY", None), getattr(mod, "VALUE", None))) except BaseException as e: # pragma: no cover - bug path errors.append(e) threads = [threading.Thread(target=worker) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() assert not errors assert len(results) == 8 # Every caller sees the same fully-initialized module. first_mod, _, _ = results[0] for mod, ready, value in results: assert mod is first_mod assert ready is True assert value == "done" def test_load_sibling_does_not_reuse_other_plugins_bare_import(tmp_path, reset_plugin_state): """If sys.path has already cached a util.py from PLUGIN A under the bare name `util`, plugin B's load_sibling('util') must NOT return plugin A's module — it has to load plugin B's own copy under the namespaced key. This is the whole point of the isolation fix; the reuse path can't accidentally undo it.""" plugins = reset_plugin_state plugin_a = _make_plugin(tmp_path, "plug_a") plugin_b = _make_plugin(tmp_path, "plug_b") (plugin_a / "util.py").write_text("OWNER = 'a'\n") (plugin_b / "util.py").write_text("OWNER = 'b'\n") # Simulate plugin A's bare import landing in sys.modules['util']. spec_a = importlib.util.spec_from_file_location("util", str(plugin_a / "util.py")) bare_a = importlib.util.module_from_spec(spec_a) sys.modules["util"] = bare_a spec_a.loader.exec_module(bare_a) assert bare_a.OWNER == "a" # Plugin B's load_sibling must give plugin B's util, NOT plugin A's. b_util = plugins._load_plugin_sibling("plug_b", plugin_b, "util") assert b_util is not bare_a assert b_util.OWNER == "b" def test_load_sibling_loads_package_form(tmp_path, reset_plugin_state): """A plugin shipping a sibling as a package directory (`extractor/__init__.py`) should be loadable through load_sibling exactly like a single-file `.py` sibling. The collision-warning scanner directs maintainers of package-form plugins toward load_sibling, so the helper has to actually support them. Codex review on PR for feedBack#33.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "pkgplugin") pkg_dir = plugin_dir / "extractor" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text("ROOT_VALUE = 7\n") (pkg_dir / "child.py").write_text("CHILD_VALUE = 8\n") extractor = plugins._load_plugin_sibling("pkgplugin", plugin_dir, "extractor") assert extractor.ROOT_VALUE == 7 # Submodule lookup works because spec carried submodule_search_locations. child = importlib.import_module("plugin_pkgplugin.extractor.child") assert child.CHILD_VALUE == 8 def test_load_sibling_prefers_package_over_file_when_both_exist(tmp_path, reset_plugin_state): """If a plugin ships BOTH `extractor.py` and `extractor/__init__.py` in the same directory, the package form wins — matches CPython's own import-resolution precedence so bare `import extractor` and `load_sibling('extractor')` always run the same code path. Spotted by codex review on PR for feedBack#33.""" plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "both") (plugin_dir / "extractor.py").write_text("FROM = 'file'\n") pkg_dir = plugin_dir / "extractor" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text("FROM = 'package'\n") extractor = plugins._load_plugin_sibling("both", plugin_dir, "extractor") assert extractor.FROM == "package" def test_load_sibling_missing_in_both_forms_raises_with_useful_message(tmp_path, reset_plugin_state): plugins = reset_plugin_state plugin_dir = _make_plugin(tmp_path, "empty") with pytest.raises(ImportError) as exc: plugins._load_plugin_sibling("empty", plugin_dir, "missing") msg = str(exc.value) # Error message should mention BOTH probed locations so a # confused author sees "I checked here AND here" not "I checked # only the .py form". assert "missing.py" in msg assert "missing" in msg and "__init__.py" in msg def test_load_sibling_disambiguates_underscored_ids_and_names(tmp_path, reset_plugin_state): """`(plugin_id='a_b', name='c')` and `(plugin_id='a', name='b_c')` must NOT collide in sys.modules. The `.` separator + bijective `_` -> `_5f_` encoding of plugin_id make the cache key unambiguous (the old `_` separator collapsed both to `plugin_a_b_c`). Codex review on PR for feedBack#33.""" plugins = reset_plugin_state p1 = _make_plugin(tmp_path, "a_b", sibling_files={"c": "WHO = 'a_b/c'\n"}) p2 = _make_plugin(tmp_path, "a", sibling_files={"b_c": "WHO = 'a/b_c'\n"}) m1 = plugins._load_plugin_sibling("a_b", p1, "c") m2 = plugins._load_plugin_sibling("a", p2, "b_c") assert m1 is not m2 assert m1.WHO == "a_b/c" assert m2.WHO == "a/b_c" # Both keys exist independently in sys.modules. plugin_id `a_b` # encodes to `a_5f_b` so the parent is `plugin_a_5f_b`. The # NAME portion is not encoded (it's only the plugin_id that # could be confused with the `.` separator). So: # id='a_b', name='c' -> plugin_a_5f_b.c # id='a', name='b_c' -> plugin_a.b_c # The old `_` separator collapsed both to `plugin_a_b_c`. assert "plugin_a_5f_b.c" in sys.modules assert "plugin_a.b_c" in sys.modules assert sys.modules["plugin_a_5f_b.c"] is m1 assert sys.modules["plugin_a.b_c"] is m2 def test_collision_warning_detects_package_form(tmp_path, reset_plugin_state, caplog): """A plugin shipping `extractor/__init__.py` collides with another plugin's `extractor.py` the same way two `.py` files would. The scanner picks up packages too. Codex review on PR for feedBack#33.""" plugins = reset_plugin_state # Plugin one: extractor.py _make_plugin(tmp_path, "as_module", sibling_files={"extractor": "X = 1\n"}) # Plugin two: extractor/ (package form) plugin_pkg = _make_plugin(tmp_path, "as_package") pkg_dir = plugin_pkg / "extractor" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text("Y = 2\n") with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) assert "Module-name collision" in caplog.text assert "extractor" in caplog.text assert "as_module" in caplog.text assert "as_package" in caplog.text # The mixed-form label should also appear so the maintainer knows # to look for both shapes. assert "module/package" in caplog.text def test_collision_warning_detects_two_packages(tmp_path, reset_plugin_state, caplog): """Two plugins each shipping the SAME package directory form.""" plugins = reset_plugin_state for pid in ("plug_a", "plug_b"): plugin_dir = _make_plugin(tmp_path, pid) pkg = plugin_dir / "shared_pkg" pkg.mkdir() (pkg / "__init__.py").write_text(f"# {pid}\n") with capture_logger(caplog, "feedBack.plugins"): _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) assert "Module-name collision" in caplog.text assert "shared_pkg" in caplog.text assert "plug_a" in caplog.text assert "plug_b" in caplog.text def test_per_plugin_context_does_not_leak_load_sibling_across_plugins(tmp_path, reset_plugin_state): """Plugin A's `load_sibling` must close over plugin A's id+dir. If both plugins received the SAME closure (the bug we are preventing), plugin A calling `load_sibling('thing')` would load whatever the loop's last-iteration closure pointed at — typically the alphabetically-last plugin's directory.""" plugins = reset_plugin_state _make_plugin( tmp_path, "aaa", sibling_files={"thing": "ORIGIN = 'aaa'\n"}, routes_body=( "def setup(app, ctx):\n" " app.state.aaa_origin = ctx['load_sibling']('thing').ORIGIN\n" ), ) _make_plugin( tmp_path, "zzz", sibling_files={"thing": "ORIGIN = 'zzz'\n"}, routes_body=( "def setup(app, ctx):\n" " app.state.zzz_origin = ctx['load_sibling']('thing').ORIGIN\n" ), ) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() _run_load_plugins(plugins, fake_app, tmp_path) assert fake_app.state.aaa_origin == "aaa" assert fake_app.state.zzz_origin == "zzz" # ── Bundled plugins always win over user-installed copies ──────────────────── def test_bundled_plugin_always_wins_over_feedBack_plugins_dir_copy( tmp_path, reset_plugin_state, monkeypatch, caplog ): """Bundled plugins always win over user-installed copies in FEEDBACK_PLUGINS_DIR. Even though FEEDBACK_PLUGINS_DIR is scanned first, a user-installed copy with the same id as a bundled plugin is evicted in favour of the bundled version. The loader emits a warning naming the ignored user copy. """ plugins = reset_plugin_state # Simulate the in-tree (bundled) plugins directory. bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() _make_plugin( bundled_dir, "highway_3d", routes_body=( "def setup(app, ctx):\n" " app.state.origin = 'bundled'\n" ), ) # Mark the in-tree plugin as bundled — required for override detection. (bundled_dir / "highway_3d" / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "highway_3d", "routes": "routes.py", "bundled": True}) ) # Simulate a user-installed plugins directory (FEEDBACK_PLUGINS_DIR). user_dir = tmp_path / "user" user_dir.mkdir() _make_plugin( user_dir, "highway_3d", routes_body=( "def setup(app, ctx):\n" " app.state.origin = 'user'\n" ), ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() # Use bundled_dir as the in-tree PLUGINS_DIR root. saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: with capture_logger(caplog, "feedBack.plugins"): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # Bundled copy must win — setup() from bundled_dir ran, not user_dir. assert fake_app.state.origin == "bundled" # Exactly one highway_3d entry registered. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 # The loader emits a warning about the ignored user copy, naming its path. assert ( "User-installed copy of bundled plugin 'highway_3d'" in caplog.text and "ignored" in caplog.text and str(user_dir / "highway_3d") in caplog.text ) def test_bundled_plugin_wins_over_user_copy_and_logs_warning( tmp_path, reset_plugin_state, monkeypatch, caplog ): """Bundled plugin wins over a user-installed copy with the same id. The loader emits a warning naming the ignored user copy. The kept entry is the bundled version (bundled=True, dir matches plugin id). """ plugins = reset_plugin_state # In-tree bundled plugin — plugin.json carries ``"bundled": true``. bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() plugin_dir = bundled_dir / "highway_3d" plugin_dir.mkdir() (plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "bundled": True}) ) # User-installed copy with the same id in a differently-named directory. user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "3dhighway" # different directory name user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)"}) ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: with capture_logger(caplog, "feedBack.plugins"): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # Exactly one entry registered. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 # The kept copy must be the bundled version. kept = hw3d_entries[0] assert str(kept["_dir"]) == str(plugin_dir) assert kept.get("bundled") is True # The loader must emit the specific warning about the ignored user copy, naming its path. assert ( "User-installed copy of bundled plugin 'highway_3d'" in caplog.text and "ignored" in caplog.text and str(user_plugin_dir) in caplog.text ) def test_bundled_plugin_wins_over_verbatim_user_copy( tmp_path, reset_plugin_state, monkeypatch, caplog ): """Bundled plugin wins even when the user copy verbatim-copied ``"bundled": true``. The three-part ``_is_bundled()`` check — in-tree directory, manifest field, AND dir name == plugin id — correctly identifies the in-tree copy as bundled and ignores the user copy regardless of its manifest contents. """ plugins = reset_plugin_state # In-tree bundled plugin. bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() plugin_dir = bundled_dir / "highway_3d" plugin_dir.mkdir() (plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "bundled": True}) ) # User copy that was copied verbatim from in-tree — still carries bundled=true. user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d_custom" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (custom)", "bundled": True}) ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: with capture_logger(caplog, "feedBack.plugins"): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # Exactly one entry registered. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 # The kept copy must be the real bundled version. kept = hw3d_entries[0] assert str(kept["_dir"]) == str(plugin_dir) assert kept.get("bundled") is True # The loader emits the warning about the ignored user copy, naming its path. assert ( "User-installed copy of bundled plugin 'highway_3d'" in caplog.text and "ignored" in caplog.text and str(user_plugin_dir) in caplog.text ) def test_bundled_plugin_wins_over_copy_in_same_plugins_dir( tmp_path, reset_plugin_state, caplog ): """Bundled plugin wins even when a user fork sorts alphabetically first. Both plugins live under the same PLUGINS_DIR, so override detection cannot use the directory parent alone. The three-part ``_is_bundled()`` check — ``(in-tree dir) AND manifest.get("bundled") AND (dir name == plugin id)`` — identifies the bundled copy and discards the user fork. Layout mirroring the canonical example from the PR description: plugins/3dhighway/ ← user-installed fork (no "bundled" field) plugins/highway_3d/ ← bundled core (has "bundled": true, dir name == id) ``3dhighway`` sorts before ``highway_3d`` alphabetically and is registered first; when ``highway_3d`` arrives it evicts the user fork and wins. """ plugins = reset_plugin_state plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() # User-installed fork — comes first alphabetically. user_plugin_dir = plugins_dir / "3dhighway" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user fork)"}) ) # Bundled core — sorts after "3dhighway"; evicts the user fork when encountered. bundled_plugin_dir = plugins_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "bundled": True}) ) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = plugins_dir try: with capture_logger(caplog, "feedBack.plugins"): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 # The bundled copy must win, regardless of alphabetical order. kept = hw3d_entries[0] assert str(kept["_dir"]) == str(bundled_plugin_dir) # Bundled flag set on the kept copy. assert kept.get("bundled") is True # The loader emits the specific warning about the ignored user copy, naming its path. assert ( "User-installed copy of bundled plugin 'highway_3d'" in caplog.text and "ignored" in caplog.text and str(user_plugin_dir) in caplog.text ) def test_bundled_plugin_wins_over_copy_in_same_plugins_dir_bundled_sorts_first( tmp_path, reset_plugin_state, caplog ): """Bundled plugin wins when it sorts *before* the user directory. When the bundled copy is encountered first it is registered; the later user copy is discarded with a warning. Layout where bundled sorts before user: plugins/highway_3d/ ← bundled core (has "bundled": true), sorts first plugins/zzz-highway/ ← user-installed fork (no "bundled" field), sorts last """ plugins = reset_plugin_state plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() # Bundled core — sorts first alphabetically. bundled_plugin_dir = plugins_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "bundled": True}) ) # User-installed fork — sorts after "highway_3d", will be encountered second. user_plugin_dir = plugins_dir / "zzz-highway" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user fork)"}) ) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = plugins_dir try: with capture_logger(caplog, "feedBack.plugins"): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 # The bundled copy must win; user fork is discarded. kept = hw3d_entries[0] assert str(kept["_dir"]) == str(bundled_plugin_dir) # Bundled flag set on the kept copy. assert kept.get("bundled") is True # The loader emits the specific warning about the ignored user copy, naming its path. assert ( "User-installed copy of bundled plugin 'highway_3d'" in caplog.text and "ignored" in caplog.text and str(user_plugin_dir) in caplog.text ) def test_bundled_plugin_wins_over_verbatim_copy_in_same_plugins_dir( tmp_path, reset_plugin_state, caplog ): """Bundled plugin wins over a verbatim user copy that carries ``"bundled": true``. The three-part ``_is_bundled()`` check — in-tree directory, manifest field, AND directory-name-matches-plugin-id — correctly identifies the real core copy and rejects the verbatim clone (dir name ≠ plugin id). Layout: plugins/highway_3d/ ← bundled core (has "bundled": true, dir name == id) plugins/zzz-highway/ ← verbatim user copy (has "bundled": true, dir name ≠ id) ``highway_3d`` sorts before ``zzz-highway``; the bundled copy is encountered first. When the user copy arrives it is not truly bundled (dir name mismatch) and is discarded with a warning. """ plugins = reset_plugin_state plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() # Bundled core — dir name == plugin id, has "bundled": true. bundled_plugin_dir = plugins_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "bundled": True}) ) # Verbatim user copy — different dir name, but manifest is identical (including "bundled": true). user_plugin_dir = plugins_dir / "zzz-highway" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "bundled": True}) ) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = plugins_dir try: with capture_logger(caplog, "feedBack.plugins"): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 # The real bundled copy (highway_3d) must win; the verbatim clone is discarded. kept = hw3d_entries[0] assert str(kept["_dir"]) == str(bundled_plugin_dir) # Bundled flag set on the kept copy. assert kept.get("bundled") is True # Warning about the ignored user copy must be emitted, naming its path. assert ( "User-installed copy of bundled plugin 'highway_3d'" in caplog.text and "ignored" in caplog.text and str(user_plugin_dir) in caplog.text ) def test_bundled_wins_with_multiple_stale_copies( tmp_path, reset_plugin_state, monkeypatch, caplog ): """Bundled plugin wins when multiple stale copies exist simultaneously. Exercises the exact #181 layout: the user has both an external ``FEEDBACK_PLUGINS_DIR/highway_3d`` copy AND a stale in-tree clone at ``plugins/3dhighway``, alongside the real bundled ``plugins/highway_3d``. Scan order: 1. FEEDBACK_PLUGINS_DIR scanned first → user_dir/highway_3d registered. 2. PLUGINS_DIR scanned next (sorted): - ``3dhighway`` sorts before ``highway_3d`` → duplicate, neither is bundled → discarded with a warning naming the specific plugin_dir. - ``highway_3d`` → bundled; evicts the FEEDBACK_PLUGINS_DIR copy; wins. Both stale paths must be named in warning log messages. """ plugins = reset_plugin_state # Simulate the in-tree (bundled) plugins directory. plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() # Stale in-tree clone (different dir name; not bundled because dir ≠ id). stale_intree_dir = plugins_dir / "3dhighway" stale_intree_dir.mkdir() (stale_intree_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (stale clone)"}) ) # Real bundled copy. bundled_plugin_dir = plugins_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "bundled": True}) ) # Simulate a user-installed plugins directory (FEEDBACK_PLUGINS_DIR). user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)"}) ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = plugins_dir try: with capture_logger(caplog, "feedBack.plugins"): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # Exactly one entry registered. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 # The bundled copy must win. kept = hw3d_entries[0] assert str(kept["_dir"]) == str(bundled_plugin_dir) assert kept.get("bundled") is True # The stale in-tree clone path must be named in the log. assert str(stale_intree_dir) in caplog.text # The user-installed copy path must be named in the log (bundled-wins warning). assert ( "User-installed copy of bundled plugin 'highway_3d'" in caplog.text and "ignored" in caplog.text and str(user_plugin_dir) in caplog.text ) def test_fallback_to_user_copy_when_bundled_routes_fail( tmp_path, reset_plugin_state, monkeypatch, caplog ): """If a bundled plugin's routes.setup() raises, the loader falls back to the previously-evicted user-installed copy so the server keeps working. """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() # Bundled copy whose routes.setup() will raise. bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n app.state.origin = 'user_fallback'\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: with capture_logger(caplog, "feedBack.plugins", level=logging.WARNING): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # The fallback user copy should be registered. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 assert hw3d_entries[0].get("bundled") is False assert str(hw3d_entries[0]["_dir"]) == str(user_plugin_dir) # The fallback's routes.setup() should have run. assert getattr(fake_app.state, "origin", None) == "user_fallback" # The fallback warning must be logged. assert "falling back to user-installed copy" in caplog.text assert str(user_plugin_dir) in caplog.text def test_fallback_when_bundled_sorts_first_and_routes_fail( tmp_path, reset_plugin_state, caplog ): """Fallback works even when the bundled plugin is discovered BEFORE the user copy. Thread 6 regression: the `elif kept_is_bundled:` branch previously discarded the user copy without storing it in `_pending_evictions`, so a bundled plugin discovered first had no fallback available. Layout (both in the same plugins_dir, bundled sorts first): plugins/highway_3d/ ← bundled (broken routes) plugins/zzz-highway/ ← user copy (working routes) """ plugins = reset_plugin_state plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() # Bundled copy — sorts first alphabetically; routes.setup() will raise. bundled_plugin_dir = plugins_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) # User copy — sorts after bundled; working routes. user_plugin_dir = plugins_dir / "zzz-highway" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n app.state.origin = 'user_fallback'\n" ) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = plugins_dir try: with capture_logger(caplog, "feedBack.plugins", level=logging.WARNING): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # The fallback user copy should be registered. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 assert hw3d_entries[0].get("bundled") is False assert str(hw3d_entries[0]["_dir"]) == str(user_plugin_dir) # The fallback's routes.setup() should have run. assert getattr(fake_app.state, "origin", None) == "user_fallback" # The fallback warning must be logged. assert "falling back to user-installed copy" in caplog.text assert str(user_plugin_dir) in caplog.text def test_plugin_absent_when_both_routes_fail( tmp_path, reset_plugin_state, monkeypatch, caplog ): """When both bundled routes AND fallback routes fail, plugin is absent from LOADED_PLUGINS. Thread 5 regression: the old code always appended to _loaded_batch even when the fallback routes also failed, causing the plugin to appear in /api/plugins despite having no working routes — contradicting the 'plugin unavailable' log. """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) # User copy routes also raise. (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('user also broken')\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: with capture_logger(caplog, "feedBack.plugins", level=logging.WARNING): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # Plugin must be absent — neither bundled nor user copy registered. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 0 # Both failures must be logged. assert "falling back to user-installed copy" in caplog.text assert "also failed to load routes" in caplog.text def test_partial_route_registration_warning( tmp_path, reset_plugin_state, monkeypatch, caplog ): """When bundled setup() registers routes before raising, a specific warning should name the count so maintainers can identify the partial registration in the server log (Thread 1, review-4226318201). """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) # Routes that register one endpoint before raising. (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n" " app.routes.append('stub')\n" " raise RuntimeError('partial fail')\n" ) fake_app = type("FakeApp", (), {"routes": []})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: with capture_logger(caplog, "feedBack.plugins", level=logging.WARNING): plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir assert "registered 1 route" in caplog.text assert "cannot be removed" in caplog.text def test_sibling_module_cache_purged_before_fallback( tmp_path, reset_plugin_state, monkeypatch ): """Sibling modules from the failed bundled copy are purged from sys.modules before the fallback copy's routes are loaded, so the fallback gets a clean slate and doesn't accidentally resolve to bundled helper code (Thread 2, review-4226318201). """ import sys plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) # Bundled helper reports 'bundled'; bundled routes load it via load_sibling # then fail — this caches the bundled helper in sys.modules. (bundled_plugin_dir / "helper.py").write_text("ORIGIN = 'bundled'\n") (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n" " ctx['load_sibling']('helper') # caches bundled helper\n" " raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) # User copy has its own helper with a different ORIGIN; routes use load_sibling. (user_plugin_dir / "helper.py").write_text("ORIGIN = 'user'\n") (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n" " helper = ctx['load_sibling']('helper')\n" " app.state.helper_origin = helper.ORIGIN\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # The fallback routes ran and resolved the user copy's helper, not bundled's. hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 1 assert getattr(fake_app.state, "helper_origin", None) == "user" def test_fallback_success_clears_error_in_progress_events( tmp_path, reset_plugin_state, monkeypatch ): """When fallback succeeds, the progress event should carry explicit error=None (clear_error=True) so downstream startup-status handlers can clear the bundled-failure error text (Thread 3, review-4226318201). """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n pass\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) events = [] fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}, progress_cb=events.append) finally: plugins.PLUGINS_DIR = saved_dir # After fallback success, a "plugin-registered" event with explicit # "error": None should clear the bundled-failure error in the handler. registered_clear_events = [ e for e in events if e.get("phase") == "plugin-registered" and e.get("plugin_id") == "highway_3d" and "error" in e and e["error"] is None ] assert registered_clear_events, ( "Expected a plugin-registered event with explicit error=None after fallback success" ) def test_plugins_complete_loaded_count_when_both_routes_fail( tmp_path, reset_plugin_state, monkeypatch ): """When both bundled and fallback routes fail, plugins-complete must report loaded = actual registered count, not len(plugin_load_specs) (Thread 4, review-4226318201). """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() # One healthy plugin + one that will fail completely. healthy_plugin_dir = bundled_dir / "healthy" healthy_plugin_dir.mkdir() (healthy_plugin_dir / "plugin.json").write_text( json.dumps({"id": "healthy", "name": "Healthy Plugin", "bundled": True}) ) bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('user also broken')\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) events = [] fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}, progress_cb=events.append) finally: plugins.PLUGINS_DIR = saved_dir complete = next(e for e in events if e["phase"] == "plugins-complete") # "healthy" loaded OK, "highway_3d" failed completely → actual loaded == 1 assert complete["loaded"] == 1, ( f"plugins-complete loaded should be 1 (only healthy), got {complete['loaded']}" ) # total still reflects how many specs were discovered assert complete["total"] == 2 def test_fallback_preserves_plugin_order( tmp_path, reset_plugin_state, monkeypatch ): """When a bundled copy evicts a user-installed copy and then fails to load its routes, the fallback (user copy) must occupy the SAME slot the user copy had at discovery time — not the position the bundled copy would have been appended at (Thread 1, review-4227852922; Thread 3, review-4227201020). Layout: user dir: highway_3d (working fallback, has routes) bundled: alpha (healthy), highway_3d (broken routes), omega (healthy) Scan order: user dir first, then bundled. user/highway_3d lands at slot 0. When bundled/highway_3d is scanned it replaces the user copy IN-PLACE at slot 0 (review-4227852922 fix). After route failure the fallback is inserted back at slot 0. Expected order after fallback: [highway_3d (fallback), alpha, omega] """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() for name, content in [ ("alpha", {"id": "alpha", "name": "Alpha", "bundled": True}), ("highway_3d", {"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}), ("omega", {"id": "omega", "name": "Omega", "bundled": True}), ]: d = bundled_dir / name d.mkdir() (d / "plugin.json").write_text(json.dumps(content)) if name == "highway_3d": (d / "routes.py").write_text("def setup(app, ctx):\n raise RuntimeError('broken')\n") user_dir = tmp_path / "user" user_dir.mkdir() user_hw = user_dir / "highway_3d" user_hw.mkdir() # User copy must have a routes file; without one fallback_routes_ok is # False and the entry is never added to LOADED_PLUGINS (review-4227852922). (user_hw / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_hw / "routes.py").write_text("def setup(app, ctx): pass\n") monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir ids = [p["id"] for p in plugins.LOADED_PLUGINS] assert ids == ["highway_3d", "alpha", "omega"], ( f"Fallback entry must occupy the user copy's original discovery slot, got order: {ids}" ) # The entry at position 0 (user copy's original slot) is the fallback. assert plugins.LOADED_PLUGINS[0].get("fallback") is True def test_fallback_not_registered_when_user_copy_has_no_routes( tmp_path, reset_plugin_state, monkeypatch ): """If the evicted user copy has no routes entry it cannot restore the bundled plugin's backend endpoints. The fallback must be treated as unsuccessful (fallback_routes_ok=False) so the plugin is absent from LOADED_PLUGINS and the bundled-failure error stays in startup-status. (Thread 2, review-4227852922) """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bd = bundled_dir / "highway_3d" bd.mkdir() (bd / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bd / "routes.py").write_text("def setup(app, ctx):\n raise RuntimeError('broken')\n") user_dir = tmp_path / "user" user_dir.mkdir() user_hw = user_dir / "highway_3d" user_hw.mkdir() # No routes in user copy — cannot restore bundled endpoints. (user_hw / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)"}) ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir ids = [p["id"] for p in plugins.LOADED_PLUGINS] assert "highway_3d" not in ids, ( "Plugin with no-routes user copy must NOT appear in LOADED_PLUGINS" ) def test_fallback_skipped_when_setup_was_mid_flight_on_timeout( tmp_path, reset_plugin_state, monkeypatch, caplog ): """When bundled setup() times out mid-flight (already executing), the loader must NOT activate the user-copy fallback — the original setup() may still be mutating the router concurrently (Thread 4, review-4227201020). Simulated by injecting an exception with ``setup_mid_flight=True`` (the attribute _route_setup_on_main attaches when it detects _started is set at timeout). """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text("def setup(app, ctx): pass\n") user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n app.state.fallback_ran = True\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) # Inject a route_setup_fn that simulates a mid-flight timeout by raising # a TimeoutError with setup_mid_flight=True. def _mid_flight_timeout_setup_fn(fn): e = concurrent.futures.TimeoutError() e.setup_mid_flight = True raise e fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: with capture_logger(caplog, "feedBack.plugins", level=logging.WARNING): plugins.load_plugins(fake_app, {}, route_setup_fn=_mid_flight_timeout_setup_fn) finally: plugins.PLUGINS_DIR = saved_dir # Fallback must NOT have run — original setup() was mid-flight. assert not getattr(fake_app.state, "fallback_ran", False), ( "Fallback setup() must not run when bundled setup() was mid-flight at timeout" ) # Plugin must be absent from LOADED_PLUGINS (both copies failed). hw3d_entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(hw3d_entries) == 0, ( "Plugin must be absent when bundled timed out mid-flight and fallback was skipped" ) # Warning about skipping fallback must appear in log. assert "Skipping fallback" in caplog.text or "mid-flight" in caplog.text def test_fallback_entry_has_fallback_true_field( tmp_path, reset_plugin_state, monkeypatch ): """When a user-copy fallback is activated because bundled routes fail, the registered LOADED_PLUGINS entry must include ``"fallback": True``. The /api/plugins endpoint exposes this field so the settings UI can show a warning badge (Thread 4, review-4226937699). """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text("def setup(app, ctx):\n pass\n") monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert len(entries) == 1 entry = entries[0] assert entry.get("fallback") is True, ( "Fallback user copy entry must have fallback=True, got " f"{entry.get('fallback')!r}" ) assert entry.get("bundled") is False, ( "Fallback user copy must not be marked bundled" ) def test_fallback_proceeds_when_install_requirements_returns_false( tmp_path, reset_plugin_state, monkeypatch ): """_install_requirements returning False in the fallback path must be non-fatal — the fallback must still load routes, matching the main loop behaviour which only emits a plugin-error but does not abort. Previously the fallback block did ``if not _install_requirements(...): continue`` which skipped the user copy entirely, leaving the bundled-failure error in startup-status even when the user copy could have run without those deps. (Thread 1, review-4227643820) A plugin-error must also be emitted when requirements fail, mirroring the main loop — without it the later clear_error=True makes startup look healthy even though the running plugin may be missing dependencies. (Thread 2, review-4228077246) """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n app.state.fallback_ran = True\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) # Simulate _install_requirements failing (read-only filesystem, optional # dep, etc.) — same scenario the main loop tolerates. monkeypatch.setattr(plugins, "_install_requirements", lambda *a, **kw: False) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() events: list = [] saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}, progress_cb=events.append) finally: plugins.PLUGINS_DIR = saved_dir # Despite requirements failure the fallback routes.setup() must have run. assert getattr(fake_app.state, "fallback_ran", False) is True, ( "Fallback routes.setup() must run even when _install_requirements returns False" ) entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert entries, "highway_3d must be registered as a fallback" assert entries[0].get("fallback") is True # A plugin-error must have been emitted for the fallback requirements failure. req_errors = [ e for e in events if e.get("phase") == "plugin-error" and e.get("plugin_id") == "highway_3d" and e.get("error") ] assert req_errors, ( "Expected at least one plugin-error event for highway_3d fallback requirements failure; " f"events were: {events}" ) # The req-failure event must include loaded/total so startup-status # progress counters are not zeroed out. # (Thread 2, review-4228421486) for e in req_errors: assert "loaded" in e and "total" in e, ( f"plugin-error event for req failure must include loaded/total; got: {e}" ) # After successful route registration the plugin-registered event must # NOT carry error=None when req install failed; that would wipe the req # error from startup-status, making startup look clean despite degraded # dependencies. (Thread 1, review-4228421486) registered_clear_events = [ e for e in events if e.get("phase") == "plugin-registered" and e.get("plugin_id") == "highway_3d" and "error" in e and e["error"] is None ] assert not registered_clear_events, ( "plugin-registered must NOT carry error=None when req install failed " "(that would silently wipe the req-failure error from startup-status); " f"events were: {events}" ) def test_fallback_routes_failure_emits_plugin_error( tmp_path, reset_plugin_state, monkeypatch ): """When the fallback user-copy's routes also fail, a plugin-error event must be emitted so startup-status reflects the fallback's failure as the root cause. Without this, startup-status is left pointing at the earlier bundled-copy error even though that is no longer the active failure, making it harder for operators to identify the correct root cause. (Thread 1, review-4228077246) """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "highway_3d" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) (bundled_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" user_dir.mkdir() user_plugin_dir = user_dir / "highway_3d" user_plugin_dir.mkdir() (user_plugin_dir / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_plugin_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('fallback also broken')\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() events: list = [] saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}, progress_cb=events.append) finally: plugins.PLUGINS_DIR = saved_dir # The plugin must NOT be registered. entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "highway_3d"] assert not entries, "highway_3d must not be registered when both copies fail" # A plugin-error event must have been emitted for the fallback failure. fallback_errors = [ e for e in events if e.get("phase") == "plugin-error" and e.get("plugin_id") == "highway_3d" and e.get("error") ] assert fallback_errors, ( "Expected at least one plugin-error event when fallback routes also fail; " f"events were: {events}" ) # The error message must mention 'Both' or 'fallback' to distinguish it # from the original bundled-failure error. assert any( "fallback" in e["error"].lower() or "both" in e["error"].lower() for e in fallback_errors ), f"plugin-error text should reference the fallback failure; got: {[e['error'] for e in fallback_errors]}" # The fallback route-error event must include loaded/total so the # startup-status progress counters are not zeroed mid-run. # (Thread 3, review-4228421486) for e in fallback_errors: assert "loaded" in e and "total" in e, ( f"plugin-error event for fallback route failure must include loaded/total; got: {e}" ) def test_normal_plugins_do_not_have_fallback_field_set( tmp_path, reset_plugin_state, monkeypatch ): """Normal (non-fallback) plugins — both bundled and user-installed — must not have ``fallback: True`` set. Only emergency user-copy fallbacks that replaced a broken bundled plugin carry the flag. """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() bundled_plugin_dir = bundled_dir / "my_plugin" bundled_plugin_dir.mkdir() (bundled_plugin_dir / "plugin.json").write_text( json.dumps({"id": "my_plugin", "name": "My Plugin", "bundled": True}) ) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir entries = [p for p in plugins.LOADED_PLUGINS if p["id"] == "my_plugin"] assert len(entries) == 1 assert not entries[0].get("fallback"), ( "Normal bundled plugin must not have fallback=True" ) def test_sibling_purge_does_not_affect_other_plugin_with_same_prefix( tmp_path, reset_plugin_state, monkeypatch ): """The sibling module purge in the fallback block must only remove the exact routes module key for the evicted plugin and its namespaced load_sibling modules. It must NOT delete modules for other plugins whose namespaced IDs share the same prefix. Previously, ``k.startswith(f"{_parent_pkg}_")`` would match ``plugin_a_routes`` when purging plugin ``a``, and would also incorrectly match ``plugin_a_5f_b_routes`` (routes for plugin ``a_b``). The fix replaces the startswith check with an exact key match. (Thread 1, review-4226937699) """ plugins = reset_plugin_state import sys # plugin "a" — bundled, fails routes. # plugin "a_b" — also bundled, healthy. # user copy of "a" — provides the fallback. bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() ba_dir = bundled_dir / "a" ba_dir.mkdir() (ba_dir / "plugin.json").write_text( json.dumps({"id": "a", "name": "Plugin A", "routes": "routes.py", "bundled": True}) ) (ba_dir / "routes.py").write_text( "def setup(app, ctx):\n raise RuntimeError('a broken')\n" ) ab_dir = bundled_dir / "a_b" ab_dir.mkdir() # `a_b` routes capture a ref so we can assert they ran from the right module. (ab_dir / "plugin.json").write_text( json.dumps({"id": "a_b", "name": "Plugin A_B", "routes": "routes.py", "bundled": True}) ) (ab_dir / "routes.py").write_text( "def setup(app, ctx):\n app.state.ab_setup_ran = True\n" ) user_dir = tmp_path / "user" user_dir.mkdir() ua_dir = user_dir / "a" ua_dir.mkdir() (ua_dir / "plugin.json").write_text( json.dumps({"id": "a", "name": "Plugin A (user)", "routes": "routes.py"}) ) (ua_dir / "routes.py").write_text( "def setup(app, ctx):\n app.state.a_fallback_ran = True\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # plugin "a_b" routes should have run without being clobbered by # the prefix-based purge. assert getattr(fake_app.state, "ab_setup_ran", False) is True, ( "plugin 'a_b' setup() was not called — its module was incorrectly " "purged during the 'a' fallback sibling purge" ) # plugin "a" fallback also ran. assert getattr(fake_app.state, "a_fallback_ran", False) is True, ( "plugin 'a' fallback setup() did not run" ) def test_bundled_flag_requires_both_in_tree_directory_and_manifest_field( tmp_path, reset_plugin_state, monkeypatch ): """``bundled`` requires ALL THREE: in-tree PLUGINS_DIR location, the manifest's ``"bundled": true`` field, AND the directory name matching the plugin id. - In-tree plugin, dir name == id, ``"bundled": true`` → ``bundled: true``. - In-tree plugin, dir name == id, no ``"bundled"`` field → ``bundled: false`` (user-installed plugin cloned into plugins/). - In-tree plugin, dir name ≠ id, ``"bundled": true`` → ``bundled: false`` (verbatim user copy under a different folder name). - User-dir plugin, ``"bundled": true`` → ``bundled: false`` (not in-tree; manifest field alone is not sufficient). This ensures a user-installed plugin cannot forge core status by adding ``"bundled": true`` to its own plugin.json, while correctly identifying legitimate in-tree bundled plugins. """ plugins = reset_plugin_state bundled_dir = tmp_path / "bundled" bundled_dir.mkdir() # In-tree plugin WITH "bundled": true AND dir name == id — the real bundled core plugin. (bundled_dir / "real_core").mkdir() (bundled_dir / "real_core" / "plugin.json").write_text( json.dumps({"id": "real_core", "name": "Real Core Plugin", "bundled": True}) ) # In-tree plugin WITHOUT "bundled" field — a user plugin cloned into plugins/. (bundled_dir / "user_in_tree").mkdir() (bundled_dir / "user_in_tree" / "plugin.json").write_text( json.dumps({"id": "user_in_tree", "name": "User Plugin (in plugins/)"}) ) # In-tree plugin WITH "bundled": true but dir name ≠ id — verbatim user copy # of a bundled plugin placed under a different folder name. (bundled_dir / "other_name").mkdir() (bundled_dir / "other_name" / "plugin.json").write_text( json.dumps({"id": "real_core_copy", "name": "Verbatim Copy", "bundled": True}) ) user_dir = tmp_path / "user" user_dir.mkdir() # User plugin in FEEDBACK_PLUGINS_DIR that forges "bundled": true. (user_dir / "fake_bundled").mkdir() (user_dir / "fake_bundled" / "plugin.json").write_text( json.dumps({"id": "fake_bundled", "name": "Fake Bundled", "bundled": True}) ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir by_id = {p["id"]: p for p in plugins.LOADED_PLUGINS} # In-tree plugin with "bundled": true AND dir name == id → bundled=True. assert by_id["real_core"]["bundled"] is True # In-tree plugin without "bundled" field → bundled=False (user clone in plugins/). assert by_id["user_in_tree"]["bundled"] is False # In-tree plugin with "bundled": true but dir name ≠ id → bundled=False. # (verbatim copy under a different folder; dir name "other_name" ≠ id "real_core_copy") assert by_id["real_core_copy"]["bundled"] is False # Plugin from user dir with "bundled": true → bundled=False (manifest field alone insufficient). assert by_id["fake_bundled"]["bundled"] is False def test_bundled_returned_by_api(tmp_path, reset_plugin_state): """GET /api/plugins must include a ``bundled`` boolean field for each plugin. - A plugin loaded from a manifest with ``"bundled": true`` (in-tree, dir name matches id) must surface ``bundled: true``. - A plain user plugin must surface ``bundled: false``. """ plugins_mod = reset_plugin_state plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() # Bundled plugin entry. plugins_mod.LOADED_PLUGINS.append({ "id": "core_viz", "name": "Core Viz", "nav": None, "type": None, "bundled": True, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) # Plain user plugin. plugins_mod.LOADED_PLUGINS.append({ "id": "my_plugin", "name": "My Plugin", "nav": None, "type": None, "bundled": False, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins_mod) try: r = client.get("/api/plugins") assert r.status_code == 200 ids = {p["id"]: p for p in r.json()} assert ids["core_viz"]["bundled"] is True assert "overrides_bundled" not in ids["core_viz"] assert ids["my_plugin"]["bundled"] is False assert "overrides_bundled" not in ids["my_plugin"] finally: client.close() def test_fallback_field_is_surfaced_in_api_response(tmp_path, reset_plugin_state): """GET /api/plugins must include a ``fallback`` boolean field for each plugin. - A fallback user-copy entry (bundled routes failed) must surface ``fallback: true``. - A normal plugin must surface ``fallback: false``. This ensures the frontend badge rendering is reliable — the badge depends on this field being present in the JSON response from the endpoint, not just in the internal LOADED_PLUGINS list (Thread 6, review-4227201020). """ plugins_mod = reset_plugin_state plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() # Fallback user-copy entry (bundled routes failed, user copy active). plugins_mod.LOADED_PLUGINS.append({ "id": "highway_3d", "name": "3D Highway (user fallback)", "nav": None, "type": None, "bundled": False, "fallback": True, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) # Normal user plugin — must NOT have fallback=true. plugins_mod.LOADED_PLUGINS.append({ "id": "normal_plugin", "name": "Normal Plugin", "nav": None, "type": None, "bundled": False, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins_mod) try: r = client.get("/api/plugins") assert r.status_code == 200 ids = {p["id"]: p for p in r.json()} # Fallback entry must have fallback=true in the response. assert "fallback" in ids["highway_3d"], ( "/api/plugins response must include the 'fallback' key" ) assert ids["highway_3d"]["fallback"] is True # Normal plugin must have fallback=false in the response. assert "fallback" in ids["normal_plugin"], ( "/api/plugins response must include the 'fallback' key for all plugins" ) assert ids["normal_plugin"]["fallback"] is False finally: client.close() def test_api_plugins_exposes_capability_validation_and_shim_metadata(reset_plugin_state): plugins_mod = reset_plugin_state fixture_dir = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "plugin_capabilities" valid = json.loads((fixture_dir / "valid_owner_provider.json").read_text(encoding="utf-8")) invalid = json.loads((fixture_dir / "invalid_capability_metadata.json").read_text(encoding="utf-8")) unsupported = json.loads((fixture_dir / "unsupported_capability_version.json").read_text(encoding="utf-8")) valid_caps, _, _ = plugins_mod._capability_warnings(valid, "stems") invalid_caps, invalid_warnings, _ = plugins_mod._capability_warnings(invalid, "broken_capability_metadata") unsupported_caps, _, unsupported_versions = plugins_mod._capability_warnings(unsupported, "future_capabilities") plugins_mod.LOADED_PLUGINS.extend([ { "id": "stems", "name": "Stems", "nav": None, "type": None, "bundled": False, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "standards": valid["standards"], "capabilities": valid_caps, "settings_schema": {}, "ui_contributions": plugins_mod._normalize_ui_contributions(valid), "runtime_domains": plugins_mod._normalize_runtime_domains(valid), "compatibility_shims": plugins_mod._compatibility_shims_from_manifest(valid, "stems"), "capability_validation_warnings": [], "capability_unsupported_versions": [], "_manifest": valid, }, { "id": "broken_capability_metadata", "name": "Broken Capability Metadata", "nav": invalid["nav"], "type": None, "bundled": False, "has_screen": True, "has_script": False, "has_settings": True, "has_tour": False, "standards": invalid["standards"], "capabilities": invalid_caps, "settings_schema": {}, "ui_contributions": plugins_mod._normalize_ui_contributions(invalid), "runtime_domains": plugins_mod._normalize_runtime_domains(invalid), "compatibility_shims": plugins_mod._compatibility_shims_from_manifest(invalid, "broken_capability_metadata"), "capability_validation_warnings": invalid_warnings, "capability_unsupported_versions": [], "_manifest": invalid, }, { "id": "future_capabilities", "name": "Future Capabilities", "nav": None, "type": None, "bundled": False, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "standards": unsupported["standards"], "capabilities": unsupported_caps, "settings_schema": {}, "ui_contributions": plugins_mod._normalize_ui_contributions(unsupported), "runtime_domains": plugins_mod._normalize_runtime_domains(unsupported), "compatibility_shims": plugins_mod._compatibility_shims_from_manifest(unsupported, "future_capabilities"), "capability_validation_warnings": [], "capability_unsupported_versions": unsupported_versions, "_manifest": unsupported, }, ]) client = _make_api_client(plugins_mod) try: listing = {entry["id"]: entry for entry in client.get("/api/plugins").json()} finally: client.close() assert listing["stems"]["capabilities"]["stems"]["roles"] == ["owner", "provider"] assert listing["broken_capability_metadata"]["capabilities"] == {} assert listing["broken_capability_metadata"]["capability_validation_warnings"] assert listing["broken_capability_metadata"]["ui_contributions"]["legacy"] assert listing["broken_capability_metadata"]["compatibility_shims"] == [] assert listing["future_capabilities"]["capability_unsupported_versions"] assert listing["future_capabilities"]["capabilities"]["stems"]["incompatible"] is True def _run_load_plugins_with_cb(plugins, app, tmp_path, progress_cb, context=None): """Like _run_load_plugins but passes a progress_cb spy.""" saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = tmp_path try: plugins.load_plugins(app, context if context is not None else {}, progress_cb=progress_cb) finally: plugins.PLUGINS_DIR = saved_dir def test_progress_cb_receives_phases_and_counts(tmp_path, reset_plugin_state): """progress_cb spy should receive structured events including plugins-discovered, plugins-complete, loaded/total counters, and the complete event should have loaded == total == number of plugins.""" plugins = reset_plugin_state _make_plugin(tmp_path, "alpha") _make_plugin(tmp_path, "beta") events = [] _run_load_plugins_with_cb(plugins, type("FakeApp", (), {})(), tmp_path, events.append) phases = [e["phase"] for e in events] assert "plugins-discovered" in phases assert "plugins-complete" in phases # All events carry int counters. for e in events: assert isinstance(e["loaded"], int), f"loaded not int in {e}" assert isinstance(e["total"], int), f"total not int in {e}" # The complete event has loaded == total == 2. complete = next(e for e in events if e["phase"] == "plugins-complete") assert complete["loaded"] == 2 assert complete["total"] == 2 def test_progress_cb_errors_do_not_break_plugin_startup(tmp_path, reset_plugin_state): """A progress_cb that raises must not abort plugin loading — the _emit_progress guard must swallow the exception.""" plugins = reset_plugin_state _make_plugin(tmp_path, "safe") def bad_cb(event): raise RuntimeError("spy exploded") _run_load_plugins_with_cb(plugins, type("FakeApp", (), {})(), tmp_path, bad_cb) # Plugin still registered despite cb explosion. assert any(p["id"] == "safe" for p in plugins.LOADED_PLUGINS) def test_progress_cb_emits_plugin_error_on_requirements_failure(tmp_path, reset_plugin_state, monkeypatch): """When _install_requirements returns False, a plugin-error event with a non-empty error field must be emitted via progress_cb, and plugin loading must still continue (non-fatal).""" plugins = reset_plugin_state _make_plugin(tmp_path, "req_fail") # Force _install_requirements to report failure without actually running pip. monkeypatch.setattr(plugins, "_install_requirements", lambda *a, **kw: False) events = [] _run_load_plugins_with_cb(plugins, type("FakeApp", (), {})(), tmp_path, events.append) error_events = [e for e in events if e["phase"] == "plugin-error" and e.get("plugin_id") == "req_fail"] assert error_events, "Expected at least one plugin-error event for req_fail" assert all(e["error"] for e in error_events), "plugin-error events must carry a non-empty error field" # Loading continued: req_fail is still registered. assert any(p["id"] == "req_fail" for p in plugins.LOADED_PLUGINS) # ── Async loading: pending registry, incremental publish, failed status ───────── def test_pending_then_incremental_publish_observed_during_setup(tmp_path, reset_plugin_state): """Every discovered plugin is recorded in PENDING_PLUGINS as "installing" up front, then GRADUATES into LOADED_PLUGINS as it becomes ready — one at a time, not all-at-once at the end (issue #421 incremental publish). Each plugin's setup() records the live LOADED/PENDING state under its own plugin id (read from ctx['log'].name): * During "aaa".setup(): both plugins are still pending+installing and nothing has graduated yet (LOADED is empty). * During "zzz".setup(): "aaa" has ALREADY graduated into LOADED while "zzz" is still pending — proving plugins publish incrementally. """ plugins = reset_plugin_state body = ( "import plugins\n" "def setup(app, ctx):\n" " pid = ctx['log'].name.rsplit('.', 1)[-1]\n" " app.state.snaps = getattr(app.state, 'snaps', {})\n" " app.state.snaps[pid] = {\n" " 'pending': {k: v['status'] for k, v in plugins.PENDING_PLUGINS.items()},\n" " 'loaded': [p['id'] for p in plugins.LOADED_PLUGINS],\n" " }\n" ) _make_plugin(tmp_path, "aaa", routes_body=body) _make_plugin(tmp_path, "zzz", routes_body=body) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() _run_load_plugins(plugins, fake_app, tmp_path) snaps = fake_app.state.snaps # During aaa.setup(): both pending+installing, nothing graduated yet. assert snaps["aaa"]["pending"] == {"aaa": "installing", "zzz": "installing"} assert snaps["aaa"]["loaded"] == [] # During zzz.setup(): aaa already graduated (incremental publish); zzz pending. assert snaps["zzz"]["loaded"] == ["aaa"] assert "zzz" in snaps["zzz"]["pending"] assert "aaa" not in snaps["zzz"]["pending"] # Final: both ready, none pending. assert sorted(p["id"] for p in plugins.LOADED_PLUGINS) == ["aaa", "zzz"] assert plugins.PENDING_PLUGINS == {} def test_stale_load_pass_cannot_republish_after_newer_pass(tmp_path, reset_plugin_state): """A still-running load pass must NOT republish into the registries after a NEWER load_plugins() pass has cleared them (re-entrancy race: a "reload plugins" action, FEEDBACK_SYNC_STARTUP hot-reload, or test teardown firing while the first pass's background install thread is mid-flight). The loader bumps a generation token at the start of every pass; _graduate() and _mark_failed() bail when the generation has advanced. Without the guard, the older pass's _graduate() would re-insert a plugin the newer pass already cleared, leaving a cross-generation / duplicate entry in LOADED_PLUGINS. """ import threading plugins = reset_plugin_state setup_entered = threading.Event() release_setup = threading.Event() # Stash the events on the module so the file-exec'd plugin setup() can reach # them without globals plumbing. Cleaned up at the end of the test. plugins._TEST_setup_entered = setup_entered plugins._TEST_release_setup = release_setup try: root1 = tmp_path / "gen1" root2 = tmp_path / "gen2" root1.mkdir() root2.mkdir() # Pass 1's plugin blocks inside setup() — past discovery and the PENDING # seed, just before it would graduate — until the test releases it. _make_plugin( root1, "slow", routes_body=( "import plugins\n" "def setup(app, ctx):\n" " plugins._TEST_setup_entered.set()\n" " plugins._TEST_release_setup.wait(5)\n" ), ) _make_plugin(root2, "fast") # no-op setup, graduates immediately errors = [] def _pass1(): saved = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = root1 try: plugins.load_plugins(type("FakeApp", (), {})(), {}) except Exception as e: # pragma: no cover - failure path errors.append(e) finally: plugins.PLUGINS_DIR = saved t1 = threading.Thread(target=_pass1) t1.start() # Wait until pass 1 is parked inside slow.setup() (discovery done, # PENDING seeded, about to eventually graduate "slow"). assert setup_entered.wait(5), "pass 1 never entered slow.setup()" # Pass 2 runs to completion on the main thread: bumps the generation, # clears both registries, graduates "fast". saved = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = root2 try: plugins.load_plugins(type("FakeApp", (), {})(), {}) finally: plugins.PLUGINS_DIR = saved # Release pass 1; it resumes and attempts to graduate "slow" — now stale. release_setup.set() t1.join(5) assert not t1.is_alive(), "pass 1 thread did not finish" assert not errors, f"pass 1 raised: {errors}" # Only pass 2's output survives. The stale pass's "slow" must NOT appear # in either registry — its _graduate() was a no-op. assert [p["id"] for p in plugins.LOADED_PLUGINS] == ["fast"] assert "slow" not in plugins.PENDING_PLUGINS assert "slow" not in {p["id"] for p in plugins.LOADED_PLUGINS} finally: delattr(plugins, "_TEST_setup_entered") delattr(plugins, "_TEST_release_setup") def test_failed_plugin_shows_failed_status_and_stays_visible(tmp_path, reset_plugin_state): """A plugin whose routes fail to load is NOT added to LOADED_PLUGINS (ready-only) but STAYS visible as a disabled "failed" entry in PENDING_PLUGINS and on /api/plugins, with its error text — so the nav can render it disabled with a tooltip rather than dropping it (ADR 0001).""" plugins = reset_plugin_state _make_plugin(tmp_path, "okp") # healthy, no-op setup _make_plugin( tmp_path, "broken", routes_body="def setup(app, ctx):\n raise RuntimeError('boom')\n", ) _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) # LOADED_PLUGINS stays ready-only: broken absent, okp present. loaded_ids = {p["id"] for p in plugins.LOADED_PLUGINS} assert "broken" not in loaded_ids assert "okp" in loaded_ids # broken remains a visible "failed" pending entry with error text. assert plugins.PENDING_PLUGINS["broken"]["status"] == "failed" assert plugins.PENDING_PLUGINS["broken"]["error"] assert "okp" not in plugins.PENDING_PLUGINS # graduated, no longer pending # /api/plugins exposes the union with per-plugin status + error. client = _make_api_client(plugins) try: rows = {p["id"]: p for p in client.get("/api/plugins").json()} assert rows["broken"]["status"] == "failed" assert rows["broken"]["error"] assert rows["okp"]["status"] == "ready" assert rows["okp"]["error"] is None finally: client.close() def test_api_plugins_surfaces_installing_pending_entry(tmp_path, reset_plugin_state): """An "installing" pending entry surfaces on /api/plugins immediately with its manifest-derived nav fields and status="installing" (no error), so the frontend can render a disabled "installing…" nav slot before the (possibly very slow) dependency install finishes.""" plugins = reset_plugin_state pdir = tmp_path / "heavy" pdir.mkdir() plugins.PENDING_PLUGINS["heavy"] = { "id": "heavy", "name": "Heavy ML Plugin", "nav": "/heavy", "type": "visualization", "bundled": True, "version": "2.0.0", "has_screen": True, "has_script": False, "has_settings": False, "has_tour": False, "_order": 0, "status": "installing", "error": None, } client = _make_api_client(plugins) try: rows = {p["id"]: p for p in client.get("/api/plugins").json()} h = rows["heavy"] assert h["status"] == "installing" assert h["error"] is None assert h["nav"] == "/heavy" assert h["type"] == "visualization" assert h["bundled"] is True assert h["version"] == "2.0.0" assert h["has_screen"] is True assert h["fallback"] is False finally: client.close() def test_api_plugins_union_sorted_by_discovery_order(tmp_path, reset_plugin_state): """The /api/plugins union is re-sorted by discovery order so an installing plugin occupies its eventual nav slot regardless of whether it currently lives in LOADED_PLUGINS (ready) or PENDING_PLUGINS (installing/failed).""" plugins = reset_plugin_state pdir = tmp_path / "d" pdir.mkdir() # Ready plugin discovered SECOND (order 1) lives in LOADED_PLUGINS. plugins.LOADED_PLUGINS.append({ "id": "ready_second", "name": "Ready", "nav": None, "type": None, "bundled": False, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "status": "ready", "_order": 1, "_dir": pdir, "_manifest": {}, }) # Installing plugin discovered FIRST (order 0) lives in PENDING_PLUGINS. plugins.PENDING_PLUGINS["installing_first"] = { "id": "installing_first", "name": "Installing", "nav": None, "type": None, "bundled": False, "version": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_order": 0, "status": "installing", "error": None, } client = _make_api_client(plugins) try: order = [p["id"] for p in client.get("/api/plugins").json()] assert order == ["installing_first", "ready_second"] finally: client.close() def test_pending_plugin_assets_not_served(tmp_path, reset_plugin_state): """Asset endpoints serve only ready plugins. A plugin still installing (present in PENDING_PLUGINS, absent from LOADED_PLUGINS) returns 404 for its screen.js / screen.html even though its manifest advertises them.""" plugins = reset_plugin_state pdir = tmp_path / "heavy" pdir.mkdir() (pdir / "screen.js").write_text("console.log('x');\n") (pdir / "screen.html").write_text("

hi

\n") plugins.PENDING_PLUGINS["heavy"] = { "id": "heavy", "name": "Heavy", "nav": None, "type": None, "bundled": False, "version": None, "has_screen": True, "has_script": True, "has_settings": False, "has_tour": False, "_order": 0, "status": "installing", "error": None, "_dir": pdir, "_manifest": {}, } client = _make_api_client(plugins) try: assert client.get("/api/plugins/heavy/screen.js").status_code == 404 assert client.get("/api/plugins/heavy/screen.html").status_code == 404 finally: client.close() # ── Tour API tests ───────────────────────────────────────────────────────────── def _make_tour_plugin(plugin_root, plugin_id, *, tour_file_name="tour.json", tour_content=None): """Create a minimal plugin directory with a tour field in plugin.json.""" plugin_dir = plugin_root / plugin_id plugin_dir.mkdir(parents=True) manifest = {"id": plugin_id, "name": plugin_id, "tour": tour_file_name} (plugin_dir / "plugin.json").write_text(json.dumps(manifest)) if tour_content is not None: (plugin_dir / tour_file_name).write_text(json.dumps(tour_content)) return plugin_dir def _make_api_client(plugins): """Register the plugin API on a fresh FastAPI app and return a TestClient.""" app = FastAPI() plugins.register_plugin_api(app) return TestClient(app) @pytest.fixture() def tour_client(tmp_path, reset_plugin_state): """A TestClient with two plugins: one with a tour, one without.""" plugins = reset_plugin_state # Plugin with tour tour_content = {"tour": [{"id": "step1", "title": "Hello", "content": "World"}]} plugin_dir = _make_tour_plugin(tmp_path, "with_tour", tour_content=tour_content) # Plugin without tour no_tour_dir = tmp_path / "no_tour" no_tour_dir.mkdir() (no_tour_dir / "plugin.json").write_text(json.dumps({"id": "no_tour", "name": "No Tour"})) # Stub LOADED_PLUGINS with both plugins plugins.LOADED_PLUGINS.append({ "id": "with_tour", "name": "With Tour", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": True, "_dir": plugin_dir, "_manifest": {"tour": "tour.json"}, }) plugins.LOADED_PLUGINS.append({ "id": "no_tour", "name": "No Tour", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": no_tour_dir, "_manifest": {}, }) client = _make_api_client(plugins) try: yield client, plugin_dir finally: client.close() def test_list_plugins_includes_has_tour(tour_client): """GET /api/plugins must include a has_tour boolean for each plugin.""" client, _ = tour_client r = client.get("/api/plugins") assert r.status_code == 200 plugins_list = r.json() ids = {p["id"]: p for p in plugins_list} assert "has_tour" in ids["with_tour"] assert ids["with_tour"]["has_tour"] is True assert "has_tour" in ids["no_tour"] assert ids["no_tour"]["has_tour"] is False def test_nav_entry_derives_has_styles_from_manifest(tmp_path, reset_plugin_state): """The real loader must derive has_styles + carry the styles path from the manifest WITHOUT importing plugin code (mirrors has_screen/has_tour).""" plugins = reset_plugin_state styled = _make_plugin(tmp_path, "styled") (styled / "plugin.json").write_text(json.dumps( {"id": "styled", "name": "Styled", "routes": "routes.py", "styles": "assets/plugin.css"})) _make_plugin(tmp_path, "plain") # no styles key _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) by_id = {p["id"]: p for p in plugins.LOADED_PLUGINS} assert by_id["styled"]["has_styles"] is True assert by_id["styled"]["styles"] == "assets/plugin.css" assert by_id["plain"]["has_styles"] is False assert by_id["plain"]["styles"] is None def test_list_plugins_surfaces_has_styles_and_path(tmp_path, reset_plugin_state): """GET /api/plugins must surface has_styles + styles for each plugin so the frontend can build the URL; a plugin without styles → False/None.""" plugins = reset_plugin_state plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "with_styles", "name": "With Styles", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "has_styles": True, "styles": "assets/plugin.css", "_dir": plugin_dir, "_manifest": {}, }) plugins.LOADED_PLUGINS.append({ "id": "no_styles", "name": "No Styles", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins) try: r = client.get("/api/plugins") assert r.status_code == 200 ids = {p["id"]: p for p in r.json()} assert ids["with_styles"]["has_styles"] is True assert ids["with_styles"]["styles"] == "assets/plugin.css" # A stubbed entry without the keys falls back cleanly (no KeyError). assert ids["no_styles"]["has_styles"] is False assert ids["no_styles"]["styles"] is None finally: client.close() def test_nav_entry_surfaces_pedalboard_metadata(tmp_path, reset_plugin_state): """The loader must carry description/category/icon from the manifest WITHOUT importing plugin code, and auto-detect assets/thumb.png when `icon` is omitted (v3 Pedalboard page).""" plugins = reset_plugin_state full = _make_plugin(tmp_path, "full") (full / "plugin.json").write_text(json.dumps( {"id": "full", "name": "Full", "routes": "routes.py", "description": "A short blurb.", "category": "audio", "icon": "assets/custom.png"})) # No `icon` in the manifest, but ships assets/thumb.png → auto-detected. probed = _make_plugin(tmp_path, "probed") (probed / "plugin.json").write_text(json.dumps( {"id": "probed", "name": "Probed", "routes": "routes.py"})) (probed / "assets").mkdir(exist_ok=True) (probed / "assets" / "thumb.png").write_bytes(b"\x89PNG\r\n") _make_plugin(tmp_path, "bare") # no pedalboard fields, no thumb _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) by_id = {p["id"]: p for p in plugins.LOADED_PLUGINS} assert by_id["full"]["description"] == "A short blurb." assert by_id["full"]["category"] == "audio" assert by_id["full"]["icon"] == "assets/custom.png" # Auto-detected convention thumbnail. assert by_id["probed"]["icon"] == "assets/thumb.png" assert by_id["probed"]["description"] is None assert by_id["probed"]["category"] is None # Nothing declared and nothing on disk → all None (no default injected here; # the frontend supplies the default pedal graphic). assert by_id["bare"]["icon"] is None assert by_id["bare"]["description"] is None assert by_id["bare"]["category"] is None def test_list_plugins_surfaces_pedalboard_metadata(tmp_path, reset_plugin_state): """GET /api/plugins must surface description/category/icon; a stubbed entry missing the keys falls back to None without raising.""" plugins = reset_plugin_state plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "decorated", "name": "Decorated", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "description": "Blurb", "category": "tools", "icon": "assets/thumb.png", "_dir": plugin_dir, "_manifest": {}, }) plugins.LOADED_PLUGINS.append({ "id": "plain", "name": "Plain", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins) try: r = client.get("/api/plugins") assert r.status_code == 200 ids = {p["id"]: p for p in r.json()} assert ids["decorated"]["description"] == "Blurb" assert ids["decorated"]["category"] == "tools" assert ids["decorated"]["icon"] == "assets/thumb.png" assert ids["plain"]["description"] is None assert ids["plain"]["category"] is None assert ids["plain"]["icon"] is None finally: client.close() def test_list_plugins_version_field(tmp_path, reset_plugin_state): """GET /api/plugins must include a `version` field for each plugin. A plugin that declares ``version`` in its manifest must surface it; a plugin that omits the field must return ``None`` (not raise). """ plugins_mod = reset_plugin_state versioned_dir = tmp_path / "versioned" versioned_dir.mkdir() (versioned_dir / "plugin.json").write_text( json.dumps({"id": "versioned", "name": "Versioned", "version": "1.2.3"}) ) unversioned_dir = tmp_path / "unversioned" unversioned_dir.mkdir() (unversioned_dir / "plugin.json").write_text( json.dumps({"id": "unversioned", "name": "Unversioned"}) ) plugins_mod.LOADED_PLUGINS.append({ "id": "versioned", "name": "Versioned", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": versioned_dir, "_manifest": {"version": "1.2.3"}, }) plugins_mod.LOADED_PLUGINS.append({ "id": "unversioned", "name": "Unversioned", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": unversioned_dir, "_manifest": {}, }) client = _make_api_client(plugins_mod) try: r = client.get("/api/plugins") assert r.status_code == 200 plugins_list = r.json() ids = {p["id"]: p for p in plugins_list} assert "version" in ids["versioned"], "version key must be present" assert ids["versioned"]["version"] == "1.2.3" assert "version" in ids["unversioned"], "version key must be present even when absent from manifest" assert ids["unversioned"]["version"] is None finally: client.close() def test_list_plugins_exposes_profile_domain_manifest_metadata(tmp_path, reset_plugin_state): plugins_mod = reset_plugin_state plugin_dir = tmp_path / "declared" plugin_dir.mkdir() manifest = { "id": "declared", "name": "Declared", "nav": "Declared", "screen": "screen.html", "settings": "settings.html", "type": "visualization", "routes": "routes.py", "standards": ["capability-pipelines.v1", "profiles.v1", 42, ""], "capabilities": {"profiles": {"roles": ["observer"]}}, "settings_schema": {"schema_version": "1", "packable_keys": ["gain"]}, "ui_contributions": {"ui.player-panels": [{"id": "declared-panel"}]}, "ui": {"ui.player-controls": [{"id": "declared-control"}]}, "runtime_domains": {"midi-control": {"role": "observer"}, "custom-domain": {"role": "owner"}}, "domains": {"jobs": [{"id": "declared-job"}], "tempo-clock": {"role": "observer"}}, } plugins_mod.LOADED_PLUGINS.append({ "id": "declared", "name": "Declared", "nav": "Declared", "type": "visualization", "has_screen": True, "has_script": False, "has_settings": True, "has_tour": False, "_dir": plugin_dir, "_manifest": manifest, }) client = _make_api_client(plugins_mod) try: response = client.get("/api/plugins") assert response.status_code == 200 plugin = response.json()[0] assert plugin["standards"] == ["capability-pipelines.v1", "profiles.v1"] assert plugin["capabilities"] == {"profiles": {"roles": ["observer"]}} assert plugin["settings_schema"] == {"schema_version": "1", "packable_keys": ["gain"]} assert plugin["ui_contributions"]["declared"] == { "ui.player-controls": [{"id": "declared-control"}], "ui.player-panels": [{"id": "declared-panel"}], } assert {item["legacy_source"] for item in plugin["ui_contributions"]["legacy"]} == {"nav", "screen", "settings", "type"} assert plugin["runtime_domains"] == { "custom-domain": {"role": "owner"}, "jobs": [{"id": "declared-job"}], "midi-control": {"role": "observer"}, "tempo-clock": {"role": "observer"}, } finally: client.close() def test_tour_json_serves_file(tour_client): """GET /api/plugins/{id}/tour.json returns file content as JSON for a plugin with a tour.""" client, _ = tour_client r = client.get("/api/plugins/with_tour/tour.json") assert r.status_code == 200 assert r.headers["content-type"].startswith("application/json") data = r.json() assert "tour" in data assert data["tour"][0]["id"] == "step1" def test_tour_json_returns_404_for_missing_plugin(tour_client): """GET /api/plugins/{id}/tour.json returns 404 JSON for an unknown plugin.""" client, _ = tour_client r = client.get("/api/plugins/nonexistent/tour.json") assert r.status_code == 404 assert r.headers["content-type"].startswith("application/json") def test_tour_json_returns_404_for_plugin_without_tour(tour_client): """GET /api/plugins/{id}/tour.json returns 404 for a plugin with no tour manifest entry.""" client, _ = tour_client r = client.get("/api/plugins/no_tour/tour.json") assert r.status_code == 404 assert r.headers["content-type"].startswith("application/json") def test_tour_json_rejects_path_traversal(tmp_path, reset_plugin_state): """tour field with `../` path traversal must be rejected (returns 404).""" plugins = reset_plugin_state plugin_dir = tmp_path / "evil_plugin" plugin_dir.mkdir() # Write a 'secret' file outside the plugin dir that the traversal targets (tmp_path / "secret.json").write_text(json.dumps({"secret": "data"})) plugins.LOADED_PLUGINS.append({ "id": "evil_plugin", "name": "Evil Plugin", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": True, "_dir": plugin_dir, "_manifest": {"tour": "../secret.json"}, }) app = FastAPI() plugins.register_plugin_api(app) client = TestClient(app) try: r = client.get("/api/plugins/evil_plugin/tour.json") assert r.status_code == 404 finally: client.close() def test_tour_json_handles_non_string_tour_manifest(tmp_path, reset_plugin_state): """A plugin whose `tour` manifest field is truthy but not a string or dict (e.g. ``true``, ``1``) must return 404 without raising AttributeError.""" plugins = reset_plugin_state plugin_dir = tmp_path / "bool_tour_plugin" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "bool_tour_plugin", "name": "Bool Tour Plugin", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, # _is_valid_tour_manifest(True) → False "_dir": plugin_dir, "_manifest": {"tour": True}, # boolean true in manifest }) app = FastAPI() plugins.register_plugin_api(app) client = TestClient(app) try: r = client.get("/api/plugins/bool_tour_plugin/tour.json") assert r.status_code == 404 assert r.headers["content-type"].startswith("application/json") finally: client.close() def test_tour_json_rejects_directory_path(tmp_path, reset_plugin_state): """A `tour` field of `"."` resolves to the plugin dir itself. The route must return 404 (not IsADirectoryError) because is_file() gates read_text.""" plugins = reset_plugin_state plugin_dir = tmp_path / "dot_tour_plugin" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "dot_tour_plugin", "name": "Dot Tour Plugin", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": True, "_dir": plugin_dir, "_manifest": {"tour": "."}, }) app = FastAPI() plugins.register_plugin_api(app) client = TestClient(app) try: r = client.get("/api/plugins/dot_tour_plugin/tour.json") assert r.status_code == 404 assert r.headers["content-type"].startswith("application/json") finally: client.close() def test_tour_json_rejects_null_file_key(tmp_path, reset_plugin_state): """`{"tour": {"file": null}}` has an explicitly invalid `file` value. _is_valid_tour_manifest must reject it (has_tour=False) and the route must return 404 without an AttributeError.""" import plugins as _plugins_mod # Directly verify the validation function rejects {"file": None} assert _plugins_mod._is_valid_tour_manifest({"file": None}) is False assert _plugins_mod._is_valid_tour_manifest({"file": ""}) is False assert _plugins_mod._is_valid_tour_manifest({}) is True # bare dict defaults to tour.json plugins = reset_plugin_state plugin_dir = tmp_path / "null_file_plugin" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "null_file_plugin", "name": "Null File Plugin", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, # _is_valid_tour_manifest({"file": None}) → False "_dir": plugin_dir, "_manifest": {"tour": {"file": None}}, }) app = FastAPI() plugins.register_plugin_api(app) client = TestClient(app) try: # Confirm the listing endpoint reflects has_tour=False for this plugin listing = client.get("/api/plugins").json() p_entry = next(p for p in listing if p["id"] == "null_file_plugin") assert p_entry["has_tour"] is False # Route must also return 404 (defensive: route validates independently) r = client.get("/api/plugins/null_file_plugin/tour.json") assert r.status_code == 404 assert r.headers["content-type"].startswith("application/json") finally: client.close() def test_install_requirements_marker_is_stable_across_calls(tmp_path, reset_plugin_state, monkeypatch): """The install marker must be deterministic so plugins don't reinstall every boot. Regression for a bug where ``hash(req_file.read_text())`` returned a different integer per process (PYTHONHASHSEED randomisation), so ``_install_requirements`` re-ran pip on every restart. """ import hashlib import subprocess as _sp plugins = reset_plugin_state plugin_dir = tmp_path / "myplugin" plugin_dir.mkdir() req_file = plugin_dir / "requirements.txt" req_file.write_text("librosa>=0.10.1\nnumpy>=1.24\n") pip_target = tmp_path / "pip_packages" monkeypatch.setattr(plugins, "_PIP_TARGET", pip_target) pip_calls: list = [] def fake_run(cmd, **kwargs): pip_calls.append(cmd) return _sp.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") monkeypatch.setattr(plugins.subprocess, "run", fake_run) # First call — pip runs once and writes the marker. assert plugins._install_requirements(plugin_dir, "myplugin") is True assert len(pip_calls) == 1, "first call must invoke pip" marker = pip_target / ".installed_myplugin" assert marker.exists(), "marker must be written after successful install" first_marker_contents = marker.read_text().strip() # Second call in the same process — must skip pip. assert plugins._install_requirements(plugin_dir, "myplugin") is True assert len(pip_calls) == 1, "second in-process call must not re-invoke pip" # The crucial regression check: the marker must be derivable purely # from the requirements.txt bytes — no process-local state. If a # future change reintroduces ``hash()`` (or any other randomised # digest) the freshly-computed expected value below differs from # what was written, and the marker would never match on restart. expected = hashlib.sha256(req_file.read_bytes()).hexdigest() assert first_marker_contents == expected, ( "marker must contain a deterministic digest of requirements.txt; " f"got {first_marker_contents!r}, expected {expected!r}" ) # ── settings.html encoding ───────────────────────────────────────────────────── def test_settings_html_served_as_utf8_regardless_of_host_locale( tmp_path, reset_plugin_state, monkeypatch ): """Regression for feedBack-desktop#166 — mojibake in the Settings UI. The ``settings.html`` plugin endpoint must read its file with an explicit ``encoding="utf-8"``. Without it, ``Path.read_text()`` falls back to the host's locale encoding — cp1252 on a Windows host — which decodes UTF-8 punctuation (em-dash ``—``, arrow ``→``) into mojibake (``â€``, ``â†``). Its sibling endpoints ``screen.html`` / ``screen.js`` already pass ``encoding="utf-8"``; ``settings.html`` was the lone outlier. This test simulates the Windows locale default so the bug is caught even on a UTF-8 CI host. """ plugins = reset_plugin_state plugin_dir = tmp_path / "fx" plugin_dir.mkdir() settings_html = ( "

Audio Quality (Guitar → audio rendering)

" "

Default — GeneralUser GS (~32 MB, bundled)

" ) # The on-disk file is genuine, correct UTF-8 — the strings are not the bug. (plugin_dir / "settings.html").write_text(settings_html, encoding="utf-8") (plugin_dir / "plugin.json").write_text( json.dumps({"id": "fx", "name": "FX", "settings": {"html": "settings.html"}}) ) plugins.LOADED_PLUGINS.append({ "id": "fx", "name": "FX", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": True, "has_tour": False, "_dir": plugin_dir, "_manifest": {"settings": {"html": "settings.html"}}, }) # Simulate a Windows host: a ``read_text()`` call with no explicit # encoding falls back to the cp1252 locale default. An explicit # encoding (the fix) is honoured unchanged. real_read_text = Path.read_text def cp1252_default_read_text(self, encoding=None, *args, **kwargs): if encoding is None: return self.read_bytes().decode("cp1252") return real_read_text(self, encoding, *args, **kwargs) monkeypatch.setattr(Path, "read_text", cp1252_default_read_text) client = _make_api_client(plugins) try: r = client.get("/api/plugins/fx/settings.html") assert r.status_code == 200 # The served body must byte-for-byte match the on-disk UTF-8 file. assert r.content == settings_html.encode("utf-8") # And must contain the real characters, not their mojibake forms. assert "Guitar → audio rendering" in r.text assert "Default — GeneralUser GS" in r.text assert "â€" not in r.text # 'â€' — em-dash decoded as cp1252 assert "â†" not in r.text # 'â†' — arrow decoded as cp1252 finally: client.close() @pytest.fixture() def asset_client(tmp_path, reset_plugin_state): """A TestClient with one plugin that bundles files under ``assets/``.""" plugins = reset_plugin_state plugin_dir = tmp_path / "stems" (plugin_dir / "assets" / "sub").mkdir(parents=True) (plugin_dir / "assets" / "worklet.js").write_text("registerProcessor('x', class {});\n") (plugin_dir / "assets" / "plugin.css").write_text(".x-[11px]{font-size:11px}\n") (plugin_dir / "assets" / "sub" / "data.bin").write_bytes(b"\x00\x01\x02") # A sibling Python module OUTSIDE assets/ that must never be reachable. (plugin_dir / "routes.py").write_text("SECRET = 1\n") plugins.LOADED_PLUGINS.append({ "id": "stems", "name": "Stems", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins) try: yield client finally: client.close() def test_plugin_asset_serves_bundled_file(asset_client): """A real file under /assets/ is served with a JS media type.""" r = asset_client.get("/api/plugins/stems/assets/worklet.js") assert r.status_code == 200 assert r.text == "registerProcessor('x', class {});\n" assert "javascript" in r.headers["content-type"] def test_plugin_asset_serves_css_as_stylesheet(asset_client): """A plugin's `styles` CSS under assets/ must serve as text/css so a (the styles capability) is honoured by the browser.""" r = asset_client.get("/api/plugins/stems/assets/plugin.css") assert r.status_code == 200 assert "text/css" in r.headers["content-type"] def test_plugin_asset_serves_nested_file(asset_client): """Nested asset paths (assets/sub/...) resolve via the {path} match.""" r = asset_client.get("/api/plugins/stems/assets/sub/data.bin") assert r.status_code == 200 assert r.content == b"\x00\x01\x02" def test_plugin_asset_missing_file_404(asset_client): """A path under assets/ that does not exist returns 404, not 500.""" r = asset_client.get("/api/plugins/stems/assets/nope.js") assert r.status_code == 404 def test_plugin_asset_rejects_traversal(asset_client): """`..` traversal must not escape assets/ to reach plugin Python modules.""" # FastAPI normalises some `..` segments at the routing layer; hit the # handler's own safe_join guard with an encoded traversal too. for attack in ( "/api/plugins/stems/assets/../routes.py", "/api/plugins/stems/assets/..%2froutes.py", "/api/plugins/stems/assets/sub/../../routes.py", ): r = asset_client.get(attack) assert r.status_code == 404, attack assert "SECRET" not in r.text def test_plugin_asset_unknown_plugin_404(asset_client): """An asset request for an unloaded plugin id returns 404.""" r = asset_client.get("/api/plugins/ghost/assets/worklet.js") assert r.status_code == 404 # --- Plugin enable/disable (v3 Pedalboard footswitch) --------------------- # # Backend contract: every /api/plugins entry carries `enabled` (default true); # POST /api/plugins/{id}/enabled persists the choice to # CONFIG_DIR/plugin_state.json; the loader skips disabled plugins at startup but # still surfaces them as "off" pedals; a disabled plugin is excluded from the # capability pipeline (its capability metadata is suppressed in /api/plugins). def test_list_plugins_enabled_default_true(tmp_path, reset_plugin_state): """A plugin entry with no `enabled` key surfaces enabled:true by default.""" plugins = reset_plugin_state plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "demo", "name": "Demo", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins) try: r = client.get("/api/plugins") assert r.status_code == 200 ids = {p["id"]: p for p in r.json()} assert ids["demo"]["enabled"] is True finally: client.close() def test_set_plugin_enabled_persists_and_suppresses_caps(tmp_path, reset_plugin_state, monkeypatch): """Disabling persists to plugin_state.json, returns {id, enabled:false}, and a subsequent GET shows enabled:false AND empty capability metadata so the plugin no longer occupies the capability pipeline graph.""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "demo", "name": "Demo", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, "capabilities": {"example.domain": {"role": "owner"}}, "standards": ["capability-pipelines.v1"], }) client = _make_api_client(plugins) try: r = client.post("/api/plugins/demo/enabled", json={"enabled": False}) assert r.status_code == 200 assert r.json() == {"id": "demo", "enabled": False} # Persisted (only the non-default entry is stored). state = json.loads((config_dir / "plugin_state.json").read_text()) assert state == {"demo": {"enabled": False}} # Reflected immediately, with capabilities suppressed. r = client.get("/api/plugins") ids = {p["id"]: p for p in r.json()} assert ids["demo"]["enabled"] is False assert ids["demo"]["capabilities"] == {} assert ids["demo"]["standards"] == [] finally: client.close() def test_set_plugin_enabled_re_enable_clears_state(tmp_path, reset_plugin_state, monkeypatch): """Re-enabling drops the key from plugin_state.json (only non-defaults are stored) and restores the plugin's capability metadata in /api/plugins.""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) (config_dir / "plugin_state.json").write_text(json.dumps({"demo": {"enabled": False}})) plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "demo", "name": "Demo", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, "enabled": False, "capabilities": {"example.domain": {"role": "owner"}}, "standards": ["capability-pipelines.v1"], }) client = _make_api_client(plugins) try: r = client.post("/api/plugins/demo/enabled", json={"enabled": True}) assert r.status_code == 200 assert r.json() == {"id": "demo", "enabled": True} assert json.loads((config_dir / "plugin_state.json").read_text()) == {} r = client.get("/api/plugins") ids = {p["id"]: p for p in r.json()} assert ids["demo"]["enabled"] is True assert ids["demo"]["capabilities"] == {"example.domain": {"role": "owner"}} assert ids["demo"]["standards"] == ["capability-pipelines.v1"] finally: client.close() def test_set_plugin_enabled_unknown_id_404(tmp_path, reset_plugin_state, monkeypatch): """Toggling a plugin id that isn't loaded or pending → 404.""" plugins = reset_plugin_state monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) client = _make_api_client(plugins) try: r = client.post("/api/plugins/ghost/enabled", json={"enabled": False}) assert r.status_code == 404 assert "error" in r.json() finally: client.close() def test_set_plugin_enabled_bad_body_400(tmp_path, reset_plugin_state, monkeypatch): """A missing or non-boolean `enabled` field → 400 (not FastAPI's 422).""" plugins = reset_plugin_state monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "demo", "name": "Demo", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins) try: for bad in ({}, {"enabled": "yes"}, {"enabled": 1}, {"other": True}): r = client.post("/api/plugins/demo/enabled", json=bad) assert r.status_code == 400, bad assert "error" in r.json() # A non-JSON body is rejected too. r = client.post("/api/plugins/demo/enabled", content=b"not json") assert r.status_code == 400 finally: client.close() def test_set_plugin_enabled_guardrails_block_always_on(tmp_path, reset_plugin_state, monkeypatch): """A LOADED capability_inspector / app_tour_* may never be disabled → 400, and nothing is written to plugin_state.json.""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() # The guard rail runs AFTER the existence check, so the always-on plugins # must actually be present for the 400 to fire (an absent id → 404 instead). for pid in ("capability_inspector", "app_tour_library"): plugins.LOADED_PLUGINS.append({ "id": pid, "name": pid, "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) client = _make_api_client(plugins) try: for pid in ("capability_inspector", "app_tour_library"): r = client.post(f"/api/plugins/{pid}/enabled", json={"enabled": False}) assert r.status_code == 400, pid assert "error" in r.json() assert not (config_dir / "plugin_state.json").exists() finally: client.close() def test_set_plugin_enabled_unknown_always_on_id_is_404_not_400(tmp_path, reset_plugin_state, monkeypatch): """An UNKNOWN id that happens to match the always-on pattern (e.g. app_tour_ghost) is "not found" (404), not "cannot be disabled" (400) — the response must not depend on the spelling of a nonexistent id (Codex review).""" plugins = reset_plugin_state monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) client = _make_api_client(plugins) try: r = client.post("/api/plugins/app_tour_ghost/enabled", json={"enabled": False}) assert r.status_code == 404 finally: client.close() def test_set_plugin_enabled_persist_failure_returns_500(tmp_path, reset_plugin_state, monkeypatch): """A filesystem write failure when persisting is a controlled 500, and the in-memory flag is NOT flipped (memory and disk stay consistent).""" plugins = reset_plugin_state monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) plugin_dir = tmp_path / "dummy" plugin_dir.mkdir() plugins.LOADED_PLUGINS.append({ "id": "demo", "name": "Demo", "nav": None, "type": None, "has_screen": False, "has_script": False, "has_settings": False, "has_tour": False, "_dir": plugin_dir, "_manifest": {}, }) def boom(plugin_id, enabled): raise OSError("disk full") monkeypatch.setattr(plugins, "_persist_plugin_enabled", boom) client = _make_api_client(plugins) try: r = client.post("/api/plugins/demo/enabled", json={"enabled": False}) assert r.status_code == 500 assert "error" in r.json() # In-memory flag untouched, so a later GET still shows enabled. ids = {p["id"]: p for p in client.get("/api/plugins").json()} assert ids["demo"]["enabled"] is True finally: client.close() def test_disabled_plugin_skipped_by_loader(tmp_path, reset_plugin_state, monkeypatch): """A plugin persisted as enabled:false is NOT loaded (its routes.setup never runs) but still surfaces in /api/plugins as a disabled entry; an enabled sibling loads normally.""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) (config_dir / "plugin_state.json").write_text(json.dumps({"off": {"enabled": False}})) marker_body = ( "import os\n" "from pathlib import Path\n" "def setup(app, ctx):\n" " Path(os.environ['CONFIG_DIR'], 'marker_{id}').write_text('1')\n" ) plugins_root = tmp_path / "plugins" _make_plugin(plugins_root, "off", routes_body=marker_body.format(id="off")) _make_plugin(plugins_root, "on", routes_body=marker_body.format(id="on")) _run_load_plugins(plugins, type("FakeApp", (), {})(), plugins_root) loaded_ids = {p["id"] for p in plugins.LOADED_PLUGINS} assert "on" in loaded_ids assert "off" not in loaded_ids # routes.setup() ran only for the enabled plugin. assert (config_dir / "marker_on").exists() assert not (config_dir / "marker_off").exists() # The disabled plugin is still a visible, disabled pending entry. assert plugins.PENDING_PLUGINS["off"]["status"] == "disabled" assert plugins.PENDING_PLUGINS["off"]["enabled"] is False client = _make_api_client(plugins) try: ids = {p["id"]: p for p in client.get("/api/plugins").json()} assert ids["off"]["enabled"] is False assert ids["off"]["status"] == "disabled" assert ids["on"]["enabled"] is True finally: client.close() def test_corrupt_plugin_state_does_not_crash_loader(tmp_path, reset_plugin_state, monkeypatch): """A corrupt plugin_state.json is ignored (all plugins enabled by default) and loading does not raise.""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) (config_dir / "plugin_state.json").write_text("{not valid json") plugins_root = tmp_path / "plugins" _make_plugin(plugins_root, "ok") _run_load_plugins(plugins, type("FakeApp", (), {})(), plugins_root) assert "ok" in {p["id"] for p in plugins.LOADED_PLUGINS} def test_reenable_startup_disabled_plugin_keeps_caps_suppressed(tmp_path, reset_plugin_state, monkeypatch): """Re-enabling a plugin that was SKIPPED at startup (pending, status "disabled") flips its `enabled` flag immediately but must NOT reintroduce its capability metadata into /api/plugins — the plugin isn't mounted until the next restart, so surfacing its capabilities would register a phantom pipeline participant (Codex review).""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) (config_dir / "plugin_state.json").write_text(json.dumps({"off": {"enabled": False}})) # A startup-skipped plugin lives in PENDING_PLUGINS with status "disabled". plugins.PENDING_PLUGINS["off"] = { "id": "off", "name": "Off", "nav": None, "type": None, "status": "disabled", "enabled": False, "_order": 0, "capabilities": {"example.domain": {"role": "owner"}}, "standards": ["capability-pipelines.v1"], } client = _make_api_client(plugins) try: r = client.post("/api/plugins/off/enabled", json={"enabled": True}) assert r.status_code == 200 assert r.json() == {"id": "off", "enabled": True} ids = {p["id"]: p for p in client.get("/api/plugins").json()} # Flag reflects intent immediately... assert ids["off"]["enabled"] is True # ...but it is still not mounted, so capabilities stay suppressed. assert ids["off"]["status"] == "disabled" assert ids["off"]["capabilities"] == {} assert ids["off"]["standards"] == [] finally: client.close() def test_is_plugin_enabled_ignores_malformed_entry(reset_plugin_state): """Only a real boolean `enabled` is authoritative; missing or junk values fall back to enabled (matches the endpoint's strict isinstance(bool) check), so a partially-corrupt or hand-edited plugin_state.json can't silently disable a plugin (Codex review).""" plugins = reset_plugin_state # A genuine ``{"enabled": false}`` disables. assert plugins._is_plugin_enabled({"p": {"enabled": False}}, "p") is False assert plugins._is_plugin_enabled({"p": {"enabled": True}}, "p") is True # Junk / falsey-but-not-bool values are treated as enabled (default true). for junk in (0, 1, "", "false", [], {}, None): assert plugins._is_plugin_enabled({"p": {"enabled": junk}}, "p") is True, junk # Missing key, non-dict entry, and absent plugin all default to enabled. assert plugins._is_plugin_enabled({"p": {}}, "p") is True assert plugins._is_plugin_enabled({"p": "nope"}, "p") is True assert plugins._is_plugin_enabled({}, "p") is True def test_disable_while_installing_is_carried_into_loaded_entry(tmp_path, reset_plugin_state, monkeypatch): """A disable that arrives while a plugin is still loading must survive graduation. The ready entry is rebuilt from the startup _spec_entries snapshot, so _graduate carries the live pending entry's `enabled` flag forward — otherwise the runtime disable silently reverts until restart (Codex review).""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) # routes.setup() runs after the pending-seed snapshot but before this # plugin graduates, so flipping the live pending entry here simulates a # runtime disable racing an in-flight load. body = ( "import plugins\n" "def setup(app, ctx):\n" " plugins.PENDING_PLUGINS['demo']['enabled'] = False\n" ) plugins_root = tmp_path / "plugins" _make_plugin(plugins_root, "demo", routes_body=body) _run_load_plugins(plugins, type("FakeApp", (), {})(), plugins_root) by_id = {p["id"]: p for p in plugins.LOADED_PLUGINS} assert "demo" in by_id, "plugin should still graduate (routes already mounting)" # The ready entry reflects the runtime disable, not the stale startup value. assert by_id["demo"]["enabled"] is False def test_persist_does_not_clobber_on_unreadable_existing_state(tmp_path, reset_plugin_state, monkeypatch): """A read-time OSError on an existing plugin_state.json must propagate (→ a controlled 500 at the endpoint) rather than reset to {} and overwrite — that would silently clobber every other plugin's persisted disabled state (Codex review).""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) # Make the state path unreadable as a file: a directory at that path makes # read_text raise IsADirectoryError (a non-FileNotFound OSError). (config_dir / "plugin_state.json").mkdir() with pytest.raises(OSError): plugins._persist_plugin_enabled("demo", False) # Untouched — not replaced with a fresh file. assert (config_dir / "plugin_state.json").is_dir() def test_disable_before_route_setup_aborts_mount(tmp_path, reset_plugin_state, monkeypatch): """A disable that lands BEFORE a plugin's routes are mounted prevents the mount entirely (no setup(), not graduated) — not just suppressed after the fact. Plugins load alphabetically, so 'a_disabler' runs first and disables 'z_target' (simulating a runtime POST during an earlier install); the loader must re-check the live flag and skip 'z_target' (Codex review).""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) plugins_root = tmp_path / "plugins" _make_plugin(plugins_root, "a_disabler", routes_body=( "import plugins\n" "def setup(app, ctx):\n" " plugins.PENDING_PLUGINS['z_target']['enabled'] = False\n" )) _make_plugin(plugins_root, "z_target", routes_body=( "import os\n" "from pathlib import Path\n" "def setup(app, ctx):\n" " Path(os.environ['CONFIG_DIR'], 'z_marker').write_text('1')\n" )) _run_load_plugins(plugins, type("FakeApp", (), {})(), plugins_root) loaded_ids = {p["id"] for p in plugins.LOADED_PLUGINS} assert "a_disabler" in loaded_ids assert "z_target" not in loaded_ids # Its routes.setup() never ran... assert not (config_dir / "z_marker").exists() # ...and it remains a visible, disabled pending entry. assert plugins.PENDING_PLUGINS["z_target"]["status"] == "disabled" assert plugins.PENDING_PLUGINS["z_target"]["enabled"] is False def test_disable_during_requirements_install_aborts_mount(tmp_path, reset_plugin_state, monkeypatch): """A disable that lands WHILE this plugin's own requirements are installing is caught by the final re-check before route setup, so the plugin is never mounted (Codex review — closes the install-window gap).""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) plugins_root = tmp_path / "plugins" _make_plugin(plugins_root, "demo", routes_body=( "import os\n" "from pathlib import Path\n" "def setup(app, ctx):\n" " Path(os.environ['CONFIG_DIR'], 'demo_marker').write_text('1')\n" )) def fake_install(plugin_dir, plugin_id): if plugin_id == "demo": # Simulate a runtime POST /enabled {"enabled": false} arriving mid-install. plugins.PENDING_PLUGINS["demo"]["enabled"] = False return True monkeypatch.setattr(plugins, "_install_requirements", fake_install) _run_load_plugins(plugins, type("FakeApp", (), {})(), plugins_root) assert "demo" not in {p["id"] for p in plugins.LOADED_PLUGINS} assert not (config_dir / "demo_marker").exists(), "setup() must not have run" assert plugins.PENDING_PLUGINS["demo"]["status"] == "disabled" assert plugins.PENDING_PLUGINS["demo"]["enabled"] is False def test_non_utf8_plugin_state_does_not_crash_loader(tmp_path, reset_plugin_state, monkeypatch): """Encoding-corrupt (non-UTF-8) plugin_state.json is tolerated like bad JSON: the loader doesn't crash (all plugins enabled by default) and a subsequent toggle self-heals the file rather than escaping its controlled path (Codex review — UnicodeDecodeError is a ValueError, not OSError/JSONDecodeError).""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) (config_dir / "plugin_state.json").write_bytes(b"\xff\xfe\x00not utf-8") # Load helper must not raise on the corrupt bytes. assert plugins._load_plugin_state() == {} plugins_root = tmp_path / "plugins" _make_plugin(plugins_root, "ok") _run_load_plugins(plugins, type("FakeApp", (), {})(), plugins_root) assert "ok" in {p["id"] for p in plugins.LOADED_PLUGINS} # Persist self-heals the encoding-corrupt file instead of raising. plugins._persist_plugin_enabled("ok", False) assert json.loads((config_dir / "plugin_state.json").read_text()) == {"ok": {"enabled": False}} def test_disable_during_fallback_aborts_fallback_mount(tmp_path, reset_plugin_state, monkeypatch): """The bundled-plugin fallback path honors a runtime disable too: if a disable lands before the fallback user copy mounts, the loader skips it (no setup()), leaving a disabled entry — same contract as the main path (Codex review).""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) bundled_dir = tmp_path / "bundled" (bundled_dir / "highway_3d").mkdir(parents=True) (bundled_dir / "highway_3d" / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway", "routes": "routes.py", "bundled": True}) ) # Bundled setup() disables the plugin (simulating a runtime POST) and then # fails — so the disable is live by the time the fallback path runs. (bundled_dir / "highway_3d" / "routes.py").write_text( "import plugins\n" "def setup(app, ctx):\n" " plugins.PENDING_PLUGINS['highway_3d']['enabled'] = False\n" " raise RuntimeError('bundled broken')\n" ) user_dir = tmp_path / "user" (user_dir / "highway_3d").mkdir(parents=True) (user_dir / "highway_3d" / "plugin.json").write_text( json.dumps({"id": "highway_3d", "name": "3D Highway (user)", "routes": "routes.py"}) ) (user_dir / "highway_3d" / "routes.py").write_text( "def setup(app, ctx):\n app.state.origin = 'user_fallback'\n" ) monkeypatch.setenv("FEEDBACK_PLUGINS_DIR", str(user_dir)) fake_app = type("FakeApp", (), {})() fake_app.state = type("State", (), {})() saved_dir = plugins.PLUGINS_DIR plugins.PLUGINS_DIR = bundled_dir try: plugins.load_plugins(fake_app, {}) finally: plugins.PLUGINS_DIR = saved_dir # The fallback copy must NOT have been registered or mounted. assert "highway_3d" not in {p["id"] for p in plugins.LOADED_PLUGINS} assert getattr(fake_app.state, "origin", None) != "user_fallback" # It remains a visible, disabled entry. assert plugins.PENDING_PLUGINS["highway_3d"]["status"] == "disabled" assert plugins.PENDING_PLUGINS["highway_3d"]["enabled"] is False def test_loader_honors_persisted_disable_without_memory_flip(tmp_path, reset_plugin_state, monkeypatch): """The loader's _became_disabled check consults the PERSISTED state, not just the in-memory flag — so a disable that has been written to disk (the endpoint persists BEFORE flipping memory) is honored even if the in-memory flag hasn't been flipped yet (Codex review — persist-then-flip window). 'a_disabler' persists a disable for 'z_target' WITHOUT touching memory.""" plugins = reset_plugin_state config_dir = tmp_path / "config" config_dir.mkdir() monkeypatch.setenv("CONFIG_DIR", str(config_dir)) plugins_root = tmp_path / "plugins" _make_plugin(plugins_root, "a_disabler", routes_body=( "import plugins\n" "def setup(app, ctx):\n" " plugins._persist_plugin_enabled('z_target', False)\n" # disk only, no memory flip )) _make_plugin(plugins_root, "z_target", routes_body=( "import os\n" "from pathlib import Path\n" "def setup(app, ctx):\n" " Path(os.environ['CONFIG_DIR'], 'z_marker').write_text('1')\n" )) _run_load_plugins(plugins, type("FakeApp", (), {})(), plugins_root) loaded_ids = {p["id"] for p in plugins.LOADED_PLUGINS} assert "a_disabler" in loaded_ids assert "z_target" not in loaded_ids assert not (config_dir / "z_marker").exists() assert plugins.PENDING_PLUGINS["z_target"]["status"] == "disabled" assert plugins.PENDING_PLUGINS["z_target"]["enabled"] is False def test_settings_category_parsed_from_manifest(tmp_path, reset_plugin_state): """A plugin manifest's settings.category is parsed into settings_category on the loaded entry (drives the v3 settings-tab placement). Absent or a bare-string `settings` value yields None → the frontend's fallback tab.""" plugins = reset_plugin_state def _write(pid, settings_value): d = tmp_path / pid d.mkdir() (d / "plugin.json").write_text(json.dumps({ "id": pid, "name": pid, "routes": "routes.py", "settings": settings_value, })) (d / "routes.py").write_text("def setup(app, ctx):\n pass\n") _write("graphy", {"html": "settings.html", "category": "graphics"}) _write("plainset", {"html": "settings.html"}) # dict, no category _write("noset", None) # no settings at all _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) rows = {p["id"]: p for p in plugins.LOADED_PLUGINS} assert rows["graphy"]["settings_category"] == "graphics" assert rows["graphy"]["has_settings"] is True assert rows["plainset"]["settings_category"] is None assert rows["plainset"]["has_settings"] is True assert rows["noset"]["settings_category"] is None assert rows["noset"]["has_settings"] is False def test_fullscreen_flag_parsed_from_manifest(tmp_path, reset_plugin_state): """A plugin manifest's top-level `fullscreen: true` surfaces as the boolean `fullscreen` on the loaded entry (drives the v3 shell's immersive mode). Only a strict boolean `true` opts in — absent, false, or a truthy non-bool (e.g. the string "true") all resolve to False so a plugin can't be opted in by accident.""" plugins = reset_plugin_state def _write(pid, manifest_extra): d = tmp_path / pid d.mkdir() (d / "plugin.json").write_text(json.dumps({ "id": pid, "name": pid, "routes": "routes.py", "screen": "screen.html", **manifest_extra, })) (d / "routes.py").write_text("def setup(app, ctx):\n pass\n") (d / "screen.html").write_text("
") _write("immersive", {"fullscreen": True}) _write("strflag", {"fullscreen": "true"}) # truthy non-bool → not opted in _write("falseflag", {"fullscreen": False}) _write("noflag", {}) # field absent _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) rows = {p["id"]: p for p in plugins.LOADED_PLUGINS} assert rows["immersive"]["fullscreen"] is True assert rows["strflag"]["fullscreen"] is False assert rows["falseflag"]["fullscreen"] is False assert rows["noflag"]["fullscreen"] is False