mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25: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>
165 lines
7.7 KiB
Python
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
|