Files
LEDMatrix/test/test_reconciliation_phantom_plugins.py
ChuckandClaude Opus 5.5 b09434a418 refactor(plugins): the display publishes plugin runtime state; retire plugin_state.json (#690)
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>
2026-09-30 10:48:14 -04:00

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 == []