Files
LEDMatrix/test/web_interface/test_update_all_plugins.py
T
ChuckandClaude Opus 5.5 7ab6fb1aff refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
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>
2026-09-30 10:39:44 -04:00

165 lines
7.7 KiB
Python

"""'Check & Update All' must only send ids POST /plugins/update handles.
Seen on a device running core 3.4.0: update-all posted every entry from
/plugins/installed, including the virtual `starlark:<app_id>` entries that
list installed Starlark apps, and the route answered each with a 500
"Plugin update failed: plugin not found". A web-service restart during the
same run also cost stock-news its update: its request was refused while the
service came back, and update-all never sent it again.
The id selection and retry live in install_manager.js and are covered by
test/js/unit/test_update_all.js, which this module runs so CI sees it. The
route contract is tested here directly.
"""
import shutil
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
REPO = Path(__file__).resolve().parents[2]
JS_SUITE = REPO / 'test' / 'js' / 'unit' / 'test_update_all.js'
@pytest.fixture
def client():
from web_interface.app import app
app.config['TESTING'] = True
with app.test_client() as c:
yield c
@pytest.fixture
def store(tmp_path):
"""A store manager that records calls; no git, no network."""
from web_interface.blueprints.api_v3 import api_v3
sm = MagicMock()
sm.plugins_dir = str(tmp_path)
sm._get_local_git_info.return_value = None
sm.get_plugin_info.return_value = None
sm.update_plugin.return_value = True
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
class TestUpdateRouteRejectsStarlarkIds:
@pytest.mark.parametrize('app_id', ['starlark:analogtime', 'starlark:analogclock'])
def test_a_starlark_id_is_a_400_not_a_500(self, client, store, app_id):
resp = client.post('/api/v3/plugins/update', json={'plugin_id': app_id})
assert resp.status_code == 400, resp.get_json()
body = resp.get_json()
assert body['status'] == 'error'
assert body['error_code'] == 'INVALID_INPUT'
assert 'Starlark app' in body['message'], body
assert 'not found' not in body['message'], \
"the app is installed; 'not found' is the misleading message this replaces"
def test_the_store_manager_is_never_asked(self, client, store):
client.post('/api/v3/plugins/update', json={'plugin_id': 'starlark:analogtime'})
store.update_plugin.assert_not_called()
def test_form_encoded_starlark_id_is_rejected_the_same_way(self, client, store):
resp = client.post('/api/v3/plugins/update', data={'plugin_id': 'starlark:analogtime'})
assert resp.status_code == 400
def test_a_plugin_id_still_reaches_the_updater(self, client, store, tmp_path):
# The guard is on the 'starlark:' prefix only: the starlark-apps plugin
# and a disabled plugin with an update are still updated.
for pid in ('stock-news', 'starlark-apps'):
(tmp_path / pid).mkdir()
(tmp_path / pid / 'manifest.json').write_text('{"id": "%s"}' % pid, encoding='utf-8')
resp = client.post('/api/v3/plugins/update', json={'plugin_id': pid})
assert resp.status_code == 200, (pid, resp.get_json())
assert [c.args[0] for c in store.update_plugin.call_args_list] == ['stock-news', 'starlark-apps']
class TestUpdateRouteReportsNoOps:
"""update_plugin() answers True when there was nothing to do.
A ZIP-installed monorepo plugin (no .git) already at the registry version
is the common case for official plugins. The route used to call that
"updated successfully", so Check & Update All counted it as updated.
"""
def _install(self, tmp_path, pid, version, last_updated='2026-09-01'):
(tmp_path / pid).mkdir()
(tmp_path / pid / 'manifest.json').write_text(
'{"id": "%s", "version": "%s", "last_updated": "%s"}' % (pid, version, last_updated),
encoding='utf-8')
def test_a_zip_plugin_already_at_the_registry_version_is_up_to_date(self, client, store, tmp_path):
self._install(tmp_path, 'stock-news', '2.8.0')
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
assert body['status'] == 'success'
assert body['data']['update_status'] == 'up_to_date'
assert 'already up to date' in body['message'], body
assert 'updated successfully' not in body['message']
def test_a_zip_plugin_reinstalled_at_a_new_version_is_updated(self, client, store, tmp_path):
self._install(tmp_path, 'stock-news', '2.6.2')
def reinstall(pid):
(tmp_path / pid / 'manifest.json').write_text(
'{"id": "%s", "version": "2.8.0", "last_updated": "2026-09-01"}' % pid,
encoding='utf-8')
return True
store.update_plugin.side_effect = reinstall
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
assert body['data']['update_status'] == 'updated'
assert '2.8.0' in body['message'], body
def test_a_git_plugin_whose_commit_is_unchanged_is_up_to_date(self, client, store, tmp_path):
self._install(tmp_path, 'clock', '1.0.0')
store._get_local_git_info.return_value = {'sha': 'abcdef1234567', 'branch': 'main'}
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
assert body['data']['update_status'] == 'up_to_date'
def test_a_git_plugin_that_moved_is_updated(self, client, store, tmp_path):
self._install(tmp_path, 'clock', '1.0.0')
store._get_local_git_info.side_effect = [
{'sha': 'aaaaaaa000', 'branch': 'main'}, # before
{'sha': 'bbbbbbb111', 'branch': 'main'}, # after
]
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
assert body['data']['update_status'] == 'updated', body
def test_a_local_only_plugin_says_so(self, client, store, tmp_path):
(tmp_path / 'mine').mkdir()
(tmp_path / 'mine' / 'manifest.json').write_text(
'{"id": "mine", "version": "0.1.0", "local_only": true}', encoding='utf-8')
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'mine'}).get_json()
assert body['data']['update_status'] == 'local_only'
store.update_plugin.assert_not_called()
class TestInstalledListContract:
"""What update-all filters on is what /plugins/installed publishes."""
def test_starlark_entries_are_flagged_and_prefixed(self, client):
apps = {'apps': {'analogtime': {'name': 'Analog Time', 'enabled': False}}}
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=apps):
resp = client.get('/api/v3/plugins/installed')
plugins = resp.get_json()['data']['plugins']
entry = next(p for p in plugins if p['id'] == 'starlark:analogtime')
assert entry['is_starlark_app'] is True
assert not any(p.get('is_starlark_app') for p in plugins
if not p['id'].startswith('starlark:')), \
"a real plugin carries the Starlark flag and would be dropped from update-all"
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
def test_update_all_js_selection_and_retry():
node = shutil.which('node')
result = subprocess.run([node, str(JS_SUITE)], capture_output=True, text=True,
timeout=120, cwd=str(JS_SUITE.parent))
assert result.returncode == 0, result.stdout + result.stderr