Files
LEDMatrix/test/web_interface/test_plugin_config_form_defaults.py
T
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

197 lines
7.1 KiB
Python

"""The server-rendered plugin form must show schema defaults for unsaved keys.
A plugin update that adds a boolean option with ``"default": true`` (geochron
1.2.0's ``show_date`` / ``show_date_line``) leaves every existing install with
a saved config that lacks the key. The partial rendered it from the raw config,
so the box came up unchecked -- and the save route treats a drawn but unposted
checkbox as false, so the first save turned the option off for good.
These drive the real partial route (pages_v3) and post what a browser would
submit from its HTML back through the real save route (api_v3).
"""
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},
"show_date": {"type": "boolean", "default": True},
"show_date_line": {"type": "boolean", "default": True},
"show_seconds": {"type": "boolean", "default": False},
"style": {"type": "string", "enum": ["flat", "globe", "night"],
"default": "globe"},
"brightness": {"type": "integer", "default": 70,
"minimum": 0, "maximum": 100},
"label": {"type": "string", "default": "UTC"},
# An object with its own default: extract_schema_defaults stops here,
# so the route merge leaves the children missing.
"overlay": {"type": "object", "default": {}, "properties": {
"show_sun": {"type": "boolean", "default": True},
"mode": {"type": "string", "enum": ["dot", "ring"],
"default": "ring"},
}, "additionalProperties": False},
# An object without one: the route merge fills the children in.
"grid": {"type": "object", "properties": {
"show_lines": {"type": "boolean", "default": True},
}, "additionalProperties": False},
"api_key": {"type": "string", "x-secret": True,
"default": "not-a-real-secret"},
},
"required": ["enabled"],
"additionalProperties": False,
}
# Saved before any of the options above existed.
STORED = {"enabled": True, "show_seconds": True}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
self._select = None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "select":
self._select = a.get("name")
return
if tag == "option" and self._select and "selected" in a:
self.pairs.append((self._select, a.get("value", "")))
return
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 handle_endtag(self, tag):
if tag == "select":
self._select = None
@pytest.fixture
def app_client(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as api
from web_interface.blueprints import pages_v3 as pages
plugins_dir = tmp_path / "plugin-repos"
pdir = plugins_dir / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
(pdir / "manifest.json").write_text(
json.dumps({"id": "demo", "name": "Demo", "version": "1.0.0"}),
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
pm = MagicMock()
pm.plugins_dir = plugins_dir
pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
names = ("config_manager", "schema_manager", "plugin_catalog")
originals = {(bp, k): getattr(bp, k, None)
for bp in (api.api_v3, pages.pages_v3) for k in names}
for bp in (api.api_v3, pages.pages_v3):
bp.config_manager = cm
bp.schema_manager = sm
bp.plugin_catalog = pm
base = Path(pages.__file__).resolve().parent.parent
app = Flask(__name__, template_folder=str(base / "templates"),
static_folder=str(base / "static"))
app.config["TESTING"] = True
app.register_blueprint(pages.pages_v3, url_prefix="/v3")
app.register_blueprint(api.api_v3, url_prefix="/api/v3")
try:
yield app.test_client(), store
finally:
for (bp, k), v in originals.items():
setattr(bp, k, v)
def _render(client):
resp = client.get("/v3/partials/plugin-config/demo")
assert resp.status_code == 200
return resp.get_data(as_text=True)
def _fields(html):
parser = _FormFields()
parser.feed(html)
return dict(parser.pairs)
def test_missing_booleans_render_their_schema_default(app_client):
client, _ = app_client
fields = _fields(_render(client))
# Checked, so a browser posts them.
assert fields.get("show_date") == "true"
assert fields.get("show_date_line") == "true"
assert fields.get("grid.show_lines") == "true"
assert fields.get("overlay.show_sun") == "true"
# A saved value still wins over the default.
assert fields.get("show_seconds") == "true"
def test_missing_non_boolean_fields_render_their_schema_default(app_client):
client, _ = app_client
fields = _fields(_render(client))
assert fields.get("style") == "globe" # not the first option
assert fields.get("overlay.mode") == "ring"
assert fields.get("brightness") == "70"
assert fields.get("label") == "UTC"
def test_a_secret_default_is_still_masked(app_client):
client, _ = app_client
assert "not-a-real-secret" not in _render(client)
def test_saving_the_rendered_form_keeps_default_true_booleans_on(app_client):
client, store = app_client
pairs = list(_fields(_render(client)).items())
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
assert resp.status_code == 200, resp.get_json()
saved = store["demo"]
assert saved["show_date"] is True
assert saved["show_date_line"] is True
assert saved["grid"]["show_lines"] is True
assert saved["overlay"]["show_sun"] is True
assert saved["overlay"]["mode"] == "ring"
assert saved["style"] == "globe"
assert saved["show_seconds"] is True