Files
FabledSteward/tests/core/test_plugin_hot_load.py
T
bvandeusenandClaude Opus 5.5 a59d1a7709
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m28s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 1m29s
fix(plugins): a plugin that can't start live says why and offers a restart (#6087)
Saving an enabled plugin threw away hot_reload_plugin's (ok, message), so a
plugin that failed to load live read "Enabled, not loaded" with nothing
more. Now:
- a live-load failure is recorded with its reason, as a boot failure is, and
  logged; a later success clears it;
- any save of an enabled plugin that isn't running tries to start it, not
  only the off-to-on toggle;
- enabling one already running is a success, not a failure;
- the plugin's page offers "Restart to load" while it is enabled and not
  running.

Quart never marks the app as having served its first request, so
register_blueprint after startup is allowed: hot-loading is not refused
wholesale, as #6087 suspected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:56:54 -04:00

65 lines
2.4 KiB
Python

"""A plugin enabled while Steward runs is loaded live; one that can't be is
recorded with its reason, so its settings page says why and offers a restart
instead of a bare "Enabled, not loaded" (#6087)."""
from __future__ import annotations
import sys
from types import SimpleNamespace
import pytest
from steward.core import plugin_manager as pm
@pytest.fixture(autouse=True)
def clean_registries(monkeypatch):
monkeypatch.setattr(pm, "_LOADED_PLUGINS", set())
monkeypatch.setattr(pm, "_FAILED_PLUGINS", {})
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
monkeypatch.setattr(pm, "_PLUGIN_MODULES", {})
yield
for key in [k for k in sys.modules if k.startswith("_steward_plugin_hl_")]:
sys.modules.pop(key)
def _app(root):
return SimpleNamespace(config={"PLUGIN_DIRS": [str(root)], "PLUGINS": {}},
_task_registry=[], mcp_server=None)
def _plugin(root, name, *, yaml_name=None, setup_body="pass"):
pdir = root / name
pdir.mkdir(parents=True)
(pdir / "plugin.yaml").write_text(f"name: {yaml_name or name}\nversion: 1.0.0\n")
(pdir / "__init__.py").write_text(
f"def setup(app):\n {setup_body}\n\n"
"def get_scheduled_tasks():\n return []\n")
def test_a_plugin_that_cannot_load_live_is_recorded_with_its_reason(tmp_path):
_plugin(tmp_path, "hl_broken", setup_body="raise RuntimeError('no controller')")
ok, message = pm.hot_reload_plugin(_app(tmp_path), "hl_broken")
assert not ok
assert pm.get_plugin_failures() == {"hl_broken": "setup() raised: no controller"}
assert "hl_broken" not in pm._LOADED_PLUGINS
def test_an_early_refusal_is_recorded_too(tmp_path):
_plugin(tmp_path, "hl_misnamed", yaml_name="other")
ok, _ = pm.hot_reload_plugin(_app(tmp_path), "hl_misnamed")
assert not ok and "name mismatch" in pm.get_plugin_failures()["hl_misnamed"]
def test_a_plugin_that_loads_clears_an_earlier_failure(tmp_path):
_plugin(tmp_path, "hl_good")
pm._FAILED_PLUGINS["hl_good"] = "setup() raised: earlier"
ok, _ = pm.hot_reload_plugin(_app(tmp_path), "hl_good")
assert ok
assert "hl_good" in pm._LOADED_PLUGINS and pm.get_plugin_failures() == {}
def test_enabling_one_already_running_is_a_success_not_a_failure(tmp_path):
pm._LOADED_PLUGINS.add("hl_running")
ok, _ = pm.hot_reload_plugin(_app(tmp_path), "hl_running")
assert ok and pm.get_plugin_failures() == {}