mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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>
177 lines
7.8 KiB
Python
177 lines
7.8 KiB
Python
"""The reconciliation-status endpoint must re-check its stored verdict.
|
|
|
|
#557 added _drop_stale_reconciliation_findings() so a resolved condition stops
|
|
being reported: the verdict is a snapshot written once per run, and a run that
|
|
fails to apply a fix also declines to retry, so a device whose plugins were all
|
|
present in config kept being told for hours that four of them were missing.
|
|
|
|
That wiring had no test. Only the pure still_unresolved() helper in
|
|
src/plugin_system/ was covered, which lives outside web_interface and therefore
|
|
survived the api_v3 blueprint split untouched -- so when #553 moved this
|
|
endpoint into api_v3/plugins.py, losing the filter would have been completely
|
|
silent. This test exists so that cannot happen again.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
|
|
|
IN_CONFIG = "plugin_missing_in_config"
|
|
ON_DISK = "plugin_missing_on_disk"
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
"""App whose status file, config and plugins dir are all under our control."""
|
|
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
plugins_dir.mkdir()
|
|
|
|
def _install(plugin_id):
|
|
d = plugins_dir / plugin_id
|
|
d.mkdir()
|
|
(d / "manifest.json").write_text(json.dumps({"id": plugin_id}), encoding="utf-8")
|
|
|
|
def _setup(verdict, config=None, installed=(), secrets=None, corrupt=()):
|
|
(tmp_path / "ledmatrix_reconciliation.json").write_text(
|
|
json.dumps(verdict), encoding="utf-8")
|
|
for pid in installed:
|
|
_install(pid)
|
|
for pid in corrupt:
|
|
d = plugins_dir / pid
|
|
d.mkdir(exist_ok=True)
|
|
(d / "manifest.json").write_text("{ this is not json", encoding="utf-8")
|
|
|
|
secrets_path = tmp_path / "config_secrets.json"
|
|
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
|
|
|
|
cm = MagicMock()
|
|
cm.load_config.return_value = dict(config or {})
|
|
cm.get_secrets_path.return_value = str(secrets_path)
|
|
pm = MagicMock()
|
|
pm.plugins_dir = str(plugins_dir)
|
|
# setattr via monkeypatch: these live on a module-level blueprint
|
|
# singleton, so assigning them directly leaks mocks -- pointing at a
|
|
# deleted tmp_path -- into every later test that imports api_v3.
|
|
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
|
|
monkeypatch.setattr(api_v3, "plugin_catalog", pm, raising=False)
|
|
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
|
return app.test_client()
|
|
|
|
return _setup
|
|
|
|
|
|
def _get(c):
|
|
return c.get("/api/v3/plugins/reconciliation-status").get_json()["data"]
|
|
|
|
|
|
class TestStaleFindingsAreDroppedByTheEndpoint:
|
|
def test_a_plugin_now_in_config_is_no_longer_reported(self, client):
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]},
|
|
config={"football-scoreboard": {"enabled": True}})
|
|
# The regression: this kept being reported for hours after it resolved.
|
|
assert _get(c)["unresolved"] == []
|
|
|
|
def test_a_plugin_now_installed_is_no_longer_reported(self, client):
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "odds-ticker", "type": ON_DISK}]},
|
|
installed=["odds-ticker"])
|
|
assert _get(c)["unresolved"] == []
|
|
|
|
def test_a_finding_that_still_holds_is_kept(self, client):
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "ghost-plugin", "type": ON_DISK}]},
|
|
config={"ghost-plugin": {"enabled": True}})
|
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
|
|
|
|
def test_a_plugin_removed_from_config_is_no_longer_reported(self, client):
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "ghost-plugin", "type": ON_DISK}]},
|
|
config={"other-plugin": {"enabled": True}})
|
|
assert _get(c)["unresolved"] == []
|
|
|
|
def test_the_reported_device_verdict_clears(self, client):
|
|
"""The five findings the live device served, against its real state."""
|
|
installed = ["football-scoreboard", "ledmatrix-weather",
|
|
"odds-ticker", "starlark-apps"]
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": p, "type": IN_CONFIG} for p in installed]},
|
|
config={p: {"enabled": True} for p in installed},
|
|
installed=installed)
|
|
assert _get(c)["unresolved"] == []
|
|
|
|
|
|
class TestTheEndpointStaysRobust:
|
|
def test_an_empty_verdict_is_passed_through(self, client):
|
|
c = client({"done": True, "unresolved": []})
|
|
assert _get(c) == {"done": True, "unresolved": []}
|
|
|
|
def test_a_broken_config_manager_leaves_findings_untouched(self, client):
|
|
"""Best-effort: a stale warning beats a failed endpoint."""
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]})
|
|
api_v3.config_manager.load_config.side_effect = OSError("config unreadable")
|
|
body = _get(c)
|
|
assert [e["plugin_id"] for e in body["unresolved"]] == ["football-scoreboard"]
|
|
|
|
def test_a_run_still_in_progress_is_reported_as_such(self, client):
|
|
c = client({"done": False, "unresolved": []})
|
|
assert _get(c)["done"] is False
|
|
|
|
|
|
class TestTheFilterUsesTheReconcilersOwnRules:
|
|
"""A looser definition of "in config" or "on disk" clears findings that are
|
|
still true. Both cases raised by CodeRabbit on #553."""
|
|
|
|
def test_a_secrets_key_does_not_clear_an_in_config_finding(self, client):
|
|
# load_config() merges the secrets file in, so 'data' appears in the
|
|
# config dict -- but it is not a plugin. A plain set(config) would treat
|
|
# it as one and clear this finding.
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "data", "type": IN_CONFIG}]},
|
|
config={"data": {"mode": "nfl_recent"}},
|
|
secrets={"data": {"mode": "nfl_recent"}})
|
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["data"]
|
|
|
|
def test_a_system_key_does_not_clear_an_in_config_finding(self, client):
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "display", "type": IN_CONFIG}]},
|
|
config={"display": {"hardware": {}}})
|
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["display"]
|
|
|
|
def test_a_non_dict_value_does_not_clear_an_in_config_finding(self, client):
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "timezone", "type": IN_CONFIG}]},
|
|
config={"timezone": "America/Chicago"})
|
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["timezone"]
|
|
|
|
def test_an_unparseable_manifest_does_not_count_as_installed(self, client):
|
|
# Otherwise a corrupt file clears a live "in config but not on disk"
|
|
# finding on the strength of something nothing can read.
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
|
|
config={"broken-plugin": {"enabled": True}},
|
|
corrupt=["broken-plugin"])
|
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
|
|
|
|
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
|
|
c = client({"done": True, "unresolved": [
|
|
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
|
|
config={"weather.standalone-backup-20260101": {"enabled": True}},
|
|
installed=["weather.standalone-backup-20260101"])
|
|
assert len(_get(c)["unresolved"]) == 1
|