mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
The web process built its own PluginManager and loaded plugins into itself: store installs and updates loaded or reloaded a web-side copy, and config saves and enable/disable called on_config_change, on_enable and on_disable on it. None of that reached the panel, and /plugins/installed reported runtime state from those copies. - Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests, directories, display modes, installed version, schema and config reads, with no way to run a plugin. app.py and both blueprints use it; the plugin_manager blueprint attribute is gone. - Remove every lifecycle call from the web routes. Config changes already reach the display through ConfigService (on_config_change) and the enabled-set reconcile. - Health and metrics readers move to api_v3.health_tracker / resource_monitor. /plugins/installed reports loaded/state/error_info as null (the display does not publish them) and enabled by the display's rule. - Store install, update and uninstall answer restart_required when the running display will not pick the change up by itself (display_restart_required). The restart banner follows the flag via window.noteRestartRequired instead of the /config/main URL heuristic; /config/main now sends restart_required: true. - The one remaining in-process import of plugin code (Starlark helper modules, oauth_flow action scripts) goes through _import_plugin_code_in_web_process() until a web-entry contract. - /plugins/installed reports vegas_participation (from #682) from the user's setting or the manifest, with vegas_participation_source; when only the plugin's code decides it, null with source 'runtime', since the web process no longer has plugin instances to ask. - Check & Update All keeps its restart flags when the final list refresh fails, and asks for a restart when an enabled plugin's first request got no answer and the re-sent one found it up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
7.1 KiB
Python
158 lines
7.1 KiB
Python
"""GET /api/v3/display/modes -- the list of modes an on-demand request can name.
|
|
|
|
Anything driving the display from outside the web UI needs two things that no
|
|
existing endpoint gave it: the set of display modes, and which plugin owns each
|
|
one. /plugins/installed carries neither, so callers read every plugin's
|
|
manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
|
api_v3_client, api_v3_module,
|
|
)
|
|
|
|
|
|
MANIFESTS = {
|
|
'clock-simple': {'name': 'Simple Clock', 'display_modes': ['clock-simple']},
|
|
'football-scoreboard': {
|
|
'name': 'Football Scoreboard',
|
|
'display_modes': ['nfl_live', 'nfl_recent', 'nfl_upcoming'],
|
|
},
|
|
'ledmatrix-weather': {'name': 'Weather', 'display_modes': ['weather']},
|
|
}
|
|
|
|
CONFIG = {
|
|
'clock-simple': {'enabled': True},
|
|
'football-scoreboard': {'enabled': True},
|
|
'ledmatrix-weather': {'enabled': False},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client(api_v3_module, api_v3_client):
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = MANIFESTS
|
|
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
pm.get_plugin_display_modes = MagicMock(
|
|
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value=CONFIG)
|
|
return api_v3_client
|
|
|
|
|
|
def _modes(response):
|
|
return {m['mode']: m for m in response.get_json()['data']['modes']}
|
|
|
|
|
|
class TestTheModeListing:
|
|
def test_enabled_plugins_contribute_every_mode(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert set(modes) == {'clock-simple', 'nfl_live', 'nfl_recent', 'nfl_upcoming'}
|
|
|
|
def test_each_mode_names_its_plugin(self, client):
|
|
"""on-demand/start's find_plugin_for_mode fallback cannot see generated
|
|
modes, so the caller has to send plugin_id -- it must come from here."""
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['nfl_live']['plugin_id'] == 'football-scoreboard'
|
|
|
|
def test_disabled_plugins_are_left_out_by_default(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert 'weather' not in modes
|
|
|
|
def test_disabled_plugins_can_be_asked_for(self, client):
|
|
"""They are still valid on-demand targets: the controller enables them
|
|
for the duration of the request."""
|
|
modes = _modes(client.get('/api/v3/display/modes?include_disabled=1'))
|
|
assert modes['weather']['enabled'] is False
|
|
|
|
def test_a_single_mode_plugin_is_labelled_with_its_own_name(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['clock-simple']['name'] == 'Simple Clock'
|
|
|
|
def test_a_multi_mode_plugin_labels_each_mode_distinctly(self, client):
|
|
"""There is no per-mode name anywhere, and three modes all called
|
|
"Football Scoreboard" are not a usable dropdown."""
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['nfl_live']['name'] == 'nfl_live'
|
|
assert modes['nfl_live']['plugin_name'] == 'Football Scoreboard'
|
|
|
|
|
|
class TestItWorksForACallerThatNeverOpensTheDashboard:
|
|
def test_discovery_is_triggered(self, client, api_v3_module):
|
|
"""Discovery is lazy and normally runs because a person loaded the
|
|
dashboard; a bridge or script would otherwise get an empty list."""
|
|
client.get('/api/v3/display/modes')
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once()
|
|
|
|
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
|
|
api_v3_module.api_v3.plugin_catalog = None
|
|
response = api_v3_client.get('/api/v3/display/modes')
|
|
assert response.status_code == 500
|
|
assert response.get_json()['status'] == 'error'
|
|
|
|
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
|
|
"""Its mode is its own id -- the same fallback the controller uses."""
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
|
|
pm.get_plugin_display_modes = MagicMock(return_value=[])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(
|
|
return_value={'starlark-apps': {'enabled': True}})
|
|
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['starlark-apps']['plugin_id'] == 'starlark-apps'
|
|
|
|
|
|
class TestOneBadConfigSectionDoesNotBlankTheList:
|
|
"""config.json can hold a non-dict under a plugin id.
|
|
|
|
DisplayController guards the same shape, so it happens in practice. Here it
|
|
used to raise AttributeError mid-loop and answer 500 with no modes at all --
|
|
and the MQTT bridge builds every one of its entities from this list, so one
|
|
hand-edited section would empty the Home Assistant dropdown.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def client_with_bad_section(self, api_v3_module, api_v3_client):
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = MANIFESTS
|
|
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
pm.get_plugin_display_modes = MagicMock(
|
|
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={
|
|
'clock-simple': {'enabled': True},
|
|
'football-scoreboard': "true", # a string, not an object
|
|
'ledmatrix-weather': {'enabled': True},
|
|
})
|
|
return api_v3_client
|
|
|
|
def test_the_endpoint_still_answers(self, client_with_bad_section):
|
|
assert client_with_bad_section.get('/api/v3/display/modes').status_code == 200
|
|
|
|
def test_the_healthy_plugins_are_still_listed(self, client_with_bad_section):
|
|
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
assert 'clock-simple' in modes and 'weather' in modes
|
|
|
|
def test_the_bad_section_is_treated_as_disabled(self, client_with_bad_section):
|
|
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
assert 'nfl_live' not in modes
|
|
|
|
def test_a_failure_is_reported_the_way_every_other_handler_reports_one(
|
|
self, api_v3_module, api_v3_client):
|
|
"""describe_exception, per test_web_error_detail's contract -- an
|
|
opaque "see logs for details" is what that test exists to prevent."""
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
|
side_effect=RuntimeError("disk is gone"))
|
|
resp = api_v3_client.get('/api/v3/display/modes')
|
|
assert resp.status_code == 500
|
|
assert 'disk is gone' in resp.get_json()['details']
|
|
|
|
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
|
"""describe_exception is what makes returning detail safe."""
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
|
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
|
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
|
assert 'SEC123' not in json.dumps(body)
|