mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +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>
389 lines
19 KiB
Python
389 lines
19 KiB
Python
"""The registry fields added after 3.7.0: ``ledmatrix_min_version``,
|
|
``aliases`` and ``commit``.
|
|
|
|
- ``ledmatrix_min_version`` lets the store refuse an install or update this
|
|
core cannot run *before* downloading anything. The gate on the downloaded
|
|
manifest stays as the fallback.
|
|
- ``aliases`` (or, for a registry without it, the ``plugin_path`` name and the
|
|
``ledmatrix-`` prefix) lets update, uninstall and reinstall find a plugin
|
|
installed under its manifest id: registry ``weather`` lives in
|
|
``ledmatrix-weather/``. Before this, update by the registry id said "not
|
|
installed", uninstall by it reported success and deleted nothing, and a
|
|
reinstall backed up ``weather/`` -- which did not exist -- then deleted
|
|
``ledmatrix-weather/`` to make room, so a post-download refusal left the user
|
|
with no plugin at all.
|
|
- ``commit`` is shown in the store; nothing installs from it.
|
|
|
|
Every field is optional: an older plugins.json must behave exactly as before.
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.plugin_system import compatibility
|
|
from src.plugin_system.store_manager import PluginStoreManager
|
|
from src.plugin_system.store_registry import alternate_ids, declared_aliases
|
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
|
|
|
REPO = "https://github.com/ChuckBuilds/ledmatrix-plugins"
|
|
CORE = "3.7.0"
|
|
|
|
# Shaped like the real registry before and after the monorepo change.
|
|
OLD_WEATHER = {"id": "weather", "name": "Weather", "repo": REPO, "branch": "main",
|
|
"plugin_path": "plugins/ledmatrix-weather", "latest_version": "2.0.0"}
|
|
NEW_WEATHER = {**OLD_WEATHER, "ledmatrix_min_version": "3.5.0",
|
|
"aliases": ["ledmatrix-weather"],
|
|
"commit": "843588025a81197056f8d96779ccb2be19337ab8"}
|
|
|
|
|
|
def manifest(version: str, floor: str = "3.0.0", plugin_id: str = "ledmatrix-weather") -> Dict[str, Any]:
|
|
return {"id": plugin_id, "name": "Weather", "class_name": "Weather",
|
|
"display_modes": ["weather"], "version": version,
|
|
"versions": [{"version": version, "ledmatrix_min_version": floor}]}
|
|
|
|
|
|
def write_install(plugins_dir: Path, name: str, content: Dict[str, Any], marker: str) -> Path:
|
|
path = plugins_dir / name
|
|
path.mkdir(parents=True)
|
|
(path / "manifest.json").write_text(json.dumps(content), encoding="utf-8")
|
|
(path / "marker.txt").write_text(marker, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def markers(plugins_dir: Path) -> Dict[str, Optional[str]]:
|
|
out = {}
|
|
for d in sorted(plugins_dir.iterdir()):
|
|
m = d / "marker.txt"
|
|
out[d.name] = m.read_text(encoding="utf-8") if m.exists() else None
|
|
return out
|
|
|
|
|
|
class FakeStore:
|
|
"""A PluginStoreManager with the registry and the download faked."""
|
|
|
|
def __init__(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
entries: List[Dict[str, Any]], downloaded: Dict[str, Any]):
|
|
self.plugins_dir = tmp_path / "plugin-repos"
|
|
self.plugins_dir.mkdir()
|
|
self.store = PluginStoreManager(plugins_dir=str(self.plugins_dir))
|
|
self.store.logger = MagicMock()
|
|
self.downloads: List[Path] = []
|
|
# Tests may replace registry["plugins"]; the fakes read it per call.
|
|
self.registry: Dict[str, Any] = {"plugins": entries}
|
|
store = self.store
|
|
|
|
def fetch_registry(*_a, **_k):
|
|
store.registry_cache = self.registry
|
|
return self.registry
|
|
|
|
def get_plugin_info(pid, **_k):
|
|
entry = store._match_registry_entry(fetch_registry()["plugins"], pid)
|
|
return dict(entry) if entry else None
|
|
|
|
monkeypatch.setattr(store, "fetch_registry", fetch_registry)
|
|
monkeypatch.setattr(store, "get_plugin_info", get_plugin_info)
|
|
monkeypatch.setattr(store, "_install_dependencies", lambda _p: True)
|
|
|
|
def fake_download(_url, _subpath, target: Path) -> bool:
|
|
self.downloads.append(target)
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
(target / "manifest.json").write_text(json.dumps(downloaded), encoding="utf-8")
|
|
(target / "marker.txt").write_text("new", encoding="utf-8")
|
|
return True
|
|
|
|
monkeypatch.setattr(store, "_install_from_monorepo", fake_download)
|
|
monkeypatch.setattr(compatibility, "current_core_version", lambda: CORE)
|
|
|
|
|
|
@pytest.fixture
|
|
def make(tmp_path, monkeypatch):
|
|
def _make(entries, downloaded=None):
|
|
return FakeStore(tmp_path, monkeypatch, entries, downloaded or manifest("2.0.0"))
|
|
return _make
|
|
|
|
|
|
class TestAliasHelpers:
|
|
def test_declared_aliases_distinguishes_absent_from_empty(self):
|
|
assert declared_aliases(OLD_WEATHER) is None
|
|
assert declared_aliases({**OLD_WEATHER, "aliases": []}) == []
|
|
assert declared_aliases(NEW_WEATHER) == ["ledmatrix-weather"]
|
|
|
|
def test_declared_aliases_drops_junk_and_the_own_id(self):
|
|
assert declared_aliases({"id": "a", "aliases": ["a", "", 3, None, "b"]}) == ["b"]
|
|
assert declared_aliases({"id": "a", "aliases": "b"}) is None
|
|
|
|
def test_only_registry_proof_counts(self):
|
|
"""aliases and the plugin_path name; never a bare ledmatrix-<id>."""
|
|
assert alternate_ids(OLD_WEATHER) == ["ledmatrix-weather"]
|
|
assert alternate_ids(NEW_WEATHER) == ["ledmatrix-weather"]
|
|
assert alternate_ids({"id": "clock", "plugin_path": "plugins/clock-simple"}) == ["clock-simple"]
|
|
assert alternate_ids({"id": "ext", "plugin_path": ""}) == []
|
|
assert alternate_ids({"id": "foo", "plugin_path": "plugins/foo", "aliases": []}) == []
|
|
assert alternate_ids({"id": "m", "plugin_path": "plugins/m", "aliases": ["x"]}) == ["x"]
|
|
|
|
|
|
class TestRegistryLookup:
|
|
def test_alias_resolves_an_entry_whose_path_does_not_say_so(self):
|
|
entry = {"id": "music", "plugin_path": "plugins/music", "aliases": ["ledmatrix-music"]}
|
|
assert PluginStoreManager._match_registry_entry([entry], "ledmatrix-music") is entry
|
|
|
|
def test_exact_id_still_beats_an_alias(self):
|
|
decoy = {"id": "decoy", "aliases": ["weather"]}
|
|
real = {"id": "weather"}
|
|
assert PluginStoreManager._match_registry_entry([decoy, real], "weather") is real
|
|
|
|
|
|
class TestFindInstalledPlugin:
|
|
def test_registry_id_finds_the_manifest_id_install(self, make):
|
|
fake = make([NEW_WEATHER])
|
|
path = write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
fake.store.fetch_registry()
|
|
assert fake.store._find_plugin_path("weather") == path
|
|
|
|
def test_old_registry_still_finds_it(self, make):
|
|
fake = make([OLD_WEATHER])
|
|
path = write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
fake.store.fetch_registry()
|
|
assert fake.store._find_plugin_path("weather") == path
|
|
|
|
def test_no_registry_loaded_is_no_proof(self, make):
|
|
"""Offline, a ledmatrix-weather/ folder is only named in the log."""
|
|
fake = make([])
|
|
path = write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
assert fake.store.registry_cache is None
|
|
assert fake.store._find_plugin_path("weather") is None
|
|
hint = " ".join(str(a) for c in fake.store.logger.warning.call_args_list for a in c.args)
|
|
assert str(path) in hint and "ledmatrix-weather" in hint
|
|
|
|
def test_a_folder_whose_manifest_declares_the_id_needs_no_proof(self, make):
|
|
fake = make([])
|
|
path = write_install(fake.plugins_dir, "ledmatrix-weather",
|
|
manifest("1.0.0", plugin_id="weather"), "old")
|
|
assert fake.store._find_plugin_path("weather") == path
|
|
|
|
def test_an_empty_aliases_list_means_no_guessing(self, make):
|
|
fake = make([{"id": "foo", "repo": REPO, "plugin_path": "plugins/foo", "aliases": []}])
|
|
write_install(fake.plugins_dir, "ledmatrix-foo", manifest("1.0.0", plugin_id="ledmatrix-foo"), "x")
|
|
fake.store.fetch_registry()
|
|
assert fake.store._find_plugin_path("foo") is None
|
|
|
|
def test_the_exact_install_wins_over_an_alias(self, make):
|
|
fake = make([NEW_WEATHER])
|
|
exact = write_install(fake.plugins_dir, "weather", manifest("1.0.0", plugin_id="weather"), "a")
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "b")
|
|
fake.store.fetch_registry()
|
|
assert fake.store._find_plugin_path("weather") == exact
|
|
|
|
def test_uninstall_by_registry_id_removes_the_install(self, make):
|
|
fake = make([NEW_WEATHER])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
fake.store.fetch_registry()
|
|
assert fake.store.uninstall_plugin("weather") is True
|
|
assert markers(fake.plugins_dir) == {}
|
|
|
|
|
|
# An unrelated plugin that happens to live in ledmatrix-foo/ (its manifest id
|
|
# is ledmatrix-foo) and that no registry entry names. Owner decision on #686:
|
|
# without registry proof the store must never replace or remove it.
|
|
FOO = {"id": "foo", "name": "Foo", "repo": REPO, "branch": "main", "plugin_path": "plugins/foo",
|
|
"latest_version": "2.0.0"}
|
|
|
|
|
|
class TestUnprovenPrefixFolderIsLeftAlone:
|
|
@pytest.fixture(params=["old registry", "empty aliases", "no registry"])
|
|
def fake(self, request, make):
|
|
entries = {"old registry": [FOO], "empty aliases": [{**FOO, "aliases": []}],
|
|
"no registry": []}[request.param]
|
|
fake = make(entries, downloaded=manifest("2.0.0", plugin_id="foo"))
|
|
write_install(fake.plugins_dir, "ledmatrix-foo",
|
|
manifest("1.0.0", plugin_id="ledmatrix-foo"), "unrelated")
|
|
return fake
|
|
|
|
def test_uninstall_foo_leaves_it(self, fake):
|
|
if fake.registry["plugins"]:
|
|
fake.store.fetch_registry()
|
|
assert fake.store.uninstall_plugin("foo") is True # "already uninstalled"
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-foo": "unrelated"}
|
|
|
|
def test_install_foo_does_not_replace_it(self, fake):
|
|
if fake.registry["plugins"]:
|
|
fake.store.fetch_registry()
|
|
else:
|
|
# Nothing loaded when install_plugin looks for a copy to protect;
|
|
# the entry to install from is fetched afterwards.
|
|
fake.registry["plugins"] = [FOO]
|
|
assert fake.store.install_plugin("foo") is True
|
|
assert markers(fake.plugins_dir) == {"foo": "new", "ledmatrix-foo": "unrelated"}
|
|
|
|
def test_update_foo_finds_nothing_to_update(self, fake):
|
|
if fake.registry["plugins"]:
|
|
fake.store.fetch_registry()
|
|
assert fake.store.update_plugin("foo") is False
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-foo": "unrelated"}
|
|
assert fake.downloads == []
|
|
|
|
|
|
class TestInstall:
|
|
def test_registry_floor_refuses_before_downloading(self, make):
|
|
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
assert fake.store.install_plugin("weather") is False
|
|
assert fake.downloads == [], "nothing may be downloaded for a refused install"
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
|
|
reason = fake.store.pop_refusal("weather")
|
|
assert reason and "requires LEDMatrix 9.0.0 or newer" in reason and CORE in reason
|
|
assert fake.store.pop_refusal("weather") is None, "a refusal is reported once"
|
|
|
|
def test_a_compatible_floor_installs(self, make):
|
|
fake = make([NEW_WEATHER])
|
|
assert fake.store.install_plugin("weather") is True
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
|
|
|
|
def test_old_registry_downloads_and_the_manifest_gate_still_refuses(self, make):
|
|
"""No field means no early answer: the post-download gate decides,
|
|
and the copy it would have replaced -- found under its alias -- is put
|
|
back. This used to delete ledmatrix-weather/ outright."""
|
|
fake = make([OLD_WEATHER], downloaded=manifest("2.0.0", floor="9.0.0"))
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
assert fake.store.install_plugin("weather") is False
|
|
assert len(fake.downloads) == 1
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
|
|
assert "9.0.0" in (fake.store.pop_refusal("weather") or "")
|
|
|
|
def test_old_registry_reinstall_over_an_alias_succeeds(self, make):
|
|
fake = make([OLD_WEATHER])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
assert fake.store.install_plugin("weather") is True
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
|
|
|
|
def test_another_branch_skips_the_registry_floor(self, make):
|
|
"""The floor describes the registry's branch; a named other branch is
|
|
another release, left to the post-download gate."""
|
|
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
|
|
assert fake.store.install_plugin("weather", branch="dev") is True
|
|
assert len(fake.downloads) == 1
|
|
|
|
@pytest.mark.parametrize("floor", [None, "", 3, ["9.0.0"]])
|
|
def test_a_missing_or_malformed_floor_says_nothing(self, make, floor):
|
|
entry = {**NEW_WEATHER, "ledmatrix_min_version": floor}
|
|
assert make([entry]).store.registry_incompatibility("weather", entry) is None
|
|
|
|
def test_an_untrustworthy_core_version_is_not_refused(self, make, monkeypatch):
|
|
"""Same leniency as the manifest gate: a core reporting 1.0.0 (the
|
|
v3.1.0 release) is unknown, not old -- unless the floor is above 2.0.0,
|
|
which that release cannot meet either."""
|
|
entry = {**NEW_WEATHER, "ledmatrix_min_version": "2.0.0"}
|
|
store = make([entry]).store
|
|
monkeypatch.setattr(compatibility, "current_core_version", lambda: "1.0.0")
|
|
assert store.registry_incompatibility("weather", entry) is None
|
|
|
|
|
|
class TestUpdate:
|
|
def test_update_by_registry_id_finds_and_updates_the_install(self, make):
|
|
fake = make([NEW_WEATHER])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
assert fake.store.update_plugin("weather") is True
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
|
|
|
|
def test_update_by_manifest_id_still_works(self, make):
|
|
fake = make([NEW_WEATHER])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
assert fake.store.update_plugin("ledmatrix-weather") is True
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
|
|
|
|
def test_incompatible_update_is_refused_before_anything_moves(self, make, monkeypatch):
|
|
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
rollback = MagicMock()
|
|
monkeypatch.setattr(fake.store, "_reinstall_with_rollback", rollback)
|
|
assert fake.store.update_plugin("ledmatrix-weather") is False
|
|
rollback.assert_not_called()
|
|
assert fake.downloads == []
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
|
|
assert "9.0.0" in (fake.store.pop_refusal("ledmatrix-weather") or "")
|
|
|
|
def test_up_to_date_plugin_is_not_refused(self, make):
|
|
"""The floor belongs to latest_version; a plugin already on it is
|
|
running, so there is nothing to refuse."""
|
|
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("2.0.0"), "current")
|
|
assert fake.store.update_plugin("weather") is True
|
|
assert fake.store.pop_refusal("weather") is None
|
|
|
|
def test_old_registry_update_falls_back_to_the_manifest_gate(self, make):
|
|
fake = make([OLD_WEATHER], downloaded=manifest("2.0.0", floor="9.0.0"))
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
assert fake.store.update_plugin("weather") is False
|
|
assert len(fake.downloads) == 1
|
|
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
|
|
|
|
def test_git_checkout_is_not_pulled_when_the_registry_refuses(self, make, monkeypatch):
|
|
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
|
|
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
monkeypatch.setattr(fake.store, "_get_local_git_info", lambda _p: {
|
|
"sha": "a" * 40, "branch": "main", "remote_url": REPO})
|
|
calls = []
|
|
monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(a) or MagicMock(
|
|
returncode=0, stdout="", stderr=""))
|
|
assert fake.store.update_plugin("weather") is False
|
|
assert not any("pull" in (a[0] if a else []) for a in calls), calls
|
|
assert "9.0.0" in (fake.store.pop_refusal("weather") or "")
|
|
|
|
|
|
# -- web routes --------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def web_store(api_v3_module, tmp_path, monkeypatch):
|
|
fake = FakeStore(tmp_path, monkeypatch, [], manifest("2.0.0"))
|
|
api_v3_module.api_v3.plugin_store_manager = fake.store
|
|
api_v3_module.api_v3.operation_queue = None
|
|
# No plugin catalog: the routes skip discovery, and the update route
|
|
# finds the directory through the store alone.
|
|
api_v3_module.api_v3.plugin_catalog = None
|
|
return fake
|
|
|
|
|
|
def test_store_list_carries_the_new_fields(api_v3_client, web_store, monkeypatch):
|
|
listing = [
|
|
dict(NEW_WEATHER),
|
|
{**NEW_WEATHER, "id": "later", "aliases": [], "ledmatrix_min_version": "9.0.0"},
|
|
{"id": "old", "name": "Old", "repo": REPO, "plugin_path": "plugins/old"},
|
|
]
|
|
monkeypatch.setattr(web_store.store, "search_plugins", lambda **_k: listing)
|
|
resp = api_v3_client.get("/api/v3/plugins/store/list")
|
|
assert resp.status_code == 200
|
|
by_id = {p["id"]: p for p in resp.get_json()["data"]["plugins"]}
|
|
assert by_id["weather"]["commit"] == NEW_WEATHER["commit"]
|
|
assert by_id["weather"]["ledmatrix_min_version"] == "3.5.0"
|
|
assert by_id["weather"]["aliases"] == ["ledmatrix-weather"]
|
|
assert by_id["weather"]["incompatible_reason"] is None
|
|
assert "9.0.0" in by_id["later"]["incompatible_reason"]
|
|
# An older registry: every new key present, and empty.
|
|
assert (by_id["old"]["commit"], by_id["old"]["ledmatrix_min_version"],
|
|
by_id["old"]["aliases"], by_id["old"]["incompatible_reason"]) == (None, None, [], None)
|
|
|
|
|
|
def test_install_route_says_why_it_refused(api_v3_client, web_store):
|
|
web_store.registry["plugins"] = [{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}]
|
|
resp = api_v3_client.post("/api/v3/plugins/install", data=json.dumps({"plugin_id": "weather"}),
|
|
content_type="application/json")
|
|
assert resp.status_code == 409
|
|
message = resp.get_json()["message"]
|
|
assert "requires LEDMatrix 9.0.0 or newer" in message
|
|
assert web_store.downloads == []
|
|
|
|
|
|
def test_update_route_says_why_it_refused(api_v3_client, web_store):
|
|
web_store.registry["plugins"] = [{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}]
|
|
write_install(web_store.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
|
|
resp = api_v3_client.post("/api/v3/plugins/update", data=json.dumps({"plugin_id": "weather"}),
|
|
content_type="application/json")
|
|
assert resp.status_code == 409
|
|
assert "requires LEDMatrix 9.0.0 or newer" in resp.get_json()["message"]
|
|
assert markers(web_store.plugins_dir) == {"ledmatrix-weather": "old"}
|