Files
LEDMatrix/test/web_interface/test_plugin_config_legacy_values.py
ChuckandClaude Opus 5.5 7ab6fb1aff refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.

- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
  directories, display modes, installed version, schema and config reads,
  with no way to run a plugin. app.py and both blueprints use it; the
  plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
  reach the display through ConfigService (on_config_change) and the
  enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
  resource_monitor. /plugins/installed reports loaded/state/error_info as
  null (the display does not publish them) and enabled by the display's
  rule.
- Store install, update and uninstall answer restart_required when the
  running display will not pick the change up by itself
  (display_restart_required). The restart banner follows the flag via
  window.noteRestartRequired instead of the /config/main URL heuristic;
  /config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
  modules, oauth_flow action scripts) goes through
  _import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
  user's setting or the manifest, with vegas_participation_source; when
  only the plugin's code decides it, null with source 'runtime', since the
  web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
  fails, and asks for a restart when an enabled plugin's first request got
  no answer and the re-sent one found it up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:39:44 -04:00

179 lines
6.4 KiB
Python

"""Stored values the current schema no longer describes must not break the form.
Two cases from the news plugin:
* ``global.dynamic_duration`` used to be a boolean and is now an object. An
install that has not saved since still holds ``true``; rendering the nested
section did ``key in true`` and the whole config page failed to load.
* A custom feed logo stored with a ``path`` but no ``id``. The template always
emitted an empty ``logo.id`` input, which the save route parsed to ``None``
and the schema rejected (``Expected type string``) -- 400 on every save.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {"type": "object", "properties": {
"dynamic_duration": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30,
"minimum": 10, "maximum": 300},
},
"additionalProperties": False,
},
}, "additionalProperties": False},
"feeds": {"type": "object", "properties": {
"custom_feeds": {
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"logo": {"type": "object", "required": ["path"],
"properties": {"id": {"type": "string"},
"path": {"type": "string"}}},
},
"required": ["name", "url"],
"additionalProperties": False,
},
},
}, "additionalProperties": False},
},
"required": ["enabled"],
"additionalProperties": False,
}
LOGO_PATH = "assets/plugins/news/uploads/verge.png"
STORED = {
"enabled": True,
"global": {"dynamic_duration": True},
"feeds": {"custom_feeds": [{"name": "Verge",
"url": "https://www.theverge.com/rss/index.xml",
"enabled": True,
"logo": {"path": LOGO_PATH}}]},
}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag != "input" or not a.get("name") or "disabled" in a:
return
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((a["name"], a.get("value", "")))
def _render():
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
a.api_v3.plugin_catalog = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
def test_a_scalar_stored_under_an_object_field_still_renders():
html = _render()
assert 'name="global.dynamic_duration.min_duration_seconds"' in html
def test_a_logo_without_an_id_posts_no_id_field():
parser = _FormFields()
parser.feed(_render())
names = [name for name, _ in parser.pairs]
assert "feeds.custom_feeds.0.logo.path" in names
assert "feeds.custom_feeds.0.logo.id" not in names
def test_saving_that_form_succeeds_and_upgrades_the_legacy_value(post):
parser = _FormFields()
parser.feed(_render())
resp, cfg = post(parser.pairs)
assert resp.status_code == 200, resp.get_json()
assert isinstance(cfg["global"]["dynamic_duration"], dict)
# The legacy `true` was the on switch; upgrading must not turn it off.
assert cfg["global"]["dynamic_duration"]["enabled"] is True
assert cfg["global"]["dynamic_duration"]["min_duration_seconds"] == 30
assert cfg["feeds"]["custom_feeds"][0]["logo"] == {"path": LOGO_PATH}