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>
This commit is contained in:
Chuck
2026-09-30 10:48:14 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7ab6fb1aff
commit b09434a418
38 changed files with 1893 additions and 940 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ from flask import Flask
# whatever a previously-run test left on the singleton.
API_V3_MANAGER_ATTRS = (
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
'health_tracker', 'resource_monitor',
)
@@ -50,7 +50,6 @@ def side_effects(module):
return {
"schema_invalidated": api.schema_manager.invalidate_cache.call_args_list,
"discovered": api.plugin_catalog.discover_plugins.call_count,
"state_set": api.plugin_state_manager.set_plugin_installed.call_args_list,
"history": api.operation_history.record_operation.call_args_list,
}
@@ -87,7 +86,6 @@ class TestInstallDirectPath:
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1
assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success"
def test_branch_forwarded_to_the_manager(self, api_v3_client, api_v3_module):
@@ -134,7 +132,6 @@ class TestInstallDirectPath:
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == []
assert effects["discovered"] == 0
assert effects["state_set"] == []
class TestInstallQueuedPath:
@@ -157,7 +154,6 @@ class TestInstallQueuedPath:
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1
assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success"
def test_callback_reports_success(self, api_v3_client, api_v3_module, queued):
@@ -199,7 +195,6 @@ class TestInstallPathsAgree:
# Reset and re-run through the queue.
for mock in (api_v3_module.api_v3.schema_manager,
api_v3_module.api_v3.plugin_catalog,
api_v3_module.api_v3.plugin_state_manager,
api_v3_module.api_v3.operation_history):
mock.reset_mock()
queue = MagicMock()
@@ -210,7 +205,6 @@ class TestInstallPathsAgree:
assert direct["schema_invalidated"] == queued["schema_invalidated"]
assert direct["discovered"] == queued["discovered"]
assert direct["state_set"] == queued["state_set"]
assert (direct["history"][0].kwargs["status"]
== queued["history"][0].kwargs["status"])
assert (direct["history"][0].kwargs["details"]
+77 -6
View File
@@ -72,7 +72,8 @@ def _make_project(root: Path) -> Path:
encoding="utf-8",
)
# plugin_state.json
# A plugin_state.json left behind by an older release. Retired: the
# listing must ignore it (see test_list_installed_plugins).
(root / "data").mkdir()
(root / "data" / "plugin_state.json").write_text(
json.dumps(
@@ -130,12 +131,82 @@ def test_bundled_fonts_matches_repo() -> None:
def test_list_installed_plugins(project: Path) -> None:
"""Installed = a manifest on disk; enabled = config.json. The retired
plugin_state.json is not read: its "other-plugin" is not installed and
not configured, so a restore must not install it."""
plugins = list_installed_plugins(project)
ids = [p["plugin_id"] for p in plugins]
assert "my-plugin" in ids
assert "other-plugin" in ids
my = next(p for p in plugins if p["plugin_id"] == "my-plugin")
assert my["version"] == "1.2.3"
assert plugins == [{"plugin_id": "my-plugin", "version": "1.2.3", "enabled": True}]
def test_list_installed_plugins_reads_enabled_from_config(project: Path) -> None:
"""The display's rule: only "enabled": true is enabled; a plugin with no
config section, or no flag, is disabled."""
for pid in ("quiet-plugin", "unconfigured-plugin"):
d = project / "plugin-repos" / pid
d.mkdir()
(d / "manifest.json").write_text(json.dumps({"id": pid, "version": "2.0.0"}),
encoding="utf-8")
config_path = project / "config" / "config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["quiet-plugin"] = {"favorites": []}
config_path.write_text(json.dumps(config), encoding="utf-8")
by_id = {p["plugin_id"]: p for p in list_installed_plugins(project)}
assert by_id["my-plugin"]["enabled"] is True
assert by_id["quiet-plugin"]["enabled"] is False
assert by_id["unconfigured-plugin"]["enabled"] is False
assert by_id["quiet-plugin"]["version"] == "2.0.0"
def test_list_installed_plugins_without_a_state_file(project: Path) -> None:
"""Nothing depends on plugin_state.json being there."""
(project / "data" / "plugin_state.json").unlink()
assert [p["plugin_id"] for p in list_installed_plugins(project)] == ["my-plugin"]
def test_backup_restore_round_trip_ignores_the_retired_state_file(
project: Path, empty_project: Path, tmp_path: Path) -> None:
"""A backup made on a device that still has plugin_state.json restores
the installed plugins and their enabled state (from config.json), and
carries no state file of its own."""
zip_path = create_backup(project, output_dir=tmp_path / "exports")
with zipfile.ZipFile(zip_path) as zf:
names = set(zf.namelist())
listed = json.loads(zf.read("plugins.json"))
assert not any("plugin_state" in n for n in names)
assert listed == [{"plugin_id": "my-plugin", "version": "1.2.3", "enabled": True}]
result = restore_backup(zip_path, empty_project, RestoreOptions())
assert result.success, result.errors
assert result.plugins_to_install == [{"plugin_id": "my-plugin", "version": "1.2.3"}]
restored = json.loads((empty_project / "config" / "config.json").read_text())
assert restored["my-plugin"]["enabled"] is True
assert not (empty_project / "data" / "plugin_state.json").exists()
def test_restore_of_a_backup_listing_a_state_file_only_plugin(
project: Path, empty_project: Path, tmp_path: Path) -> None:
"""A backup written by an older release could list a plugin known only
to plugin_state.json. Restore reads plugins.json as written, so such a
backup still restores everything it lists."""
zip_path = tmp_path / "old.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("manifest.json", json.dumps({
"schema_version": 1, "created_at": "2026-01-01T00:00:00Z",
"ledmatrix_version": "3.6.0", "hostname": "old",
"contents": ["config", "plugins"]}))
zf.writestr("config/config.json", json.dumps({"my-plugin": {"enabled": True}}))
zf.writestr("plugins.json", json.dumps([
{"plugin_id": "my-plugin", "version": "1.2.3", "enabled": True},
{"plugin_id": "other-plugin", "version": "0.1.0", "enabled": False},
]))
result = restore_backup(zip_path, empty_project, RestoreOptions())
assert result.success, result.errors
assert {p["plugin_id"] for p in result.plugins_to_install} == {"my-plugin", "other-plugin"}
def test_preview_backup_contents(project: Path) -> None:
-6
View File
@@ -97,14 +97,8 @@ def _reconcile(tmp_path, config, installed=(), secrets=None):
_install(plugins_dir, pid)
secrets_path = tmp_path / "config_secrets.json"
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
state_manager = Mock()
state_manager.get_all_states.return_value = {}
plugin_manager = Mock()
plugin_manager.plugin_manifests = {}
reconciler = StateReconciliation(
state_manager=state_manager,
config_manager=_ConfigManager(config, str(secrets_path)),
plugin_manager=plugin_manager,
plugins_dir=plugins_dir,
)
return reconciler, reconciler.reconcile_state()
+442
View File
@@ -0,0 +1,442 @@
"""The display publishes its plugin runtime state; the web reads it back.
Only the display runs plugins, so only it knows which ones are loaded, where
each is in its lifecycle, why one failed and which version it runs. It
publishes that as one snapshot in the shared cache (plugin_runtime), and the
web interface fills /api/v3/plugins/installed's ``loaded`` / ``state`` /
``error_info`` from it -- but only while the snapshot is live. A stale,
stopped or missing snapshot is reported as such, with those fields null.
The cross-process tests use two CacheManagers over one temporary directory,
the arrangement of the real services (which share /var/cache/ledmatrix).
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.cache_manager import CacheManager # noqa: E402
from src.plugin_system import plugin_runtime as rt # noqa: E402
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.plugin_runtime import ( # noqa: E402
PLUGIN_RUNTIME_KEY, PluginRuntimePublisher, read_plugin_runtime,
view_from_snapshot,
)
from src.plugin_system.plugin_state import PluginState, PluginStateManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
class FakeClock:
def __init__(self, now=1000.0):
self.now = now
def __call__(self):
return self.now
@pytest.fixture
def shared_cache(tmp_path, monkeypatch):
"""Two cache managers over one directory: the display's and the web's."""
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(tmp_path))
display_cache, web_cache = CacheManager(), CacheManager()
yield display_cache, web_cache
display_cache.stop_cleanup_thread()
web_cache.stop_cleanup_thread()
def _publisher(cache, states, wall=None, mono=None):
return PluginRuntimePublisher(cache, states, clock=mono or FakeClock(),
wall_clock=wall or FakeClock(1_800_000_000.0))
# --- The state machine: what counts as a change -----------------------------
class TestChangeCount:
def test_an_ordinary_update_is_not_a_change(self):
"""ENABLED -> RUNNING -> ENABLED is every update() call. Publishing it
would be an SD-card write per plugin update."""
states = PluginStateManager()
states.set_state("clock", PluginState.ENABLED)
before = states.change_count
for _ in range(50):
states.set_state("clock", PluginState.RUNNING)
states.set_state("clock", PluginState.ENABLED)
assert states.change_count == before
assert states.runtime_records()["clock"]["state"] == "enabled"
def test_running_is_published_as_enabled(self):
states = PluginStateManager()
states.set_state("clock", PluginState.RUNNING)
assert states.runtime_records()["clock"]["state"] == "enabled"
@pytest.mark.parametrize("change", [
lambda s: s.set_state("clock", PluginState.ERROR, error=ValueError("x")),
lambda s: s.set_state_with_error("clock", PluginState.ENABLED, {"error": "x"}),
lambda s: s.record_loaded("clock", "1.0.0"),
lambda s: s.set_state("clock", PluginState.DISABLED),
lambda s: s.clear_state("clock"),
lambda s: s.set_state("weather", PluginState.LOADED),
])
def test_reader_visible_changes_move_it(self, change):
states = PluginStateManager()
states.set_state("clock", PluginState.ENABLED)
before = states.change_count
change(states)
assert states.change_count > before
def test_recovering_from_an_error_is_a_change(self):
states = PluginStateManager()
states.set_state_with_error("clock", PluginState.ENABLED, {"error": "timeout"})
before = states.change_count
states.set_state("clock", PluginState.ENABLED) # next update succeeded
assert states.change_count > before
assert states.runtime_records()["clock"]["error_info"] is None
def test_clear_state_forgets_the_loaded_record(self):
states = PluginStateManager()
states.set_state("clock", PluginState.ENABLED)
states.record_loaded("clock", "1.0.0", loaded_at=5.0)
assert states.runtime_records()["clock"]["loaded"] is True
states.clear_state("clock")
assert states.runtime_records() == {}
class TestPluginManagerRecordsWhatItLoaded:
@pytest.fixture
def pm(self, tmp_path):
plugins_dir = tmp_path / "plugins"
(plugins_dir / "demo").mkdir(parents=True)
manager = PluginManager(plugins_dir=str(plugins_dir))
manager.plugin_manifests["demo"] = {"id": "demo", "name": "Demo", "version": "2.1.0"}
manager.schema_manager = MagicMock()
manager.schema_manager.get_schema_path.return_value = None
manager.plugin_loader = MagicMock()
manager.plugin_loader.find_plugin_directory.return_value = plugins_dir / "demo"
manager.plugin_loader.load_plugin.return_value = (MagicMock(spec=["on_enable"]), None)
return manager
def test_load_records_the_manifest_version(self, pm):
assert pm.load_plugin("demo") is True
record = pm.state_manager.runtime_records()["demo"]
assert record["loaded"] is True
assert record["state"] == "enabled"
assert record["version"] == "2.1.0"
assert isinstance(record["loaded_at"], float)
def test_unload_forgets_it(self, pm):
pm.load_plugin("demo")
pm.unload_plugin("demo")
assert "demo" not in pm.state_manager.runtime_records()
def test_a_failed_load_is_an_error_and_not_loaded(self, pm):
pm.plugin_loader.load_plugin.side_effect = ImportError("No module named 'requests'")
assert pm.load_plugin("demo") is False
record = pm.state_manager.runtime_records()["demo"]
assert record["loaded"] is False
assert record["state"] == "error"
assert "requests" in record["error_info"]["error"]
# --- Publishing -------------------------------------------------------------
class TestPublisher:
def test_first_tick_publishes(self):
cache = MagicMock()
states = PluginStateManager()
states.set_state("clock", PluginState.ENABLED)
states.record_loaded("clock", "1.0.0", loaded_at=10.0)
assert _publisher(cache, states).tick() is True
key, snapshot = cache.set.call_args.args
assert key == PLUGIN_RUNTIME_KEY
assert snapshot["running"] is True
assert snapshot["published_at"] == 1_800_000_000.0
assert snapshot["stale_after"] == rt.STALE_AFTER
assert snapshot["plugins"] == {"clock": {
"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": 10.0}}
def test_changes_are_throttled_and_quiet_displays_refresh(self):
cache = MagicMock()
states = PluginStateManager()
mono = FakeClock()
publisher = _publisher(cache, states, mono=mono)
publisher.tick()
assert cache.set.call_count == 1
# Nothing changed: no write until the refresh is due.
mono.now += rt.REFRESH_INTERVAL - 1
assert publisher.tick() is False
mono.now += 1
assert publisher.tick() is True
assert cache.set.call_count == 2
# A burst of changes is written at most once per MIN_INTERVAL.
states.set_state("a", PluginState.LOADED)
mono.now += 1
assert publisher.tick() is False
mono.now += rt.MIN_INTERVAL
assert publisher.tick() is True
assert cache.set.call_count == 3
def test_updates_alone_cause_no_writes(self):
"""A display running plugins but changing nothing writes once a
minute, however many update() calls it makes."""
cache = MagicMock()
states = PluginStateManager()
states.set_state("clock", PluginState.ENABLED)
mono = FakeClock()
publisher = _publisher(cache, states, mono=mono)
publisher.tick()
for _ in range(11): # 55 s of 5 s ticks
states.set_state("clock", PluginState.RUNNING)
states.set_state("clock", PluginState.ENABLED)
mono.now += rt.TICK_INTERVAL
publisher.tick()
assert cache.set.call_count == 1
def test_errors_are_redacted_and_short(self):
cache = MagicMock()
states = PluginStateManager()
secret = "https://api.example.com/v1?api_key=SUPERSECRET123&q=" + "x" * 500
states.set_state("weather", PluginState.ERROR, error=ConnectionError(secret))
_publisher(cache, states).tick()
error = cache.set.call_args.args[1]["plugins"]["weather"]["error"]
assert "SUPERSECRET123" not in json.dumps(error)
assert len(error["message"]) <= 200
assert error["type"] == "ConnectionError"
assert isinstance(error["at"], float)
assert error["recoverable"] is False
def test_stop_publishes_stopped(self):
cache = MagicMock()
states = PluginStateManager()
states.record_loaded("clock", "1.0.0")
publisher = _publisher(cache, states)
publisher.tick()
publisher.stop()
snapshot = cache.set.call_args.args[1]
assert snapshot["running"] is False
assert snapshot["plugins"] == {}
def test_a_failing_cache_never_raises(self):
cache = MagicMock()
cache.set.side_effect = OSError("read-only file system")
publisher = _publisher(cache, PluginStateManager())
assert publisher.tick() is False
publisher.stop() # also swallowed
def test_the_thread_starts_and_stops(self):
cache = MagicMock()
publisher = rt.start_plugin_runtime_publisher(cache, PluginStateManager())
try:
assert publisher is not None
finally:
publisher.stop()
assert cache.set.call_args.args[1]["running"] is False
# --- Reading: live, stale, stopped, unknown ---------------------------------
def _snapshot(published_at, running=True, plugins=None, **extra):
base = {"schema": rt.SNAPSHOT_SCHEMA, "running": running,
"published_at": published_at, "stale_after": 180.0,
"plugins": plugins or {}}
base.update(extra)
return base
class TestReader:
NOW = 1_800_000_000.0
CLOCK = {"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": 5.0}
def test_live(self):
view = view_from_snapshot(_snapshot(self.NOW - 30, plugins={"clock": self.CLOCK}),
now=self.NOW)
assert view.status == "live"
assert view.plugin("clock") == {"loaded": True, "state": "enabled",
"error_info": None, "loaded_version": "1.0.0",
"loaded_at": 5.0}
# Listed nowhere in a live snapshot: not loaded.
assert view.plugin("weather")["loaded"] is False
assert view.plugin("weather")["state"] == "unloaded"
assert view.describe()["age_seconds"] == 30.0
def test_stale_reports_nothing(self):
view = view_from_snapshot(_snapshot(self.NOW - 181, plugins={"clock": self.CLOCK}),
now=self.NOW)
assert view.status == "stale"
assert view.plugin("clock") == {"loaded": None, "state": None, "error_info": None,
"loaded_version": None, "loaded_at": None}
assert view.describe()["status"] == "stale"
def test_stopped_reports_nothing(self):
view = view_from_snapshot(_snapshot(self.NOW - 1, running=False), now=self.NOW)
assert view.status == "stopped"
assert view.plugin("clock")["loaded"] is None
@pytest.mark.parametrize("snapshot", [
None, "junk", [], {}, {"schema": 99, "running": True, "published_at": NOW},
_snapshot(None), _snapshot("yesterday"), _snapshot(float("nan")),
])
def test_unknown(self, snapshot):
view = view_from_snapshot(snapshot, now=self.NOW)
assert view.status == "unknown"
assert view.plugin("clock")["state"] is None
def test_a_little_in_the_future_is_live_far_is_stale(self):
"""The Pi has no RTC: its clock steps at NTP sync."""
assert view_from_snapshot(_snapshot(self.NOW + 60), now=self.NOW).status == "live"
assert view_from_snapshot(_snapshot(self.NOW + 3600), now=self.NOW).status == "stale"
def test_a_corrupt_stale_after_is_bounded(self):
forever = _snapshot(self.NOW - 7200, stale_after=1e12)
assert view_from_snapshot(forever, now=self.NOW).status == "stale"
eager = _snapshot(self.NOW - 20, stale_after=0)
assert view_from_snapshot(eager, now=self.NOW).status == "live"
def test_no_cache_manager_or_a_failing_one_is_unknown(self):
assert read_plugin_runtime(None).status == "unknown"
cache = MagicMock()
cache.get.side_effect = OSError("gone")
assert read_plugin_runtime(cache).status == "unknown"
# --- Across processes, through the real cache and the real routes -----------
class TestAcrossProcesses:
def test_what_the_display_publishes_the_web_reads(self, shared_cache):
display_cache, web_cache = shared_cache
states = PluginStateManager()
states.set_state("clock", PluginState.ENABLED)
states.record_loaded("clock", "1.0.0")
publisher = PluginRuntimePublisher(display_cache, states)
assert read_plugin_runtime(web_cache).status == "unknown"
publisher.tick()
view = read_plugin_runtime(web_cache)
assert view.status == "live"
assert view.plugin("clock")["loaded"] is True
# memory_ttl=0 on the reading side: a later write is seen at once.
publisher.stop()
assert read_plugin_runtime(web_cache).status == "stopped"
def test_a_snapshot_left_by_a_dead_display_goes_stale(self, shared_cache):
display_cache, web_cache = shared_cache
states = PluginStateManager()
states.record_loaded("clock", "1.0.0")
old = FakeClock(1_000_000.0) # long ago
PluginRuntimePublisher(display_cache, states, wall_clock=old).tick()
assert read_plugin_runtime(web_cache).status == "stale"
@pytest.fixture
def web_listing(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa: F811
"""GET /api/v3/plugins/installed over one real plugin and the shared cache."""
display_cache, web_cache = shared_cache
api = api_v3_module.api_v3
api.cache_manager = web_cache
api.plugin_catalog.plugins_dir = str(tmp_path / "plugins")
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[
{"id": "clock", "name": "Clock", "version": "1.1.0"},
{"id": "weather", "name": "Weather", "version": "3.0.0"},
])
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={
"clock": {"enabled": True}, "weather": {"enabled": True}})
def get():
response = api_v3_client.get("/api/v3/plugins/installed")
assert response.status_code == 200, response.get_data(as_text=True)
data = response.get_json()["data"]
return {p["id"]: p for p in data["plugins"]}, data["runtime"]
return display_cache, get
class TestInstalledPluginsRoute:
def _display(self, display_cache):
states = PluginStateManager()
states.set_state("clock", PluginState.ENABLED)
states.record_loaded("clock", "1.0.0", loaded_at=123.0)
states.set_state("weather", PluginState.ERROR,
error=RuntimeError("token=abc123 rejected"))
return PluginRuntimePublisher(display_cache, states)
def test_restores_loaded_state_and_error_info(self, web_listing):
display_cache, get = web_listing
self._display(display_cache).tick()
plugins, runtime = get()
assert runtime["status"] == "live"
clock, weather = plugins["clock"], plugins["weather"]
assert (clock["loaded"], clock["state"], clock["error_info"]) == (True, "enabled", None)
# The display runs 1.0.0; 1.1.0 is on disk (an update awaiting restart).
assert clock["version"] == "1.1.0" and clock["loaded_version"] == "1.0.0"
assert clock["loaded_at"] == 123.0
assert weather["loaded"] is False and weather["state"] == "error"
assert weather["error_info"]["type"] == "RuntimeError"
assert "abc123" not in weather["error_info"]["message"]
def test_unknown_before_the_display_has_published(self, web_listing):
_, get = web_listing
plugins, runtime = get()
assert runtime["status"] == "unknown"
assert all(p["loaded"] is None and p["state"] is None and p["error_info"] is None
for p in plugins.values())
# enabled still comes from config.json.
assert plugins["clock"]["enabled"] is True
def test_stopped_display_is_not_reported_as_running(self, web_listing):
display_cache, get = web_listing
publisher = self._display(display_cache)
publisher.tick()
publisher.stop()
plugins, runtime = get()
assert runtime["status"] == "stopped"
assert plugins["clock"]["loaded"] is None
def test_stale_snapshot_is_not_reported_as_truth(self, web_listing):
display_cache, get = web_listing
states = PluginStateManager()
states.record_loaded("clock", "1.0.0")
PluginRuntimePublisher(display_cache, states,
wall_clock=FakeClock(1_000_000.0)).tick()
plugins, runtime = get()
assert runtime["status"] == "stale"
assert runtime["age_seconds"] > rt.STALE_AFTER
assert plugins["clock"]["loaded"] is None
class TestDisplayControllerStopsThePublisher:
def test_cleanup_publishes_stopped(self, monkeypatch):
# display_manager binds the hardware rgbmatrix module unless
# EMULATOR=true is set before import (test_initial_update_budget.py).
monkeypatch.setenv("EMULATOR", "true")
from src.display_controller import DisplayController
controller = DisplayController.__new__(DisplayController)
publisher = MagicMock()
controller._plugin_runtime_publisher = publisher
controller.plugin_manager = None
controller.vegas_coordinator = None
controller.sync_manager = None
controller.display_manager = MagicMock()
controller.cleanup()
publisher.stop.assert_called_once_with()
+7 -39
View File
@@ -1,48 +1,16 @@
"""plugin_state.json and the operation history file are replaced atomically.
"""The operation history file is replaced atomically.
Both were written with a plain ``open(path, 'w')`` + ``json.dump`` outside
their lock. The open truncates first, so a value json can't encode (or a
crash, or a second Flask thread saving at the same moment) left a partial
file, and the next load dropped every saved state. They now serialise first
and go through a temp file + rename while holding the lock.
It was written with a plain ``open(path, 'w')`` + ``json.dump`` outside its
lock. The open truncates first, so a value json can't encode (or a crash, or
a second Flask thread saving at the same moment) left a partial file, and the
next load dropped every saved record. It now serialises first and goes
through a temp file + rename while holding the lock. (plugin_state.json had
the same fix; it is retired now -- nothing writes it.)
"""
import json
import threading
from src.plugin_system.operation_history import OperationHistory
from src.plugin_system.state_manager import PluginStateManager
def test_state_file_survives_a_failed_save(tmp_path):
state_file = tmp_path / "plugin_state.json"
mgr = PluginStateManager(state_file=str(state_file))
mgr.set_plugin_enabled("clock", True)
before = json.loads(state_file.read_text())
# Not JSON-serialisable: the save fails (and is logged, not raised).
mgr.update_plugin_state("clock", {"metadata": {"bad": object()}})
assert json.loads(state_file.read_text()) == before
assert [p.name for p in tmp_path.iterdir()] == ["plugin_state.json"]
def test_state_file_is_valid_after_concurrent_saves(tmp_path):
state_file = tmp_path / "plugin_state.json"
mgr = PluginStateManager(state_file=str(state_file))
def worker(n):
for i in range(15):
mgr.set_plugin_enabled(f"plugin-{n}", i % 2 == 0)
threads = [threading.Thread(target=worker, args=(n,)) for n in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
data = json.loads(state_file.read_text())
assert set(data["states"]) == {f"plugin-{n}" for n in range(6)}
def test_history_file_survives_a_failed_save(tmp_path):
+3 -16
View File
@@ -1,8 +1,8 @@
"""Plugin-system JSON files are read as UTF-8, whatever the locale says.
store_manager (install_from_url's manifest, the secrets file) and
state_manager (plugin_state.json) opened text files without an encoding, so
the platform default applied. A manifest written in UTF-8 with a non-ASCII
store_manager (install_from_url's manifest, the secrets file) opened text
files without an encoding, so the platform default applied. (So did the
retired plugin_state.json reader.) A manifest written in UTF-8 with a non-ASCII
name then read as mojibake -- or raised UnicodeDecodeError -- on a host
whose locale encoding is not UTF-8 (Windows' cp1252; a Pi with LANG=C).
On a UTF-8 host these pass either way; they fail on old code where the
@@ -13,7 +13,6 @@ import json
import pytest
from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.store_manager import PluginStoreManager
NAME = "Météo Á" # "Á" is C3 81 in UTF-8; 0x81 is undefined in cp1252
@@ -48,15 +47,3 @@ def test_install_from_url_reads_a_utf8_manifest(store):
written = json.loads(
(store.plugins_dir / "meteo" / "manifest.json").read_bytes().decode("utf-8"))
assert written["name"] == NAME
def test_state_manager_loads_a_utf8_state_file(tmp_path):
state_file = tmp_path / "plugin_state.json"
state_file.write_bytes(json.dumps({
"version": 1,
"states": {"meteo": {"plugin_id": "meteo", "status": "installed",
"enabled": True, "metadata": {"label": NAME}}},
}, ensure_ascii=False).encode("utf-8"))
mgr = PluginStateManager(state_file=str(state_file))
assert mgr.get_plugin_state("meteo").metadata["label"] == NAME
+2 -5
View File
@@ -54,9 +54,7 @@ class _ConfigManager:
def _reconciler(config, plugins_dir, secrets_path=None):
return StateReconciliation(
state_manager=Mock(),
config_manager=_ConfigManager(config, secrets_path),
plugin_manager=Mock(),
plugins_dir=plugins_dir,
)
@@ -92,9 +90,8 @@ class TestSecretsKeysAreNotPlugins:
assert "odds-ticker" in r._get_config_state()
def test_config_manager_without_secrets_path_is_survivable(self, tmp_path):
r = StateReconciliation(state_manager=Mock(),
config_manager=Mock(spec=["load_config", "save_config"]),
plugin_manager=Mock(), plugins_dir=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()
@@ -33,7 +33,7 @@ from test._api_v3_test_helpers import mock_plugin_catalog
_API_V3_MOCKED_ATTRS = (
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
'health_tracker', 'resource_monitor',
)
@@ -64,8 +64,6 @@ def _make_client():
api_v3.plugin_catalog = mock_plugin_catalog()
api_v3.plugin_catalog.plugins_dir = "/tmp"
api_v3.plugin_store_manager = MagicMock()
api_v3.plugin_state_manager = MagicMock()
api_v3.plugin_state_manager.get_all_states.return_value = {}
api_v3.saved_repositories_manager = MagicMock()
api_v3.schema_manager = MagicMock()
api_v3.operation_queue = None # force the direct (non-queue) path
-5
View File
@@ -68,7 +68,6 @@ def client(mock_config_manager, mock_plugin_catalog):
api_v3.saved_repositories_manager = MagicMock()
api_v3.schema_manager = MagicMock()
api_v3.operation_queue = MagicMock()
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
api_v3.cache_manager = MagicMock()
# Readers of what the display publishes (app.py wires real ones).
@@ -88,9 +87,6 @@ def client(mock_config_manager, mock_plugin_catalog):
'properties': {'enabled': {'type': 'boolean'}}
}
# Setup state manager mocks
api_v3.plugin_state_manager.get_all_states.return_value = {}
test_app.register_blueprint(api_v3, url_prefix='/api/v3')
with test_app.test_client() as client:
@@ -664,7 +660,6 @@ class TestPluginsAPI:
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
api_v3.plugin_catalog = mock_plugin_catalog
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
# Setup plugin manifests
-1
View File
@@ -87,7 +87,6 @@ class TestUpdateRoute:
api.plugin_store_manager.update_plugin = MagicMock(return_value=True)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.schema_manager = None
api.plugin_state_manager = None
api.operation_history = None
return client.post("/api/v3/plugins/update", json={"plugin_id": "demo"})
@@ -9,7 +9,6 @@ from pathlib import Path
from src.plugin_system.operation_queue import PluginOperationQueue
from src.plugin_system.operation_types import OperationType
from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.operation_history import OperationHistory
@@ -22,12 +21,10 @@ class TestPluginOperationsIntegration(unittest.TestCase):
# Initialize components
self.operation_queue = PluginOperationQueue(max_history=100)
self.state_manager = PluginStateManager(
state_file=str(self.temp_dir / "state.json"),
auto_save=True
)
# No state manager: installed / enabled / version are read from disk
# and config.json, not from a plugin_state.json record.
self.operation_history = OperationHistory(
history_file=str(self.temp_dir / "history.json"),
max_records=100
@@ -64,22 +61,15 @@ class TestPluginOperationsIntegration(unittest.TestCase):
operation_id=operation_id
)
self.assertIsNotNone(history_id)
# Update state manager
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
# Verify state
state = self.state_manager.get_plugin_state(plugin_id)
self.assertIsNotNone(state)
self.assertEqual(state.version, "1.0.0")
# Verify history
history = self.operation_history.get_history(plugin_id=plugin_id)
self.assertEqual([r.operation_type for r in history], ["install"])
def test_update_operation_flow(self):
"""Test complete update operation flow."""
plugin_id = "test-plugin"
# First, mark as installed
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
# Enqueue update operation
operation_id = self.operation_queue.enqueue_operation(
OperationType.UPDATE,
@@ -97,20 +87,14 @@ class TestPluginOperationsIntegration(unittest.TestCase):
operation_id=operation_id
)
# Update state
self.state_manager.update_plugin_state(plugin_id, {"version": "2.0.0"})
# Verify state
state = self.state_manager.get_plugin_state(plugin_id)
self.assertEqual(state.version, "2.0.0")
# Verify history
history = self.operation_history.get_history(plugin_id=plugin_id)
self.assertEqual([r.operation_type for r in history], ["update"])
def test_uninstall_operation_flow(self):
"""Test complete uninstall operation flow."""
plugin_id = "test-plugin"
# First, mark as installed
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
# Enqueue uninstall operation
operation_id = self.operation_queue.enqueue_operation(
OperationType.UNINSTALL,
@@ -127,13 +111,10 @@ class TestPluginOperationsIntegration(unittest.TestCase):
operation_id=operation_id
)
# Update state - remove plugin state
self.state_manager.remove_plugin_state(plugin_id)
# Verify state
state = self.state_manager.get_plugin_state(plugin_id)
self.assertIsNone(state)
# Verify history
history = self.operation_history.get_history(plugin_id=plugin_id)
self.assertEqual([r.operation_type for r in history], ["uninstall"])
def test_operation_history_tracking(self):
"""Test that operations are tracked in history."""
plugin_id = "test-plugin"
@@ -30,7 +30,7 @@ from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
_MANAGER_ATTRS = (
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
_SENTINEL = object()
@@ -33,7 +33,7 @@ URL = "/api/v3/backup/restore"
_MANAGER_ATTRS = (
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
_SENTINEL = object()
+1 -1
View File
@@ -44,7 +44,7 @@ def env(tmp_path):
_SENTINEL = object()
attrs = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager',
'saved_repositories_manager',
'schema_manager', 'operation_queue', 'operation_history',
'cache_manager')
originals = {name: getattr(api_v3, name, _SENTINEL) for name in attrs}
@@ -86,7 +86,6 @@ def env(tmp_path):
api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock()
api_v3.operation_queue = MagicMock()
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
api_v3.cache_manager = MagicMock()
@@ -41,7 +41,7 @@ EXPECTED = {
MANAGERS = ("config_manager", "plugin_catalog", "plugin_store_manager",
"saved_repositories_manager", "schema_manager", "operation_queue",
"plugin_state_manager", "operation_history", "cache_manager")
"operation_history", "cache_manager")
class Boom:
@@ -73,7 +73,7 @@ STORED = {
}
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager')
@@ -0,0 +1,166 @@
"""data/plugin_state.json is retired; /plugins/state and reconciliation read
config + disk (desired) and the display's runtime snapshot (observed).
The file held, per plugin, an enabled flag (a copy of config.json's), a
version (a copy of the manifest's, when anything set it at all), a status
derived from those two, and install/update timestamps that only
GET /api/v3/plugins/state ever returned -- the operation history records the
same events. Nothing in it was needed that cannot be derived, so it is not
migrated: nothing reads or writes it any more, and an existing file is left
in place, unread. These tests hold that line and check the replacements.
"""
import ast
import json
import time
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.operation_history import OperationRecord
from src.plugin_system.plugin_runtime import SNAPSHOT_SCHEMA, view_from_snapshot
from src.plugin_system.state_reconciliation import StateReconciliation
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
REPO = Path(__file__).resolve().parents[2]
def _code_strings(path):
"""String constants in a module, docstrings excluded."""
tree = ast.parse(path.read_text(encoding="utf-8"))
docstrings = {id(node.value) for node in ast.walk(tree)
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)}
return [node.value for node in ast.walk(tree)
if isinstance(node, ast.Constant) and isinstance(node.value, str)
and id(node) not in docstrings]
def test_no_code_reads_or_writes_the_state_file():
offenders = []
for root in ("src", "web_interface", "scripts"):
for path in (REPO / root).rglob("*.py"):
if any("plugin_state.json" in s for s in _code_strings(path)):
offenders.append(str(path.relative_to(REPO)))
assert offenders == []
def test_the_web_side_state_manager_is_gone():
import importlib.util
assert importlib.util.find_spec("src.plugin_system.state_manager") is None
from web_interface.blueprints.api_v3 import api_v3
import web_interface.app as app_module
assert not hasattr(app_module, "plugin_state_manager")
assert getattr(api_v3, "plugin_state_manager", None) is None
def _install(plugins_dir, plugin_id, version):
d = plugins_dir / plugin_id
d.mkdir(parents=True)
(d / "manifest.json").write_text(json.dumps({"id": plugin_id, "version": version}),
encoding="utf-8")
def _live(plugins):
now = time.time()
return view_from_snapshot({"schema": SNAPSHOT_SCHEMA, "running": True,
"published_at": now, "plugins": plugins}, now=now)
def test_an_existing_state_file_changes_nothing(tmp_path):
"""A device upgraded with a plugin_state.json that disagrees with config
and disk: reconciliation answers exactly as on a device without one."""
plugins_dir = tmp_path / "plugin-repos"
_install(plugins_dir, "clock", "1.0.0")
config = MagicMock()
config.load_config.return_value = {"clock": {"enabled": True}}
config.get_secrets_path.return_value = str(tmp_path / "none.json")
def run():
r = StateReconciliation(config_manager=config, plugins_dir=plugins_dir)
result = r.reconcile_state()
return ([i.inconsistency_type for i in result.inconsistencies_found],
r.plugin_states())
without = run()
(tmp_path / "data").mkdir()
(tmp_path / "data" / "plugin_state.json").write_text(json.dumps({
"version": 1, "states": {
"clock": {"plugin_id": "clock", "status": "disabled", "enabled": False,
"version": "0.0.1"},
"gone": {"plugin_id": "gone", "status": "installed", "enabled": True}}}),
encoding="utf-8")
assert run() == without
assert without[1]["clock"]["enabled"] is True
assert "gone" not in without[1]
@pytest.fixture
def state_api(api_v3_module, api_v3_client, tmp_path, monkeypatch): # noqa: F811
api = api_v3_module.api_v3
plugins_dir = tmp_path / "plugin-repos"
_install(plugins_dir, "clock", "1.1.0")
_install(plugins_dir, "weather", "2.0.0")
api.plugin_catalog.plugins_dir = str(plugins_dir)
api.config_manager.load_config = MagicMock(return_value={
"clock": {"enabled": True}, "weather": {"enabled": False},
"ghost": {"enabled": True}})
api.config_manager.get_secrets_path = MagicMock(return_value=str(tmp_path / "s.json"))
api.operation_history.get_history = MagicMock(return_value=[
OperationRecord("3", "update", "clock", datetime(2026, 9, 3), "success"),
OperationRecord("2", "update", "clock", datetime(2026, 9, 2), "failed"),
OperationRecord("1", "install", "clock", datetime(2026, 9, 1), "success"),
])
observed = {"view": _live({"clock": {"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": 50.0}})}
import web_interface.blueprints.api_v3 as pkg
monkeypatch.setattr(pkg, "_plugin_runtime_view", lambda: observed["view"])
return api_v3_client, observed
class TestPluginStateRoute:
def test_every_plugin_desired_and_observed(self, state_api):
client, _ = state_api
body = client.get("/api/v3/plugins/state").get_json()
assert body["status"] == "success"
assert body["runtime"]["status"] == "live"
data = body["data"]
assert set(data) == {"clock", "weather", "ghost"}
clock = data["clock"]
assert clock["status"] == "enabled"
assert (clock["installed"], clock["enabled"], clock["version"]) == (True, True, "1.1.0")
assert (clock["loaded"], clock["loaded_version"]) == (True, "1.0.0")
assert clock["installed_at"] == "2026-09-01T00:00:00"
assert clock["last_updated"] == "2026-09-03T00:00:00"
assert data["weather"]["status"] == "disabled"
assert data["weather"]["loaded"] is False
assert data["weather"]["installed_at"] is None
assert data["ghost"]["status"] == "unknown" # configured, not installed
def test_one_plugin(self, state_api):
client, _ = state_api
body = client.get("/api/v3/plugins/state?plugin_id=clock").get_json()
assert body["data"]["plugin_id"] == "clock"
response = client.get("/api/v3/plugins/state?plugin_id=nope")
assert response.status_code == 404
def test_display_not_running_leaves_observed_fields_null(self, state_api):
client, observed = state_api
observed["view"] = view_from_snapshot({"schema": SNAPSHOT_SCHEMA, "running": False,
"published_at": time.time()})
body = client.get("/api/v3/plugins/state").get_json()
assert body["runtime"]["status"] == "stopped"
assert body["data"]["clock"]["loaded"] is None
assert body["data"]["clock"]["enabled"] is True
def test_reconcile_reports_what_the_display_runs(self, state_api):
client, _ = state_api
body = client.post("/api/v3/plugins/state/reconcile", json={}).get_json()
found = {(i["plugin_id"], i["type"], i["fix_action"])
for i in body["data"]["inconsistencies"]}
assert ("clock", "plugin_version_mismatch", "no_action") in found
assert ("ghost", "plugin_missing_on_disk", "manual_fix_required") in found
# Reported only; nothing the user must fix beyond the missing plugin.
assert [i["plugin_id"] for i in body["data"]["manual_fix_required"]] == ["ghost"]
+231 -226
View File
@@ -1,338 +1,352 @@
"""
Tests for state reconciliation system.
Desired state is config.json plus the plugins on disk; observed state is
the runtime snapshot the display publishes (plugin_runtime). There is no
third, persisted record: data/plugin_state.json is retired.
"""
import unittest
import tempfile
import shutil
import json
import time
from pathlib import Path
from unittest.mock import Mock, patch
from src.plugin_system.plugin_runtime import (
PluginRuntimeView, SNAPSHOT_SCHEMA, view_from_snapshot,
)
from src.plugin_system.state_reconciliation import (
StateReconciliation,
InconsistencyType,
FixAction,
ReconciliationResult
)
from src.plugin_system.state_manager import PluginStateManager, PluginStateStatus
def live_view(plugins):
"""A live runtime view, as read from a fresh snapshot of a running display."""
now = time.time()
return view_from_snapshot({
"schema": SNAPSHOT_SCHEMA, "running": True, "published_at": now,
"stale_after": 180, "plugins": plugins,
}, now=now)
def stale_view(plugins):
"""The same snapshot, read long after the display stopped refreshing it."""
now = time.time()
return view_from_snapshot({
"schema": SNAPSHOT_SCHEMA, "running": True, "published_at": now - 3600,
"stale_after": 180, "plugins": plugins,
}, now=now)
class TestStateReconciliation(unittest.TestCase):
"""Test state reconciliation system."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = Path(tempfile.mkdtemp())
self.plugins_dir = self.temp_dir / "plugins"
self.plugins_dir.mkdir()
# Create mock managers
self.state_manager = Mock(spec=PluginStateManager)
self.config_manager = Mock()
self.plugin_manager = Mock()
# What the display reports; tests replace it.
self.observed = PluginRuntimeView(status="unknown")
# Initialize reconciliation system
self.reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir
plugins_dir=self.plugins_dir,
runtime_source=lambda: self.observed,
)
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir)
def _install(self, plugin_id, version="1.0.0"):
plugin_dir = self.plugins_dir / plugin_id
plugin_dir.mkdir()
with open(plugin_dir / "manifest.json", 'w') as f:
json.dump({"id": plugin_id, "version": version, "name": plugin_id}, f)
def test_reconcile_no_inconsistencies(self):
"""Test reconciliation with no inconsistencies."""
# Setup: All states are consistent
"""Config, disk and the display agree."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=True,
status=PluginStateStatus.ENABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {"plugin1": Mock()}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
self._install("plugin1")
self.observed = live_view({"plugin1": {
"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": time.time()}})
result = self.reconciler.reconcile_state()
# Verify
self.assertIsInstance(result, ReconciliationResult)
self.assertEqual(len(result.inconsistencies_found), 0)
self.assertTrue(result.reconciliation_successful)
def test_plugin_missing_in_config(self):
"""Test detection of plugin missing in config."""
# Setup: Plugin exists on disk but not in config
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
self._install("plugin1")
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG)
self.assertTrue(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
def test_plugin_missing_on_disk(self):
"""Test detection of plugin missing on disk."""
# Setup: Plugin in config but not on disk
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Don't create plugin directory
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_ON_DISK)
self.assertFalse(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.MANUAL_FIX_REQUIRED)
def test_enabled_state_mismatch(self):
"""Test detection of enabled state mismatch."""
# Setup: Config says enabled=True, state manager says enabled=False
def test_enabled_but_not_loaded_is_reported_not_fixed(self):
"""Enabled in config, installed, but the display reports a failed load:
a finding that names the error, left alone (the display loads by
config on its own) and not counted as needing manual attention."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=False,
status=PluginStateStatus.DISABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
self._install("plugin1")
self.observed = live_view({"plugin1": {
"loaded": False, "state": "error",
"error": {"type": "ImportError", "message": "No module named 'x'",
"at": time.time(), "recoverable": False},
"version": None, "loaded_at": None}})
self.config_manager.save_config = Mock()
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH)
self.assertTrue(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
self.assertFalse(inconsistency.can_auto_fix)
self.assertIn("No module named", inconsistency.description)
self.assertEqual(result.inconsistencies_fixed, [])
self.assertEqual(result.inconsistencies_manual, [])
self.assertTrue(result.reconciliation_successful)
self.config_manager.save_config.assert_not_called()
def test_enabled_and_absent_from_live_snapshot_is_not_loaded(self):
"""A plugin a live snapshot does not list is not loaded."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
self.observed = live_view({})
result = self.reconciler.reconcile_state()
types = [i.inconsistency_type for i in result.inconsistencies_found]
self.assertEqual(types, [InconsistencyType.PLUGIN_ENABLED_MISMATCH])
def test_disabled_and_not_loaded_agrees(self):
"""Missing "enabled" is disabled (the display's rule), and a plugin
the display has not loaded matches it."""
self.config_manager.load_config.return_value = {"plugin1": {}}
self._install("plugin1")
self.observed = live_view({})
result = self.reconciler.reconcile_state()
self.assertEqual(result.inconsistencies_found, [])
def test_loaded_at_an_older_version_is_reported(self):
"""The display runs 1.0.0 while 2.0.0 is on disk: restart needed."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1", version="2.0.0")
self.observed = live_view({"plugin1": {
"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": time.time()}})
result = self.reconciler.reconcile_state()
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_VERSION_MISMATCH)
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
self.assertIn("restart", inconsistency.description)
self.assertTrue(result.reconciliation_successful)
def test_stale_or_missing_snapshot_is_not_compared(self):
"""Observed state that is not live says nothing: no findings from it."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
dead = {"plugin1": {"loaded": False, "state": "error", "error": None,
"version": None, "loaded_at": None}}
for observed in (stale_view(dead), PluginRuntimeView(status="stopped"),
PluginRuntimeView(status="unknown")):
self.observed = observed
result = self.reconciler.reconcile_state()
self.assertEqual(result.inconsistencies_found, [], observed.status)
def test_no_runtime_source_is_unknown(self):
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
reconciler = StateReconciliation(config_manager=self.config_manager,
plugins_dir=self.plugins_dir)
self.assertEqual(reconciler.reconcile_state().inconsistencies_found, [])
def test_runtime_source_that_raises_is_unknown(self):
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
def boom():
raise RuntimeError("cache unreadable")
reconciler = StateReconciliation(config_manager=self.config_manager,
plugins_dir=self.plugins_dir,
runtime_source=boom)
result = reconciler.reconcile_state()
self.assertEqual(result.inconsistencies_found, [])
self.assertTrue(result.reconciliation_successful)
def test_old_constructor_arguments_are_refused(self):
"""state_manager / plugin_manager are gone; passing them is an error,
not something silently ignored."""
with self.assertRaises(TypeError):
StateReconciliation(state_manager=Mock(), config_manager=self.config_manager,
plugin_manager=Mock(), plugins_dir=self.plugins_dir)
def test_plugin_states_combines_desired_and_observed(self):
"""What /plugins/state serves in place of plugin_state.json."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True},
"plugin2": {"enabled": False},
"ghost": {"enabled": True},
}
self._install("plugin1", version="1.2.0")
self._install("plugin2")
self._install("plugin3")
self.observed = live_view({"plugin1": {
"loaded": True, "state": "enabled", "error": None,
"version": "1.2.0", "loaded_at": 1234.0}})
states = self.reconciler.plugin_states()
self.assertEqual(set(states), {"plugin1", "plugin2", "plugin3", "ghost"})
self.assertEqual(states["plugin1"], {
"plugin_id": "plugin1", "installed": True, "version": "1.2.0",
"in_config": True, "enabled": True, "loaded": True,
"state": "enabled", "error_info": None,
"loaded_version": "1.2.0", "loaded_at": 1234.0,
})
self.assertFalse(states["plugin2"]["enabled"])
self.assertIs(states["plugin2"]["loaded"], False)
self.assertEqual(states["plugin2"]["state"], "unloaded")
self.assertFalse(states["plugin3"]["in_config"])
self.assertFalse(states["ghost"]["installed"])
def test_plugin_states_without_a_live_snapshot_reports_unknown(self):
self.config_manager.load_config.return_value = {"plugin1": {"enabled": True}}
self._install("plugin1")
self.observed = stale_view({"plugin1": {"loaded": True, "state": "enabled"}})
record = self.reconciler.plugin_states()["plugin1"]
self.assertTrue(record["enabled"])
self.assertIsNone(record["loaded"])
self.assertIsNone(record["state"])
self.assertIsNone(record["error_info"])
def test_auto_fix_plugin_missing_in_config(self):
"""Test auto-fix of plugin missing in config."""
# Setup
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Mock save_config to track calls
self._install("plugin1")
saved_configs = []
def save_config(config):
saved_configs.append(config)
self.config_manager.save_config = save_config
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify fix was attempted
self.assertEqual(len(result.inconsistencies_fixed), 1)
self.assertEqual(len(saved_configs), 1)
self.assertIn("plugin1", saved_configs[0])
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
def test_auto_fix_enabled_state_mismatch(self):
"""Test auto-fix of enabled state mismatch."""
# Setup: Config says enabled=True, state manager says enabled=False
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=False,
status=PluginStateStatus.DISABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
result = self.reconciler.reconcile_state()
# config.json is the source of truth for enabled state. The fix syncs
# the state manager to match config (config says True → state set True),
# rather than overwriting the config with the stale state value.
self.assertEqual(len(result.inconsistencies_fixed), 1)
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
def test_multiple_inconsistencies(self):
"""Test reconciliation with multiple inconsistencies."""
# Setup: Multiple plugins with different issues
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}, # Exists in config but not on disk
# plugin2 exists on disk but not in config
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=True,
status=PluginStateStatus.ENABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin2 directory (exists on disk but not in config)
plugin2_dir = self.plugins_dir / "plugin2"
plugin2_dir.mkdir()
manifest_path = plugin2_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 2"}, f)
# Run reconciliation
self._install("plugin2")
result = self.reconciler.reconcile_state()
# Verify multiple inconsistencies found
self.assertGreaterEqual(len(result.inconsistencies_found), 2)
# Check types
inconsistency_types = [inc.inconsistency_type for inc in result.inconsistencies_found]
self.assertIn(InconsistencyType.PLUGIN_MISSING_ON_DISK, inconsistency_types)
self.assertIn(InconsistencyType.PLUGIN_MISSING_IN_CONFIG, inconsistency_types)
def test_reconciliation_with_exception(self):
"""Test reconciliation handles exceptions gracefully."""
# Setup: State manager raises exception when getting states
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.side_effect = Exception("State manager error")
# Run reconciliation
self.config_manager.load_config.side_effect = Exception("Config error")
result = self.reconciler.reconcile_state()
# Verify error is handled - reconciliation may still succeed if other sources work
self.assertIsInstance(result, ReconciliationResult)
# Note: Reconciliation may still succeed if other sources provide valid state
def test_fix_failure_handling(self):
"""Test that fix failures are handled correctly."""
# Setup: Plugin missing in config, but save fails
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Mock save_config to raise exception
self._install("plugin1")
self.config_manager.save_config.side_effect = Exception("Save failed")
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected but not fixed
self.assertEqual(len(result.inconsistencies_found), 1)
self.assertEqual(len(result.inconsistencies_fixed), 0)
self.assertEqual(len(result.inconsistencies_manual), 1)
def test_get_config_state_handles_exception(self):
"""Test that _get_config_state handles exceptions."""
# Setup: Config manager raises exception
self.config_manager.load_config.side_effect = Exception("Config error")
# Call method directly
state = self.reconciler._get_config_state()
# Verify empty state returned
self.assertEqual(state, {})
def test_get_disk_state_handles_exception(self):
"""Test that _get_disk_state handles exceptions."""
# Setup: Make plugins_dir inaccessible
with patch.object(self.reconciler, 'plugins_dir', create=True) as mock_dir:
mock_dir.exists.side_effect = Exception("Disk error")
mock_dir.iterdir.side_effect = Exception("Disk error")
# Call method directly
state = self.reconciler._get_disk_state()
# Verify empty state returned
self.assertEqual(state, {})
@@ -352,15 +366,10 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
self.plugins_dir = self.temp_dir / "plugins"
self.plugins_dir.mkdir()
self.state_manager = Mock(spec=PluginStateManager)
self.state_manager.get_all_states.return_value = {}
self.config_manager = Mock()
self.config_manager.load_config.return_value = {
"ghost": {"enabled": True}
}
self.plugin_manager = Mock()
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Store manager with an empty registry — install_plugin always fails
self.store_manager = Mock()
@@ -372,9 +381,7 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
self.store_manager.is_plugin_uninstalled.return_value = False
self.reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir,
store_manager=self.store_manager,
)
@@ -483,9 +490,7 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
)
reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir,
store_manager=real_store,
)
@@ -43,7 +43,6 @@ def store(tmp_path):
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
patch.object(api_v3, 'plugin_catalog', None, create=True), \
patch.object(api_v3, 'schema_manager', None, create=True), \
patch.object(api_v3, 'plugin_state_manager', None, create=True), \
patch.object(api_v3, 'operation_history', None, create=True):
yield sm
@@ -194,8 +194,11 @@ class TestTheWebProcessNeverRunsAPlugin:
entry = next(p for p in body["data"]["plugins"] if p["id"] == PLUGIN_ID)
assert entry["version"] == "1.0.0"
assert entry["enabled"] is True
# Not published by the display, so not invented here.
# No display has published a runtime snapshot here, so these are
# unknown rather than invented (test_plugin_runtime_snapshot.py
# covers a live one).
assert entry["loaded"] is None and entry["state"] is None
assert body["data"]["runtime"]["status"] == "unknown"
# Nothing in its files declares a participation; the display derives
# one from its hooks, which are not called here.
assert (entry["vegas_participation"], entry["vegas_participation_source"]) == (