mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Stage 2 of the web plugin catalog, after #688. - The display publishes a plugin runtime snapshot (plugin_runtime.py) to the shared cache: per plugin loaded, lifecycle state, a short redacted error summary, the version it loaded and when, plus published_at / stale_after / running. Written on change (throttled to 10 s; the RUNNING/ENABLED flip of an ordinary update is not a change) and once a minute otherwise; cleanup() publishes running: false. - The web reads it back and restores loaded / state / error_info in /api/v3/plugins/installed (plus loaded_version, loaded_at and data.runtime). Only a live snapshot counts; stale, stopped or missing answers null and says which. - data/plugin_state.json is retired: every reader and writer moved to config + disk (desired) or the snapshot (observed). Nothing in it was non-derivable, so nothing is migrated and an existing file is left unread. The web-side PluginStateManager (state_manager.py) is removed; the display's plugin_state.PluginStateManager is the only state machine. - StateReconciliation compares config + disk with the snapshot, reporting enabled-but-not-loaded and older-version-loaded as no_action findings. - Backups list installed manifests with enabled from config.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
7.6 KiB
Python
173 lines
7.6 KiB
Python
"""Tests for plugin-state reconciliation reporting phantom inconsistencies.
|
|
|
|
Observed on a device running four installed, configured, working plugins: the
|
|
web UI showed "Stale plugin config entries found: football-scoreboard,
|
|
odds-ticker, data, ledmatrix-weather, starlark-apps. Remove them from
|
|
config.json" for hours. Three separate defects combined to produce that:
|
|
|
|
1. load_config() merges config_secrets.json into the config it returns, and the
|
|
ignore list named only 'github' and 'youtube'. A 'data' key in the secrets
|
|
file therefore became a phantom plugin, reported as in-config-not-on-disk.
|
|
|
|
2. The auto-fix for "on disk but not in config" assigned
|
|
``config[plugin_id] = {'enabled': False}`` unconditionally. When detection was
|
|
wrong, that traded a plugin's real configuration (4.9KB of league settings in
|
|
the reported case) for a stub. It only avoided firing there because the write
|
|
failed with EACCES.
|
|
|
|
3. The verdict is a snapshot written once per run and served to the UI from a
|
|
status file, and a run that fails to apply a fix also declines to retry. So a
|
|
condition that had since resolved was reported indefinitely.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from src.plugin_system.state_reconciliation import (
|
|
InconsistencyType,
|
|
StateReconciliation,
|
|
still_unresolved,
|
|
)
|
|
|
|
|
|
class _ConfigManager:
|
|
"""Minimal config manager with the secrets path the real one exposes."""
|
|
|
|
def __init__(self, config, secrets_path=None):
|
|
self._config = config
|
|
self._secrets_path = secrets_path
|
|
self.saved = []
|
|
|
|
def load_config(self):
|
|
return dict(self._config)
|
|
|
|
def save_config(self, config):
|
|
self.saved.append(config)
|
|
self._config = dict(config)
|
|
|
|
def get_secrets_path(self):
|
|
return self._secrets_path
|
|
|
|
|
|
def _reconciler(config, plugins_dir, secrets_path=None):
|
|
return StateReconciliation(
|
|
config_manager=_ConfigManager(config, secrets_path),
|
|
plugins_dir=plugins_dir,
|
|
)
|
|
|
|
|
|
class TestSecretsKeysAreNotPlugins:
|
|
def test_secrets_key_is_not_treated_as_a_plugin(self, tmp_path):
|
|
secrets = tmp_path / "config_secrets.json"
|
|
# The shape actually found on the device: display state written here.
|
|
secrets.write_text(json.dumps({"data": {"mode": "nfl_recent"},
|
|
"timestamp": 1.0}), encoding="utf-8")
|
|
config = {
|
|
"data": {"mode": "nfl_recent"},
|
|
"football-scoreboard": {"enabled": True, "nfl": {"favorite_teams": ["TB"]}},
|
|
}
|
|
state = _reconciler(config, tmp_path, secrets).\
|
|
_get_config_state()
|
|
|
|
assert "data" not in state, "secrets key leaked in as a phantom plugin"
|
|
assert "football-scoreboard" in state
|
|
|
|
def test_missing_secrets_file_is_survivable(self, tmp_path):
|
|
state = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
|
|
tmp_path / "does-not-exist.json")._get_config_state()
|
|
assert "odds-ticker" in state
|
|
|
|
def test_non_path_secrets_location_is_survivable(self, tmp_path):
|
|
"""Regression: a non-path return raised TypeError out of the helper,
|
|
which the broad handler in _get_config_state() swallowed as "Error
|
|
reading config state" -- emptying the config state and making every
|
|
downstream detection wrong."""
|
|
r = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
|
|
secrets_path=Mock())
|
|
assert "odds-ticker" in r._get_config_state()
|
|
|
|
def test_config_manager_without_secrets_path_is_survivable(self, tmp_path):
|
|
r = StateReconciliation(config_manager=Mock(spec=["load_config", "save_config"]),
|
|
plugins_dir=tmp_path)
|
|
r.config_manager.load_config.return_value = {"odds-ticker": {"enabled": True}}
|
|
assert "odds-ticker" in r._get_config_state()
|
|
|
|
|
|
class TestFixNeverClobbersRealConfig:
|
|
def _missing_in_config(self, plugin_id):
|
|
inc = Mock()
|
|
inc.inconsistency_type = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
|
|
inc.plugin_id = plugin_id
|
|
return inc
|
|
|
|
def test_existing_entry_is_not_overwritten(self, tmp_path):
|
|
rich = {"enabled": True, "nfl": {"favorite_teams": ["TB"], "show_odds": True}}
|
|
r = _reconciler({"football-scoreboard": dict(rich)}, tmp_path)
|
|
|
|
assert r._fix_inconsistency(self._missing_in_config("football-scoreboard")) is True
|
|
# The regression: this wrote {'enabled': False} over the real settings.
|
|
assert r.config_manager.saved == []
|
|
assert r.config_manager.load_config()["football-scoreboard"] == rich
|
|
|
|
def test_genuinely_absent_plugin_is_still_added(self, tmp_path):
|
|
r = _reconciler({"other-plugin": {"enabled": True}}, tmp_path)
|
|
|
|
assert r._fix_inconsistency(self._missing_in_config("new-plugin")) is True
|
|
assert len(r.config_manager.saved) == 1
|
|
assert r.config_manager.load_config()["new-plugin"] == {"enabled": False}
|
|
# and it must not disturb what was already there
|
|
assert r.config_manager.load_config()["other-plugin"] == {"enabled": True}
|
|
|
|
|
|
class TestStaleFindingsAreDropped:
|
|
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG.value
|
|
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK.value
|
|
|
|
def test_resolved_missing_in_config_is_dropped(self):
|
|
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
|
|
assert still_unresolved(entries, {"football-scoreboard"}, set()) == []
|
|
|
|
def test_genuinely_missing_in_config_is_kept(self):
|
|
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
|
|
assert still_unresolved(entries, set(), set()) == entries
|
|
|
|
def test_resolved_missing_on_disk_is_dropped(self):
|
|
entries = [{"plugin_id": "odds-ticker", "type": self.ON_DISK}]
|
|
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
|
|
|
def test_genuinely_missing_on_disk_is_kept(self):
|
|
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
|
|
assert still_unresolved(entries, {"ghost-plugin"}, {"odds-ticker"}) == entries
|
|
|
|
def test_missing_on_disk_no_longer_in_config_is_dropped(self):
|
|
# Removed from config.json (or never a plugin entry at all, like a
|
|
# core key an older build misread): nothing left to reinstall.
|
|
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
|
|
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
|
|
|
def test_unrecheckable_kinds_are_kept(self):
|
|
# Filtering must only ever remove what it can prove stale.
|
|
entries = [{"plugin_id": "x", "type": "plugin_version_mismatch"}]
|
|
assert still_unresolved(entries, set(), set()) == entries
|
|
|
|
def test_the_reported_device_state_clears_every_false_finding(self):
|
|
"""The exact verdict the device served, against its real state."""
|
|
entries = [
|
|
{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG},
|
|
{"plugin_id": "odds-ticker", "type": self.IN_CONFIG},
|
|
{"plugin_id": "data", "type": self.ON_DISK},
|
|
{"plugin_id": "ledmatrix-weather", "type": self.IN_CONFIG},
|
|
{"plugin_id": "starlark-apps", "type": self.IN_CONFIG},
|
|
]
|
|
installed = {"football-scoreboard", "ledmatrix-weather", "odds-ticker",
|
|
"starlark-apps", "web-ui-info"}
|
|
live = still_unresolved(entries, installed, installed)
|
|
|
|
# All four "installed but not in config" findings were false and clear.
|
|
# 'data' is not a plugin entry in config (it came from the secrets
|
|
# file), so its "not on disk" finding is provably stale and clears too.
|
|
assert live == []
|