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>
This commit is contained in:
Chuck
2026-09-30 10:39:44 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ba6eccb489
commit 7ab6fb1aff
74 changed files with 1759 additions and 676 deletions
+21 -2
View File
@@ -21,14 +21,32 @@ from flask import Flask
# Every manager attribute the blueprint reads. Anything missing here keeps
# whatever a previously-run test left on the singleton.
API_V3_MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager',
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
'health_tracker', 'resource_monitor',
)
_SENTINEL = object()
def mock_plugin_catalog():
"""A MagicMock shaped like PluginCatalog, and only like it.
``spec`` makes anything a PluginCatalog lacks raise AttributeError --
``get_plugin``, ``load_plugin``, ``plugins`` -- so a route that reached
for a plugin instance in the web process fails the test that drives it
instead of quietly calling a mock. The instance attributes the spec
cannot see are set explicitly.
"""
from src.plugin_system.plugin_catalog import PluginCatalog
catalog = MagicMock(spec=PluginCatalog)
for name in ('plugins_dir', 'config_manager', 'schema_manager',
'plugin_manifests', 'plugin_directories'):
setattr(catalog, name, MagicMock())
return catalog
def build_app(blueprint):
app = Flask(__name__)
app.config['TESTING'] = True
@@ -52,7 +70,8 @@ def api_v3_module():
for name in API_V3_MANAGER_ATTRS
}
for name in API_V3_MANAGER_ATTRS:
setattr(module.api_v3, name, MagicMock())
setattr(module.api_v3, name,
mock_plugin_catalog() if name == 'plugin_catalog' else MagicMock())
# Default to the direct path; queue tests opt in explicitly.
module.api_v3.operation_queue = None
+1 -1
View File
@@ -22,7 +22,7 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js'];
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
+104
View File
@@ -0,0 +1,104 @@
// The "restart the display" banner is raised by the server's answer, not by
// which URL was called.
//
// app.js used to show it after any successful POST to /api/v3/config/main and
// nothing else. A plugin update, install or uninstall that the running display
// cannot pick up live now answers `restart_required: true` (with the banner's
// wording in `restart_message`), and so does a main-config save. The htmx
// after-request handler and window.noteRestartRequired must both follow the
// flag. Runs the shipped app.js in a vm with a minimal fake DOM -- no jsdom and
// no server needed, so it runs under test/test_js_unit_suites.py too.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function load() {
const handlers = {};
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
const banner = { style: { display: 'none' } };
const text = { dataset: {}, textContent: ' Configuration saved — restart the display to apply the changes ' };
const store = {};
const document = {
body: { addEventListener: listen('body') },
addEventListener: listen('document'),
getElementById: (id) => ({ 'restart-pending-banner': banner, 'restart-pending-text': text })[id] || null,
querySelector: () => null,
querySelectorAll: () => [],
};
const window = {
addEventListener: listen('window'),
getApp: () => null,
};
const context = {
window, document, console,
sessionStorage: {
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
getItem: (k) => (k in store ? store[k] : null),
},
showNotification: () => {},
setTimeout: () => 0,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
const elt = { closest: () => (reportsItself ? {} : null) };
afterRequest({
target: { closest: () => null },
detail: {
xhr: { status, responseText: body === undefined ? '' : (typeof body === 'string' ? body : JSON.stringify(body)) },
elt,
requestConfig: { verb: 'post', path: reqPath },
},
});
};
return { window, banner, text, store, fire, afterRequest };
}
console.log('\nwindow.noteRestartRequired');
{
const t = load();
ok('app.js defines it', typeof t.window.noteRestartRequired === 'function');
ok('no flag, no banner', t.window.noteRestartRequired({ status: 'success' }) === false
&& t.banner.style.display === 'none');
ok('a missing body is ignored', t.window.noteRestartRequired(null) === false);
ok('restart_required: false is not a request',
t.window.noteRestartRequired({ restart_required: false }) === false && t.banner.style.display === 'none');
ok('restart_required: true shows the banner',
t.window.noteRestartRequired({ restart_required: true }) === true && t.banner.style.display === 'block');
ok('without a message the template wording stays',
t.text.textContent === 'Configuration saved — restart the display to apply the changes', t.text.textContent);
t.window.noteRestartRequired({ restart_required: true, restart_message: 'Plugin updated — restart the display to run the new version' });
ok('restart_message becomes the wording',
t.text.textContent === 'Plugin updated — restart the display to run the new version', t.text.textContent);
ok('and survives a reload with the flag', t.store['ledmatrix-restart-pending'] === '1'
&& t.store['ledmatrix-restart-pending-text'] === 'Plugin updated — restart the display to run the new version');
}
console.log('\nhtmx after-request follows the flag, not the URL');
{
const t = load();
ok('the handler is registered', typeof t.afterRequest === 'function');
t.fire({ path: '/api/v3/config/main', body: { status: 'success', message: 'Configuration saved successfully' } });
ok('a /config/main answer without the flag raises nothing', t.banner.style.display === 'none');
t.fire({ path: '/api/v3/plugins/update', status: 500, body: { status: 'error', restart_required: true } });
ok('an error answer never raises it', t.banner.style.display === 'none');
t.fire({ path: '/api/v3/whatever', body: '<html>not json' });
ok('a non-JSON answer is ignored', t.banner.style.display === 'none');
// The main-config forms report their own result (hx-on after-request), so
// the flag must be read even when the toast is not this handler's to show.
t.fire({ path: '/api/v3/config/main', reportsItself: true,
body: { status: 'success', message: 'Configuration saved successfully', restart_required: true } });
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+72
View File
@@ -229,6 +229,78 @@ const noSleep = { sleep: async () => {} };
allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop);
}
console.log('\nrestart banner: driven by the server\'s restart_required');
{
const body = (restart_required, restart_message) => ({
success: true,
result: { status: 'success', data: { update_status: 'updated' }, restart_required, restart_message },
});
const needed = body(true, 'Plugin updated — restart the display to run the new version');
ok('an update the display is running asks for the banner, with its wording',
Manager.restartRequest([body(false), needed, body(true, 'second')]) === needed.result);
ok('updates the display does not run need no restart',
Manager.restartRequest([body(false), body(false)]) === null);
ok('a failed request never raises the banner',
Manager.restartRequest([{ success: false, error: { restart_required: true } }]) === null);
ok('an older server that sends no flag raises nothing',
Manager.restartRequest([{ success: true, result: { status: 'success' } }]) === null);
ok('no results, no banner', Manager.restartRequest(undefined) === null);
// The first request's answer was lost; the re-sent one finds nothing to do.
const lost = (enabled, update_status = 'up_to_date') => ({
pluginId: 'clock', success: true, afterLostAnswer: true, enabled,
result: { status: 'success', data: { update_status }, restart_required: false },
});
const maybe = Manager.restartRequest([body(false), lost(true)]);
ok('an enabled plugin up to date after a lost answer may have been updated: banner',
maybe && maybe.restart_required === true && /clock/.test(maybe.restart_message), maybe);
ok('...but an explicit answer still wins, with its wording',
Manager.restartRequest([lost(true), needed]) === needed.result);
ok('a disabled one needs no restart (enabling it loads it)',
Manager.restartRequest([lost(false)]) === null);
ok('nor does one that was not retried',
Manager.restartRequest([{ ...lost(true), afterLostAnswer: undefined }]) === null);
}
console.log('\nupdateAll keeps what the banner needs');
{
// ledmatrix-flights (enabled) loses its first answer, then is up to date.
const api = fakeApi({
'ledmatrix-flights': (n) => {
if (n === 1) throw netErr();
return { status: 'success', data: { update_status: 'up_to_date' }, restart_required: false };
},
});
setup(api, { windowList: INSTALLED });
const results = await Manager.updateAll(null, noSleep);
const flights = results.find(r => r.pluginId === 'ledmatrix-flights');
ok('a retried entry is marked, with the plugin\'s enabled flag',
flights.afterLostAnswer === true && flights.enabled === true, flights);
ok('an entry answered first time is not marked',
results.filter(r => r.afterLostAnswer).length === 1, results);
ok('...so the run asks for a restart', Manager.restartRequest(results) !== null);
}
{
const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true };
const api = fakeApi({ 'ledmatrix-flights': () => answer });
setup(api, { stateList: INSTALLED });
window.PluginStateManager.loadInstalledPlugins = async () => { throw new Error('refresh failed'); };
const warn = console.warn;
console.warn = () => {};
let results;
try {
results = await Manager.updateAll(null, noSleep);
} catch (e) {
results = e;
} finally {
console.warn = warn;
}
ok('a failed list refresh still returns the results',
Array.isArray(results) && results.length === EXPECTED.length, String(results));
ok('...with the restart flag intact',
Array.isArray(results) && Manager.restartRequest(results) === answer);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+1 -1
View File
@@ -52,7 +52,7 @@ class TestPluginToggle:
class TestOnDemandStart:
@pytest.fixture
def service(self, api_v3_module):
api_v3_module.api_v3.plugin_manager = None
api_v3_module.api_v3.plugin_catalog = None
api_v3_module.api_v3.config_manager = None
with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
return_value={"active": True}), \
+2 -2
View File
@@ -45,7 +45,7 @@ VALID_CREDENTIALS = {
def plugin_dir(tmp_path, api_v3_module):
directory = tmp_path / "plugins" / "calendar"
directory.mkdir(parents=True)
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(directory)
return directory
@@ -98,7 +98,7 @@ class TestRequestValidation:
assert not (plugin_dir / "credentials.json").exists()
def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path):
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(
tmp_path / "not-installed")
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404
+1 -1
View File
@@ -229,7 +229,7 @@ def display_page(monkeypatch):
config_manager.get_config_path.return_value = 'config/config.json'
config_manager.get_secrets_path.return_value = 'config/config_secrets.json'
monkeypatch.setattr(pv.pages_v3, 'config_manager', config_manager, raising=False)
monkeypatch.setattr(pv.pages_v3, 'plugin_manager', MagicMock(plugins={}), raising=False)
monkeypatch.setattr(pv.pages_v3, 'plugin_catalog', MagicMock(), raising=False)
app.register_blueprint(pv.pages_v3, url_prefix='/v3')
response = app.test_client().get('/v3/partials/display')
assert response.status_code == 200
+7 -7
View File
@@ -34,7 +34,7 @@ CONFIG = {
@pytest.fixture
def client(api_v3_module, api_v3_client):
pm = api_v3_module.api_v3.plugin_manager
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(
@@ -85,17 +85,17 @@ class TestItWorksForACallerThatNeverOpensTheDashboard:
"""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_manager.discover_plugins.assert_called_once()
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_manager = None
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_manager
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(
@@ -116,7 +116,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
@pytest.fixture
def client_with_bad_section(self, api_v3_module, api_v3_client):
pm = api_v3_module.api_v3.plugin_manager
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(
@@ -143,7 +143,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
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_manager.discover_plugins = MagicMock(
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
@@ -151,7 +151,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
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_manager.discover_plugins = MagicMock(
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)
+2 -2
View File
@@ -32,7 +32,7 @@ def _checks(client):
def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_manager.plugin_manifests = {
api_v3_module.api_v3.plugin_catalog.plugin_manifests = {
"clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"},
}
@@ -42,7 +42,7 @@ def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_
def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module):
pm = api_v3_module.api_v3.plugin_manager
pm = api_v3_module.api_v3.plugin_catalog
pm.plugin_manifests = {}
def discover():
+2 -3
View File
@@ -20,9 +20,8 @@ def installed(api_v3_module, api_v3_client, tmp_path):
api = api_v3_module.api_v3
info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': False}
info.update(manifest_extra)
api.plugin_manager.plugins_dir = str(tmp_path) # no manifest on disk
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_manager.get_plugin = MagicMock(return_value=None)
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={})
response = api_v3_client.get('/api/v3/plugins/installed')
+6 -6
View File
@@ -16,7 +16,7 @@ callers (the Home Assistant MQTT bridge, scripts) saw it after every restart.
undiscovered plugin section skipped secret separation and wrote its API key
into config.json in plain text.
A real PluginManager over a temporary plugins directory, so "empty until
A real PluginCatalog over a temporary plugins directory, so "empty until
discovered" is the real behaviour rather than a mock's.
"""
@@ -30,7 +30,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.plugin_catalog import PluginCatalog # noqa: E402
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
@@ -69,10 +69,10 @@ def plugins_dir(tmp_path):
@pytest.fixture
def fresh_web_process(api_v3_module, plugins_dir):
"""The web process right after a restart: nothing discovered yet."""
manager = PluginManager(plugins_dir=str(plugins_dir))
assert not manager.plugin_manifests
api_v3_module.api_v3.plugin_manager = manager
return manager
catalog = PluginCatalog(plugins_dir=plugins_dir)
assert not catalog.plugin_manifests
api_v3_module.api_v3.plugin_catalog = catalog
return catalog
@pytest.fixture
+1 -1
View File
@@ -47,7 +47,7 @@ def service(api_v3_module):
resolution (not what is under test here). The cache is the blueprint's
MagicMock cache_manager, so mailbox writes are visible as set() calls.
"""
api_v3_module.api_v3.plugin_manager = None
api_v3_module.api_v3.plugin_catalog = None
api_v3_module.api_v3.config_manager = None
state = {"active": True}
+2 -3
View File
@@ -35,9 +35,8 @@ class SharedCache:
@pytest.fixture
def shared_cache(api_v3_module):
cache = SharedCache()
pm = api_v3_module.api_v3.plugin_manager
pm.health_tracker = PluginHealthTracker(cache)
pm.resource_monitor = PluginResourceMonitor(cache)
api_v3_module.api_v3.health_tracker = PluginHealthTracker(cache)
api_v3_module.api_v3.resource_monitor = PluginResourceMonitor(cache)
return cache
+14 -12
View File
@@ -5,6 +5,11 @@ Both were only ever tested at the PluginStoreManager layer, so the route
logic — the queue-vs-direct branch, schema invalidation, plugin discovery,
state and history recording — was unexercised.
Neither route loads the plugin: the web process only lists it (the catalog
has no load_plugin, so calling one fails these tests). The display loads it
when it is enabled; restart_required says when that won't happen by itself
(test/web_interface/test_web_process_runs_no_plugin_code.py).
/plugins/install carries the same install logic twice: once inside the
operation-queue callback and once in the direct fallback. The paired
tests below assert both branches produce the same side effects, so the
@@ -44,8 +49,7 @@ def side_effects(module):
api = module.api_v3
return {
"schema_invalidated": api.schema_manager.invalidate_cache.call_args_list,
"discovered": api.plugin_manager.discover_plugins.call_count,
"loaded": api.plugin_manager.load_plugin.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,
}
@@ -83,7 +87,6 @@ class TestInstallDirectPath:
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1
assert effects["loaded"] == [(("clock",), {})]
assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success"
@@ -130,7 +133,7 @@ class TestInstallDirectPath:
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == []
assert effects["loaded"] == []
assert effects["discovered"] == 0
assert effects["state_set"] == []
@@ -154,7 +157,6 @@ class TestInstallQueuedPath:
effects = side_effects(api_v3_module)
assert effects["schema_invalidated"] == [(("clock",), {})]
assert effects["discovered"] == 1
assert effects["loaded"] == [(("clock",), {})]
assert effects["state_set"] == [(("clock",), {})]
assert effects["history"][0].kwargs["status"] == "success"
@@ -196,7 +198,7 @@ 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_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()
@@ -208,7 +210,6 @@ class TestInstallPathsAgree:
assert direct["schema_invalidated"] == queued["schema_invalidated"]
assert direct["discovered"] == queued["discovered"]
assert direct["loaded"] == queued["loaded"]
assert direct["state_set"] == queued["state_set"]
assert (direct["history"][0].kwargs["status"]
== queued["history"][0].kwargs["status"])
@@ -260,21 +261,22 @@ class TestInstallFromUrl:
branch="dev",
)
def test_success_invalidates_schema_and_loads_plugin(self, api_v3_client, api_v3_module):
def test_success_invalidates_schema_and_lists_plugin(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": "clock"}
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
assert response.status_code == 200, response.get_json()
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock")
api_v3_module.api_v3.plugin_manager.load_plugin.assert_called_once_with("clock")
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once_with()
def test_success_without_plugin_id_skips_discovery(self, api_v3_client, api_v3_module):
# install_from_url can succeed without naming the plugin; there is
# then nothing to invalidate or load.
# then nothing to invalidate or list.
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": None}
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_not_called()
api_v3_module.api_v3.plugin_manager.load_plugin.assert_not_called()
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_not_called()
def test_branch_from_result_included(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
+1 -1
View File
@@ -110,7 +110,7 @@ class TestCalendarCredentials:
def plugin_dir(self, tmp_path, api_v3_module):
directory = tmp_path / "plugins" / "calendar"
directory.mkdir(parents=True)
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(directory)
return directory
def _post(self, client):
+1 -1
View File
@@ -793,7 +793,7 @@ def api_client(monkeypatch):
cm.get_raw_file_content.return_value = {}
cm.save_config_atomic.return_value = MagicMock(status=MagicMock(value='success'), message=None)
api_v3.config_manager = cm
api_v3.plugin_manager = MagicMock(plugins={})
api_v3.plugin_catalog = MagicMock()
# Never restart a real display from a test run.
setup_calls = []
monkeypatch.setattr(au, 'start_setup_if_needed',
+1 -1
View File
@@ -241,7 +241,7 @@ class TestTheStatusEndpoint:
pm = MagicMock()
pm.plugins_dir = str(plugins_dir)
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
monkeypatch.setattr(api_v3, "plugin_catalog", pm, raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
+3 -4
View File
@@ -57,7 +57,7 @@ def render(config):
# pages_v3 is a module-level singleton shared across the test process;
# restore whatever the previous test left on it.
original_cm = getattr(pv.pages_v3, "config_manager", None)
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
original_pm = getattr(pv.pages_v3, "plugin_catalog", None)
mock_cm = MagicMock()
mock_cm.load_config.return_value = config
@@ -65,10 +65,9 @@ def render(config):
pv.pages_v3.config_manager = mock_cm
mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = []
mock_pm.get_plugin_display_modes.side_effect = lambda pid: []
pv.pages_v3.plugin_manager = mock_pm
pv.pages_v3.plugin_catalog = mock_pm
app.register_blueprint(pv.pages_v3, url_prefix="")
try:
@@ -77,7 +76,7 @@ def render(config):
return resp.get_data(as_text=True)
finally:
pv.pages_v3.config_manager = original_cm
pv.pages_v3.plugin_manager = original_pm
pv.pages_v3.plugin_catalog = original_pm
def timezone_step(body):
+1 -1
View File
@@ -17,7 +17,7 @@ from web_interface.blueprints import pages_v3 as module # noqa: E402
def client(tmp_path, monkeypatch):
plugin_manager = MagicMock()
plugin_manager.plugins_dir = tmp_path
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "plugin_catalog", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "config_manager",
MagicMock(load_config=lambda: {}), raising=False)
app = Flask(__name__, template_folder=str(
+3 -4
View File
@@ -39,19 +39,18 @@ def pages(tmp_path):
"<p>panel</p>", encoding="utf-8"
)
original_pm = getattr(module.pages_v3, "plugin_manager", None)
original_pm = getattr(module.pages_v3, "plugin_catalog", None)
original_cm = getattr(module.pages_v3, "config_manager", None)
plugin_manager = MagicMock()
plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"}
plugin_manager.get_plugin.return_value = None
module.pages_v3.plugin_manager = plugin_manager
module.pages_v3.plugin_catalog = plugin_manager
module.pages_v3.config_manager = MagicMock(load_config=lambda: {})
yield module, plugins_dir
module.pages_v3.plugin_manager = original_pm
module.pages_v3.plugin_catalog = original_pm
module.pages_v3.config_manager = original_cm
+11 -6
View File
@@ -132,8 +132,8 @@ class TestServePluginStatic:
candidate = plugins / plugin_id
return str(candidate) if candidate.exists() else None
api_v3_module.api_v3.plugin_manager = MagicMock()
api_v3_module.api_v3.plugin_manager.get_plugin_directory = get_plugin_directory
api_v3_module.api_v3.plugin_catalog = MagicMock()
api_v3_module.api_v3.plugin_catalog.get_plugin_directory = get_plugin_directory
return plugins
def test_a_real_plugin_file_is_still_served(
@@ -397,10 +397,13 @@ class TestPluginActionDirectory:
'../elsewhere' ran a script from any directory holding a manifest.
"""
@pytest.fixture
def tree(self, tmp_path):
@pytest.fixture(params=["catalog", "manager"])
def tree(self, request, tmp_path):
"""The web process resolves through PluginCatalog; the display
through PluginManager. Both apply the same rules."""
import threading
from src.plugin_system.plugin_catalog import PluginCatalog
from src.plugin_system.plugin_manager import PluginManager
plugins = tmp_path / "plugin-repos"
@@ -415,6 +418,8 @@ class TestPluginActionDirectory:
(outside / "s.py").write_text(
"open(%r, 'w').write('ran')\n" % str(marker), encoding="utf-8"
)
if request.param == "catalog":
return PluginCatalog(plugins), plugins, marker
manager = MagicMock()
manager.plugins_dir = plugins
manager._discovery_lock = threading.Lock()
@@ -446,7 +451,7 @@ class TestPluginActionDirectory:
self, api_v3_client, api_v3_module, tree
):
manager, _, marker = tree
api_v3_module.api_v3.plugin_manager = manager
api_v3_module.api_v3.plugin_catalog = manager
response = api_v3_client.post(
"/api/v3/plugins/action",
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
@@ -457,7 +462,7 @@ class TestPluginActionDirectory:
def test_the_fallback_without_a_plugin_manager_is_guarded_too(
self, api_v3_client, api_v3_module
):
api_v3_module.api_v3.plugin_manager = None
api_v3_module.api_v3.plugin_catalog = None
response = api_v3_client.post(
"/api/v3/plugins/action",
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
+1 -1
View File
@@ -64,7 +64,7 @@ def client(tmp_path, monkeypatch):
# singleton, so assigning them directly leaks mocks -- pointing at a
# deleted tmp_path -- into every later test that imports api_v3.
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
monkeypatch.setattr(api_v3, "plugin_catalog", pm, raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
+1 -1
View File
@@ -39,7 +39,7 @@ class SharedCache:
@pytest.fixture
def shared_cache(api_v3_module):
cache = SharedCache()
api_v3_module.api_v3.plugin_manager.resource_monitor = PluginResourceMonitor(
api_v3_module.api_v3.resource_monitor = PluginResourceMonitor(
cache, enable_monitoring=False)
return cache
+3 -3
View File
@@ -342,9 +342,9 @@ def web_store(api_v3_module, tmp_path, monkeypatch):
fake = FakeStore(tmp_path, monkeypatch, [], manifest("2.0.0"))
api_v3_module.api_v3.plugin_store_manager = fake.store
api_v3_module.api_v3.operation_queue = None
# No plugin manager: the routes skip discovery and reload, and the update
# route finds the directory through the store alone.
api_v3_module.api_v3.plugin_manager = None
# No plugin catalog: the routes skip discovery, and the update route
# finds the directory through the store alone.
api_v3_module.api_v3.plugin_catalog = None
return fake
+1 -1
View File
@@ -33,7 +33,7 @@ def client():
app = Flask(__name__)
app.config["TESTING"] = True
from web_interface.blueprints.api_v3 import api_v3
for attr in ("config_manager", "plugin_manager", "cache_manager"):
for attr in ("config_manager", "plugin_catalog", "cache_manager"):
setattr(api_v3, attr, MagicMock())
if "api_v3" not in app.blueprints:
app.register_blueprint(api_v3, url_prefix="/api/v3")
+20 -48
View File
@@ -28,11 +28,14 @@ sys.path.insert(0, str(project_root))
from flask import Flask
from test._api_v3_test_helpers import mock_plugin_catalog
_API_V3_MOCKED_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager',
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
'health_tracker', 'resource_monitor',
)
@@ -58,9 +61,8 @@ def _make_client():
api_v3.config_manager = MagicMock()
api_v3.config_manager.get_raw_file_content.return_value = {}
api_v3.config_manager.secrets_path = "/tmp/nonexistent_secrets.json"
api_v3.plugin_manager = MagicMock()
api_v3.plugin_manager.plugins = {}
api_v3.plugin_manager.plugins_dir = "/tmp"
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 = {}
@@ -202,22 +204,20 @@ class TestTransactionalUninstall(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.api_v3.plugin_store_manager.uninstall_plugin.assert_called_once_with('thing')
def test_file_removal_failure_reloads_previously_loaded_plugin(self):
"""Regression: rollback must restore BOTH config AND runtime state.
def test_file_removal_failure_rolls_back_config_only(self):
"""The web process has no running plugin to unload or reload.
If the plugin was loaded at runtime before the uninstall
request, and file removal fails after unload has already
succeeded, the rollback must call ``load_plugin`` so the user
doesn't end up in a state where the files exist and the config
exists but the plugin is no longer loaded.
The display runs the plugin, and unloads it when the removed config
section reaches its config watcher. A failed file removal restores
that section, so the display sees no change at all; there is nothing
in this process to put back. (The catalog mock has no
unload_plugin/load_plugin: calling either fails with a different
message than the one asserted.)
"""
# Plugin is currently loaded.
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
self.api_v3.config_manager.get_raw_file_content.return_value = {
'thing': {'enabled': True}
}
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
self.api_v3.plugin_manager.unload_plugin.return_value = None
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = False
response = self.client.post(
@@ -227,11 +227,10 @@ class TestTransactionalUninstall(unittest.TestCase):
)
self.assertEqual(response.status_code, 500)
# Unload did happen (it's part of the uninstall sequence)...
self.api_v3.plugin_manager.unload_plugin.assert_called_once_with('thing')
# ...and because file removal failed, the rollback must have
# called load_plugin to restore runtime state.
self.api_v3.plugin_manager.load_plugin.assert_called_once_with('thing')
self.assertIn('Failed to uninstall plugin thing', response.get_json()['message'])
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
self.assertTrue(any(c.args[0] == 'main' for c in calls),
"main config was not restored after the removal failed")
def test_snapshot_survives_config_read_error(self):
"""Regression: if get_raw_file_content raises an expected error
@@ -283,33 +282,6 @@ class TestTransactionalUninstall(unittest.TestCase):
# exception bubbled up before we got that far.
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
def test_unload_failure_restores_config_and_does_not_call_uninstall(self):
"""If unload_plugin itself raises, config must be restored and
uninstall_plugin must NOT be called."""
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
self.api_v3.config_manager.get_raw_file_content.return_value = {
'thing': {'enabled': True}
}
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
self.api_v3.plugin_manager.unload_plugin.side_effect = RuntimeError("unload boom")
response = self.client.post(
'/api/v3/plugins/uninstall',
data=json.dumps({'plugin_id': 'thing'}),
content_type='application/json',
)
self.assertEqual(response.status_code, 500)
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
# Config should have been restored.
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
self.assertTrue(
any(c.args[0] == 'main' for c in calls),
"main config was not restored after unload_plugin raised",
)
# load_plugin must NOT have been called — unload didn't succeed,
# so runtime state is still what it was.
self.api_v3.plugin_manager.load_plugin.assert_not_called()
class TestReconcileEndpointPayload(unittest.TestCase):
@@ -402,7 +374,7 @@ class TestNonPluginIdsAreRefused(unittest.TestCase):
self.client, self.mod, _cleanup = _make_client()
self.addCleanup(_cleanup)
self.api_v3 = self.mod.api_v3
self.api_v3.plugin_manager.plugin_manifests = {'thing': {'id': 'thing'}}
self.api_v3.plugin_catalog.plugin_manifests = {'thing': {'id': 'thing'}}
def _post(self, url, body):
return self.client.post(url, data=json.dumps(body),
@@ -427,7 +399,7 @@ class TestNonPluginIdsAreRefused(unittest.TestCase):
'gone-plugin', remove_secrets=True)
def test_secrets_only_core_key_is_allowed_when_a_plugin_has_that_id(self):
self.api_v3.plugin_manager.plugin_manifests = {'youtube': {'id': 'youtube'}}
self.api_v3.plugin_catalog.plugin_manifests = {'youtube': {'id': 'youtube'}}
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
response = self._post('/api/v3/plugins/uninstall', {'plugin_id': 'youtube'})
+33 -15
View File
@@ -434,28 +434,46 @@ class TestSchema:
class TestInstalledPluginsApi:
"""The web process runs no plugin code (it has a PluginCatalog, not a
PluginManager), so it reports what the files say -- the user's setting,
then the manifest -- and leaves the rest to the display."""
@pytest.fixture
def installed(self, api_v3_module, api_v3_client, tmp_path):
def _get(instance, config):
def installed(self, api_v3_module, api_v3_client):
def _get(config, manifest_extra=None):
api = api_v3_module.api_v3
info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': True}
api.plugin_manager.plugins_dir = str(tmp_path)
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_manager.get_plugin = MagicMock(return_value=instance)
info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0',
**(manifest_extra or {})}
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_catalog.get_plugin_directory = MagicMock(return_value=None)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={'demo': config})
response = api_v3_client.get('/api/v3/plugins/installed')
assert response.status_code == 200
return [p for p in response.get_json()['data']['plugins']
if p['id'] == 'demo'][0]
entry = [p for p in response.get_json()['data']['plugins']
if p['id'] == 'demo'][0]
return entry['vegas_participation'], entry['vegas_participation_source']
return _get
def test_a_loaded_plugin_reports_its_participation(self, installed):
plugin = _plugin({'vegas_mode': 'static'})
assert installed(plugin, plugin.config)['vegas_participation'] == 'pause'
def test_the_user_setting_wins(self, installed):
assert installed({'vegas_participation': 'exclude'},
{'vegas_participation': 'pause'}) == ('exclude', 'config')
def test_an_unloaded_plugin_reports_only_the_user_setting(self, installed):
assert installed(None, {'vegas_participation': 'exclude'})[
'vegas_participation'] == 'exclude'
assert installed(None, {})['vegas_participation'] is None
def test_then_the_manifest_declaration(self, installed):
assert installed({}, {'vegas_participation': 'Pause'}) == ('pause', 'manifest')
# An invalid setting is ignored, as the display ignores it.
assert installed({'vegas_participation': 'fixed'},
{'vegas_participation': 'scroll'}) == ('scroll', 'manifest')
def test_otherwise_it_is_decided_at_run_time(self, installed):
# The legacy hooks would say 'pause' for vegas_mode 'static', but
# only the display runs them: nothing is guessed here.
assert installed({'vegas_mode': 'static'}) == (None, 'runtime')
assert installed({}, {'vegas_participation': 'sometimes'}) == (None, 'runtime')
def test_it_matches_the_display_where_the_files_decide(self, installed):
for config, manifest in (({'vegas_participation': 'pause'}, None),
({}, {'vegas_participation': 'exclude'})):
plugin = _plugin(config, manifest)
assert installed(config, manifest)[0] == resolve_vegas_participation(plugin)
+49 -53
View File
@@ -43,18 +43,15 @@ def mock_config_manager():
@pytest.fixture
def mock_plugin_manager():
"""Create a mock plugin manager."""
def mock_plugin_catalog():
"""Create a mock plugin catalog."""
mock = MagicMock()
mock.plugins = {}
mock.discover_plugins.return_value = []
mock.health_tracker = MagicMock()
mock.health_tracker.get_health_status.return_value = {'healthy': True}
return mock
@pytest.fixture
def client(mock_config_manager, mock_plugin_manager):
def client(mock_config_manager, mock_plugin_catalog):
"""Create a Flask test client with mocked dependencies."""
# Create a minimal Flask app for testing
test_app = Flask(__name__)
@@ -66,7 +63,7 @@ def client(mock_config_manager, mock_plugin_manager):
# Mock the managers on the blueprint
api_v3.config_manager = mock_config_manager
api_v3.plugin_manager = mock_plugin_manager
api_v3.plugin_catalog = mock_plugin_catalog
api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock()
api_v3.schema_manager = MagicMock()
@@ -74,7 +71,10 @@ def client(mock_config_manager, mock_plugin_manager):
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).
api_v3.health_tracker = MagicMock()
api_v3.resource_monitor = MagicMock()
# Setup operation queue mocks
mock_operation = MagicMock()
mock_operation.operation_id = 'test-op-123'
@@ -128,7 +128,7 @@ class TestConfigAPI:
mock_config_manager.save_config_atomic.assert_called_once()
def test_save_main_config_fails_closed_when_schema_path_is_unresolvable(
self, client, mock_config_manager, mock_plugin_manager
self, client, mock_config_manager, mock_plugin_catalog
):
"""A plugin id whose schema path fails safe-resolution must not have
its config saved with secret_fields left empty.
@@ -140,7 +140,7 @@ class TestConfigAPI:
the plugin's submitted config -- credentials included -- as
ordinary, unencrypted configuration instead of refusing the request.
"""
mock_plugin_manager.plugin_manifests = {'evil': {}}
mock_plugin_catalog.plugin_manifests = {'evil': {}}
with patch('web_interface.blueprints.api_v3.config.resolve_under', return_value=None):
response = client.post(
@@ -545,38 +545,35 @@ class TestDisplayAPI:
class TestPluginsAPI:
"""Test plugins API endpoints."""
def test_get_installed_plugins(self, client, mock_plugin_manager):
def test_get_installed_plugins(self, client, mock_plugin_catalog):
"""Test getting list of installed plugins."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugins = {
'weather': MagicMock(plugin_id='weather'),
'clock': MagicMock(plugin_id='clock')
}
mock_plugin_manager.get_plugin_metadata.return_value = {
'id': 'weather',
'name': 'Weather Plugin'
}
api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_catalog.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_catalog.get_plugin_directory.return_value = None
mock_plugin_catalog.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather Plugin'}
]
api_v3.plugin_store_manager.get_registry_info.return_value = None
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, (list, dict))
names = [p['name'] for p in data['data']['plugins']]
assert 'Weather Plugin' in names
def test_installed_plugins_report_update_available(self, client, mock_plugin_manager):
def test_installed_plugins_report_update_available(self, client, mock_plugin_catalog):
"""Installed-plugin entries surface latest_version + update_available
by comparing the on-disk manifest version to the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
api_v3.plugin_catalog = mock_plugin_catalog
# No on-disk manifest to merge — keep the version we hand in below.
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
mock_plugin_catalog.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_catalog.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
]
# Avoid touching plugin instances (Vegas hooks, enabled fallback).
mock_plugin_manager.get_plugin.return_value = None
# Registry advertises a newer version than the installed one.
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
@@ -591,15 +588,14 @@ class TestPluginsAPI:
assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is True
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager):
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_catalog):
"""No update is flagged when installed version matches the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_catalog.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_catalog.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
]
mock_plugin_manager.get_plugin.return_value = None
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
@@ -625,17 +621,17 @@ class TestPluginsAPI:
# mismatch rather than hiding a possible update.
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
def test_get_plugin_health(self, client, mock_plugin_manager):
def test_get_plugin_health(self, client, mock_plugin_catalog):
"""Test getting plugin health information."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
api_v3.plugin_catalog = mock_plugin_catalog
# Setup health tracker
mock_health_tracker = MagicMock()
mock_health_tracker.get_all_health_summaries.return_value = {
'weather': {'healthy': True}
}
mock_plugin_manager.health_tracker = mock_health_tracker
api_v3.health_tracker = mock_health_tracker
response = client.get('/api/v3/plugins/health')
@@ -643,10 +639,10 @@ class TestPluginsAPI:
data = json.loads(response.data)
assert isinstance(data, (list, dict))
def test_get_plugin_health_single(self, client, mock_plugin_manager):
def test_get_plugin_health_single(self, client, mock_plugin_catalog):
"""Test getting health for single plugin."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
api_v3.plugin_catalog = mock_plugin_catalog
# Setup health tracker with proper method (endpoint calls get_health_summary)
mock_health_tracker = MagicMock()
@@ -655,7 +651,7 @@ class TestPluginsAPI:
'failures': 0,
'last_success': '2024-01-01T00:00:00'
}
mock_plugin_manager.health_tracker = mock_health_tracker
api_v3.health_tracker = mock_health_tracker
response = client.get('/api/v3/plugins/health/weather')
@@ -663,16 +659,16 @@ class TestPluginsAPI:
data = json.loads(response.data)
assert 'healthy' in data.get('data', {}) or 'data' in data
def test_toggle_plugin(self, client, mock_config_manager, mock_plugin_manager):
def test_toggle_plugin(self, client, mock_config_manager, mock_plugin_catalog):
"""Test toggling plugin enabled state."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
api_v3.plugin_manager = mock_plugin_manager
api_v3.plugin_catalog = mock_plugin_catalog
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
# Setup plugin manifests
mock_plugin_manager.plugin_manifests = {'weather': {}}
mock_plugin_catalog.plugin_manifests = {'weather': {}}
request_data = {
'plugin_id': 'weather',
@@ -1006,15 +1002,15 @@ class TestPluginHealthRoutes:
"""Phase 1: /plugins/health and /plugins/metrics build per-installed-id so
they surface cross-process data persisted by the display service."""
def test_health_route_builds_per_installed_id(self, client, mock_plugin_manager):
def test_health_route_builds_per_installed_id(self, client, mock_plugin_catalog):
from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.plugin_health import PluginHealthTracker
cache = MagicMock()
cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugin_manifests = {'p1': {}, 'p2': {}}
mock_plugin_manager.health_tracker = PluginHealthTracker(cache)
api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_catalog.plugin_manifests = {'p1': {}, 'p2': {}}
api_v3.health_tracker = PluginHealthTracker(cache)
resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200
@@ -1023,10 +1019,10 @@ class TestPluginHealthRoutes:
assert data['p1']['is_healthy'] is True
assert data['p1']['degraded'] is False
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_manager):
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_catalog):
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.health_tracker = None
api_v3.plugin_catalog = mock_plugin_catalog
api_v3.health_tracker = None
resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200
@@ -1034,15 +1030,15 @@ class TestPluginHealthRoutes:
assert body['data'] == {}
assert 'not available' in body['message'].lower()
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_manager):
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_catalog):
from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.resource_monitor import PluginResourceMonitor
cache = MagicMock()
cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugin_manifests = {'p1': {}}
mock_plugin_manager.resource_monitor = PluginResourceMonitor(
api_v3.plugin_catalog = mock_plugin_catalog
mock_plugin_catalog.plugin_manifests = {'p1': {}}
api_v3.resource_monitor = PluginResourceMonitor(
cache, enable_monitoring=False
)
+4 -8
View File
@@ -46,8 +46,8 @@ def prefixed_plugin(tmp_path, api_v3_module):
}), encoding="utf-8")
api = api_v3_module.api_v3
api.plugin_manager.plugins_dir = str(plugins_dir)
api.plugin_manager.get_plugin_directory = MagicMock(side_effect=_resolver(plugins_dir))
api.plugin_catalog.plugins_dir = str(plugins_dir)
api.plugin_catalog.get_plugin_directory = MagicMock(side_effect=_resolver(plugins_dir))
api.plugin_store_manager.plugins_dir = str(plugins_dir)
return plugin_dir
@@ -58,8 +58,7 @@ class TestInstalledList:
api = api_v3_module.api_v3
info = {"id": "demo", "name": "Demo", "version": "1.0.0",
"description": "stale cached copy", "loaded": False}
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_manager.get_plugin = MagicMock(return_value=None)
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
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={})
@@ -87,7 +86,6 @@ class TestUpdateRoute:
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
api.plugin_store_manager.update_plugin = MagicMock(return_value=True)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.plugin_manager.plugins = {}
api.schema_manager = None
api.plugin_state_manager = None
api.operation_history = None
@@ -121,7 +119,6 @@ class TestUpdateRoute:
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
api.plugin_store_manager.update_plugin = MagicMock(return_value=False)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.plugin_manager.plugins = {}
api.operation_history = None
response = api_v3_client.post("/api/v3/plugins/update", json={"plugin_id": "ghost"})
@@ -140,8 +137,7 @@ def pages(tmp_path, monkeypatch):
plugins_dir.mkdir()
plugin_manager = MagicMock()
plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "plugin_catalog", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "config_manager",
MagicMock(load_config=lambda: {}), raising=False)
monkeypatch.setattr(module.pages_v3, "schema_manager", None, raising=False)
+1 -1
View File
@@ -78,7 +78,7 @@ def client():
mock_cm.get_config_path.return_value = "config/config.json"
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
pv.pages_v3.config_manager = mock_cm
pv.pages_v3.plugin_manager = MagicMock(plugins={})
pv.pages_v3.plugin_catalog = MagicMock()
app.register_blueprint(pv.pages_v3, url_prefix="/v3")
return app.test_client()
+3 -4
View File
@@ -82,7 +82,7 @@ def client():
# the originals and restore them on teardown so this fixture can't leak
# its mocks into tests that run afterward.
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)
original_plugin_manager = getattr(pv.pages_v3, "plugin_catalog", None)
mock_cm = MagicMock()
mock_cm.load_config.return_value = SMOKE_CONFIG
@@ -92,7 +92,6 @@ def client():
pv.pages_v3.config_manager = mock_cm
mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = [
{"id": "clock", "name": "Clock"},
{"id": "ledmatrix-weather", "name": "Weather"},
@@ -100,7 +99,7 @@ def client():
mock_pm.get_plugin_display_modes.side_effect = (
lambda pid: PLUGIN_MODES.get(pid, [])
)
pv.pages_v3.plugin_manager = mock_pm
pv.pages_v3.plugin_catalog = mock_pm
# Same dual registration as web_interface/app.py: un-prefixed primary,
# /v3 kept as a working legacy alias.
@@ -110,7 +109,7 @@ def client():
yield app.test_client()
finally:
pv.pages_v3.config_manager = original_config_manager
pv.pages_v3.plugin_manager = original_plugin_manager
pv.pages_v3.plugin_catalog = original_plugin_manager
# (path, [markers that must appear in the body])
@@ -29,7 +29,7 @@ from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
_MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager',
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
@@ -32,7 +32,7 @@ from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
URL = "/api/v3/backup/restore"
_MANAGER_ATTRS = (
'config_manager', 'plugin_manager', 'plugin_store_manager',
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
+1 -1
View File
@@ -43,7 +43,7 @@ def env(tmp_path):
config_manager.template_path = str(tmp_path / "no-template.json")
_SENTINEL = object()
attrs = ('config_manager', 'plugin_manager', 'plugin_store_manager',
attrs = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager',
'schema_manager', 'operation_queue', 'operation_history',
'cache_manager')
@@ -93,13 +93,12 @@ def env(tmp_path, api_v3_module):
api = api_v3_module.api_v3
api.config_manager = config_manager
api.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api.plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
api.plugin_catalog.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
NEWS_ID: {"id": NEWS_ID}}
api.plugin_manager.get_plugin.return_value = None
class Env:
client = build_app(api).test_client()
plugin_manager = api.plugin_manager
plugin_catalog = api.plugin_catalog
@staticmethod
def stored(plugin_id=PLUGIN_ID):
@@ -183,19 +182,18 @@ class TestReset:
assert calls == [True]
assert env.stored()["stock_symbols"] == ["AAPL"]
def test_reset_notifies_the_plugin_with_its_prepared_config(self, env):
plugin = MagicMock()
env.plugin_manager.get_plugin.return_value = plugin
env.plugin_manager.prepare_plugin_config.side_effect = (
lambda _pid, raw: {**raw, "prepared": True})
def test_reset_runs_no_plugin_code_in_the_web_process(self, env):
# The running plugin gets the reset config from the display's config
# watcher (on_config_change there, with the prepared section). The
# catalog has no get_plugin, so a route that still reached for a
# plugin instance here would fail this request.
assert not hasattr(env.plugin_catalog, "get_plugin")
response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID})
assert response.status_code == 200, response.get_json()
handed_over = plugin.on_config_change.call_args.args[0]
assert handed_over["prepared"] is True
assert handed_over["stock_symbols"] == ["AAPL"]
assert env.stored()["stock_symbols"] == ["AAPL"]
def test_a_failed_save_is_reported(self, env, monkeypatch):
failed = MagicMock(message="disk full")
@@ -79,11 +79,10 @@ def env(tmp_path):
plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None
api_v3.config_manager = config_manager
api_v3.schema_manager = schema_manager
api_v3.plugin_manager = plugin_manager
api_v3.plugin_catalog = plugin_manager
api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock()
api_v3.operation_queue = MagicMock()
@@ -39,7 +39,7 @@ EXPECTED = {
'details': describe_exception(FORCED),
}
MANAGERS = ("config_manager", "plugin_manager", "plugin_store_manager",
MANAGERS = ("config_manager", "plugin_catalog", "plugin_store_manager",
"saved_repositories_manager", "schema_manager", "operation_queue",
"plugin_state_manager", "operation_history", "cache_manager")
@@ -222,7 +222,7 @@ class TestPluginActionStep1:
from unittest.mock import MagicMock
manager = MagicMock()
manager.get_plugin_directory.return_value = str(plugin_dir)
monkeypatch.setattr(api_v3, "plugin_manager", manager, raising=False)
monkeypatch.setattr(api_v3, "plugin_catalog", manager, raising=False)
app = Flask(__name__)
app.register_blueprint(api_v3, url_prefix="/api/v3")
@@ -113,7 +113,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
@@ -137,9 +137,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
a.api_v3.plugin_catalog = pm
app = Flask(__name__)
app.config["TESTING"] = True
@@ -99,7 +99,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
@@ -122,9 +122,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
a.api_v3.plugin_catalog = pm
app = Flask(__name__)
app.config["TESTING"] = True
@@ -115,19 +115,17 @@ def app_client(tmp_path):
cm.save_config_atomic.side_effect = _save
pm = MagicMock()
pm.plugins = {}
pm.plugins_dir = plugins_dir
pm.get_plugin.return_value = None
pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
names = ("config_manager", "schema_manager", "plugin_manager")
names = ("config_manager", "schema_manager", "plugin_catalog")
originals = {(bp, k): getattr(bp, k, None)
for bp in (api.api_v3, pages.pages_v3) for k in names}
for bp in (api.api_v3, pages.pages_v3):
bp.config_manager = cm
bp.schema_manager = sm
bp.plugin_manager = pm
bp.plugin_catalog = pm
base = Path(pages.__file__).resolve().parent.parent
app = Flask(__name__, template_folder=str(base / "templates"),
@@ -72,7 +72,7 @@ STORED = {
"skin_options": {"accent": "#00ff00"},
}
_ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager',
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager')
@@ -96,13 +96,12 @@ def env(tmp_path):
plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin.return_value = None
for name in _ATTRS:
setattr(api_v3, name, MagicMock())
api_v3.config_manager = config_manager
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api_v3.plugin_manager = plugin_manager
api_v3.plugin_catalog = plugin_manager
api_v3.operation_queue = None
app = Flask(__name__)
@@ -110,7 +110,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
@@ -134,9 +134,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
a.api_v3.plugin_catalog = pm
app = Flask(__name__)
app.config["TESTING"] = True
@@ -71,7 +71,7 @@ def render(tmp_path):
from web_interface.blueprints import pages_v3 as pv
from src.plugin_system.schema_manager import SchemaManager
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
orig_pm = getattr(pv.pages_v3, "plugin_catalog", None)
orig_cm = getattr(pv.pages_v3, "config_manager", None)
orig_sm = getattr(pv.pages_v3, "schema_manager", None)
@@ -86,8 +86,7 @@ def render(tmp_path):
pm = MagicMock()
pm.plugins_dir = str(tmp_path / "plugins")
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
pm.get_plugin.return_value = None
pv.pages_v3.plugin_manager = pm
pv.pages_v3.plugin_catalog = pm
cm = MagicMock()
cm.load_config.return_value = {plugin_id: {"enabled": True}}
@@ -111,7 +110,7 @@ def render(tmp_path):
try:
yield _render
finally:
pv.pages_v3.plugin_manager = orig_pm
pv.pages_v3.plugin_catalog = orig_pm
pv.pages_v3.config_manager = orig_cm
pv.pages_v3.schema_manager = orig_sm
@@ -52,7 +52,7 @@ def make_client(tmp_path):
"""
from web_interface.blueprints import pages_v3 as pv
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
original_pm = getattr(pv.pages_v3, "plugin_catalog", None)
def _build(plugins_dir=None, plugin_manager=_UNSET):
base = PROJECT_ROOT / "web_interface"
@@ -64,7 +64,7 @@ def make_client(tmp_path):
if plugin_manager is _UNSET:
plugin_manager = MagicMock()
plugin_manager.plugins_dir = str(plugins_dir or tmp_path)
pv.pages_v3.plugin_manager = plugin_manager
pv.pages_v3.plugin_catalog = plugin_manager
app.register_blueprint(pv.pages_v3, url_prefix="")
return app.test_client()
@@ -72,7 +72,7 @@ def make_client(tmp_path):
try:
yield _build
finally:
pv.pages_v3.plugin_manager = original_pm
pv.pages_v3.plugin_catalog = original_pm
URL = "/static/plugin-widgets/{}/{}.js"
@@ -211,7 +211,7 @@ def config_form(tmp_path):
"""Render a plugin's config partial with a temp plugin on disk."""
from web_interface.blueprints import pages_v3 as pv
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
orig_pm = getattr(pv.pages_v3, "plugin_catalog", None)
orig_cm = getattr(pv.pages_v3, "config_manager", None)
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None,
@@ -226,8 +226,7 @@ def config_form(tmp_path):
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
if version is not None:
pm.get_plugin_info.return_value["version"] = version
pm.get_plugin.return_value = None
pv.pages_v3.plugin_manager = pm
pv.pages_v3.plugin_catalog = pm
cm = MagicMock()
cm.load_config.return_value = {plugin_id: {"enabled": True}}
@@ -244,7 +243,7 @@ def config_form(tmp_path):
try:
yield _render
finally:
pv.pages_v3.plugin_manager = orig_pm
pv.pages_v3.plugin_catalog = orig_pm
pv.pages_v3.config_manager = orig_cm
@@ -64,7 +64,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
@@ -86,9 +86,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
a.api_v3.plugin_catalog = pm
app = Flask(__name__)
app.config["TESTING"] = True
@@ -62,7 +62,7 @@ def post(tmp_path):
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
@@ -85,9 +85,7 @@ def post(tmp_path):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
a.api_v3.plugin_catalog = pm
app = Flask(__name__)
app.config["TESTING"] = True
@@ -41,7 +41,7 @@ def store(tmp_path):
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_manager', None, 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):
@@ -0,0 +1,460 @@
"""The web process reads plugins; only the display process runs them.
The web interface used to build its own PluginManager and load plugins into
the web process: 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 -- the
display process runs its own instances -- and the web then reported
"runtime" state from copies nothing displayed. An update in particular
looked applied while the display kept running the old code until it
restarted, and nothing said so.
Now the web process has a PluginCatalog (manifests, schemas, config,
installed versions) and nothing that can run a plugin:
- every route a user drives for a plugin works without importing the
plugin's module at all -- the plugin below records any import and any
lifecycle call to a file, and the file must never appear;
- the catalog reads what is really installed, in plugin-repos/ and in the
test fixtures;
- a store install, update or uninstall answers ``restart_required`` exactly
when the running display will not pick the change up by itself.
"""
import json
import shutil
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.plugin_catalog import ( # noqa: E402
PluginCatalog, display_restart_required,
)
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from test._api_v3_test_helpers import api_v3_module # noqa: F401,E402
PLUGIN_ID = "tripwire"
# A plugin that leaves evidence of being run: importing its module, building
# it, or calling any lifecycle hook appends a line to TRIPWIRE_LOG.
MANAGER_PY = '''
import os
LOG = os.environ.get("TRIPWIRE_LOG") or {log!r}
def _note(what):
with open(LOG, "a", encoding="utf-8") as f:
f.write(what + "\\n")
_note("imported")
from src.plugin_system.base_plugin import BasePlugin
class TripwirePlugin(BasePlugin):
def __init__(self, *args, **kwargs):
_note("instantiated")
super().__init__(*args, **kwargs)
def update(self):
_note("update")
def display(self, force_clear=False):
_note("display")
def on_config_change(self, new_config):
_note("on_config_change")
def on_enable(self):
_note("on_enable")
def on_disable(self):
_note("on_disable")
'''
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"message": {"type": "string", "default": "hello"},
},
}
def _write_plugin(plugins_dir, log, version="1.0.0", plugin_id=PLUGIN_ID):
plugin_dir = plugins_dir / plugin_id
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": plugin_id, "name": "Tripwire", "version": version,
"entry_point": "manager.py", "class_name": "TripwirePlugin",
"display_modes": ["tripwire"],
}), encoding="utf-8")
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
(plugin_dir / "manager.py").write_text(MANAGER_PY.format(log=str(log)), encoding="utf-8")
return plugin_dir
class Web:
"""The api_v3 and pages_v3 blueprints over a real catalog and config."""
def __init__(self, api_v3_module, tmp_path, monkeypatch, enabled=True):
self.log = tmp_path / "tripwire.log"
monkeypatch.setenv("TRIPWIRE_LOG", str(self.log))
self.plugins_dir = tmp_path / "plugin-repos"
self.plugins_dir.mkdir()
_write_plugin(self.plugins_dir, self.log)
self.config_file = tmp_path / "config.json"
self.config_file.write_text(json.dumps(
{PLUGIN_ID: {"enabled": enabled, "message": "hi"}}), encoding="utf-8")
self.config_manager = ConfigManager(
config_path=str(self.config_file),
secrets_path=str(tmp_path / "config_secrets.json"))
self.config_manager.template_path = str(tmp_path / "no-template.json")
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, project_root=tmp_path,
config_manager=self.config_manager)
self.catalog = PluginCatalog(self.plugins_dir, self.config_manager, self.schema_manager)
api = self.api = api_v3_module.api_v3
api.config_manager = self.config_manager
api.schema_manager = self.schema_manager
api.plugin_catalog = self.catalog
store = api.plugin_store_manager
store.plugins_dir = str(self.plugins_dir)
store.get_registry_info.return_value = None
store.get_plugin_info.return_value = None
store._get_local_git_info.return_value = None
store.install_plugin.return_value = True
store.uninstall_plugin.return_value = True
store.update_plugin.return_value = True
from web_interface.blueprints import pages_v3 as pages_module
pages = pages_module.pages_v3
for name, value in (("config_manager", self.config_manager),
("schema_manager", self.schema_manager),
("plugin_catalog", self.catalog)):
monkeypatch.setattr(pages, name, value, raising=False)
app = Flask(__name__, template_folder=str(PROJECT_ROOT / "web_interface" / "templates"))
app.config["TESTING"] = True
app.register_blueprint(api, url_prefix="/api/v3")
app.register_blueprint(pages, url_prefix="")
self.client = app.test_client()
def ran(self):
"""What the plugin recorded: [] when none of its code ever ran."""
return self.log.read_text(encoding="utf-8").split() if self.log.exists() else []
def stored(self):
return json.loads(self.config_file.read_text(encoding="utf-8")).get(PLUGIN_ID, {})
def post(self, url, body):
response = self.client.post(url, json=body)
assert response.status_code == 200, (url, response.get_json())
return response.get_json()
@pytest.fixture
def web(api_v3_module, tmp_path, monkeypatch):
return Web(api_v3_module, tmp_path, monkeypatch)
@pytest.fixture
def disabled_web(api_v3_module, tmp_path, monkeypatch):
return Web(api_v3_module, tmp_path, monkeypatch, enabled=False)
def _bump_version(web, version):
"""What a store update does to the files on disk."""
def update(plugin_id):
_write_plugin(web.plugins_dir, web.log, version=version, plugin_id=plugin_id)
return True
web.api.plugin_store_manager.update_plugin.side_effect = update
class TestTheWebProcessNeverRunsAPlugin:
"""Every plugin route, against a plugin that reports being run."""
def test_the_tripwire_works(self, web, tmp_path):
# Sanity: importing the module does leave the mark this suite checks for.
import importlib.util
spec = importlib.util.spec_from_file_location(
"tripwire_probe", web.plugins_dir / PLUGIN_ID / "manager.py")
spec.loader.exec_module(importlib.util.module_from_spec(spec))
assert web.ran() == ["imported"]
def test_listing_the_installed_plugins(self, web):
body = web.client.get("/api/v3/plugins/installed").get_json()
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.
assert entry["loaded"] is None and entry["state"] is None
# 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"]) == (
None, "runtime")
assert web.ran() == []
def test_enabling_and_disabling(self, web):
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": False})
assert web.stored()["enabled"] is False
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": True})
assert web.stored()["enabled"] is True
assert web.ran() == []
def test_saving_its_config(self, web):
web.post("/api/v3/plugins/config",
{"plugin_id": PLUGIN_ID, "config": {"message": "changed"}})
assert web.stored()["message"] == "changed"
assert web.ran() == []
def test_saving_its_section_through_the_main_config(self, web):
body = web.post("/api/v3/config/main", {PLUGIN_ID: {"message": "via main"}})
assert web.stored()["message"] == "via main"
assert body["restart_required"] is True
assert web.ran() == []
def test_resetting_its_config(self, web):
web.post("/api/v3/plugins/config/reset", {"plugin_id": PLUGIN_ID})
assert web.stored()["message"] == "hello"
assert web.ran() == []
def test_rendering_its_settings_page(self, web):
response = web.client.get(f"/partials/plugin-config/{PLUGIN_ID}")
assert response.status_code == 200
assert "Tripwire" in response.get_data(as_text=True)
assert web.ran() == []
def test_updating_it(self, web):
_bump_version(web, "1.1.0")
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["data"]["update_status"] == "updated"
assert web.catalog.get_installed_version(PLUGIN_ID) == "1.1.0"
assert web.ran() == []
def test_installing_it(self, web):
web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert PLUGIN_ID in web.catalog.plugin_manifests
assert web.ran() == []
def test_uninstalling_it(self, web):
web.post("/api/v3/plugins/uninstall", {"plugin_id": PLUGIN_ID})
web.api.plugin_store_manager.uninstall_plugin.assert_called_once_with(PLUGIN_ID)
assert web.ran() == []
def test_no_plugin_module_is_registered_in_this_process(self, web):
web.client.get("/api/v3/plugins/installed")
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": True})
assert not [name for name, mod in list(sys.modules.items())
if str(web.plugins_dir) in str(getattr(mod, "__file__", "") or "")]
def test_the_web_app_has_a_catalog_and_no_plugin_manager(self):
"""app.py wires a PluginCatalog, and nothing named like a manager."""
source = (PROJECT_ROOT / "web_interface" / "app.py").read_text(encoding="utf-8")
assert "PluginCatalog(" in source
assert "PluginManager" not in source
class TestNoLifecycleCallsInWebCode:
"""A static backstop for the behavioural tests above: the web process's
own code has no call that runs a plugin. Comments may mention the hooks;
calls may not."""
FORBIDDEN = (".on_enable(", ".on_disable(", ".on_config_change(",
".load_plugin(", ".unload_plugin(", ".reload_plugin(",
".get_plugin(", ".update(force", ".display(force_clear",
# Vegas participation past config and manifest calls the
# plugin's hooks; the installed route reports it from files.
"resolve_vegas_participation(", "legacy_vegas_participation(",
".get_vegas_participation(")
def _code_lines(self):
for path in sorted((PROJECT_ROOT / "web_interface").rglob("*.py")):
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
code = line.split("#", 1)[0]
yield path.relative_to(PROJECT_ROOT), number, code
def test_no_web_module_calls_a_plugin_lifecycle_hook(self):
offenders = [f"{path}:{number}: {code.strip()}"
for path, number, code in self._code_lines()
if any(call in code for call in self.FORBIDDEN)]
assert offenders == []
def test_plugin_code_is_imported_in_exactly_one_place(self):
"""exec_module in the web blueprints happens only in the seam named
for it (Starlark helpers and oauth_flow action scripts)."""
blueprints = PROJECT_ROOT / "web_interface" / "blueprints"
hits = [f"{path.name}:{n}" for path in sorted(blueprints.rglob("*.py"))
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if "exec_module(" in line.split("#", 1)[0]]
assert len(hits) == 1, hits
source = (blueprints / "api_v3" / "__init__.py").read_text(encoding="utf-8")
seam = source.index("def _import_plugin_code_in_web_process(")
assert source.index("exec_module(", seam) < source.index("\ndef ", seam + 1)
class TestStoreOperationsReachTheDisplay:
"""Each answer says whether the display needs a restart to show it."""
def test_an_update_of_a_plugin_the_display_runs_asks_for_a_restart(self, web):
_bump_version(web, "2.0.0")
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is True
assert "restart the display" in body["restart_message"]
def test_an_update_of_a_disabled_plugin_does_not(self, disabled_web):
_bump_version(disabled_web, "2.0.0")
body = disabled_web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["data"]["update_status"] == "updated"
assert body["restart_required"] is False
assert "restart_message" not in body
def test_an_update_that_changed_nothing_does_not(self, web):
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["data"]["update_status"] == "up_to_date"
assert body["restart_required"] is False
def test_installing_a_plugin_config_already_enables_asks_for_a_restart(self, web):
body = web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is True
def test_installing_a_disabled_plugin_does_not(self, disabled_web):
# Enabling it later loads it live, from disk, in the display.
body = disabled_web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is False
def test_the_queued_install_carries_the_flag_in_the_operation_result(self, web):
results = []
def enqueue(operation_type, plugin_id, operation_callback=None):
results.append(operation_callback(MagicMock()))
return "op-1"
web.api.operation_queue = MagicMock()
web.api.operation_queue.enqueue_operation.side_effect = enqueue
web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
assert results[0]["success"] is True
assert results[0]["restart_required"] is True
def test_install_from_url_carries_the_flag(self, web):
web.api.plugin_store_manager.install_from_url.return_value = {
"success": True, "plugin_id": PLUGIN_ID}
body = web.post("/api/v3/plugins/install-from-url", {"repo_url": "https://example.invalid/r"})
assert body["restart_required"] is True
def test_uninstall_that_removes_the_config_needs_no_restart(self, web):
# The removed section reaches the display's config watcher, and its
# reconcile unloads the plugin.
body = web.post("/api/v3/plugins/uninstall", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is False
assert PLUGIN_ID not in json.loads(web.config_file.read_text(encoding="utf-8"))
def test_uninstall_that_keeps_an_enabled_config_asks_for_a_restart(self, web):
body = web.post("/api/v3/plugins/uninstall",
{"plugin_id": PLUGIN_ID, "preserve_config": True})
assert body["restart_required"] is True
@pytest.mark.parametrize("action,enabled,kwargs,expected", [
("install", False, {}, False),
("install", True, {}, True),
("update", True, {"changed": True}, True),
("update", True, {"changed": False}, False),
("update", False, {"changed": True}, False),
("uninstall", True, {}, False),
("uninstall", True, {"preserve_config": True}, True),
("uninstall", False, {"preserve_config": True}, False),
])
def test_the_rule(self, action, enabled, kwargs, expected):
assert display_restart_required(action, enabled, **kwargs) is expected
def test_an_unknown_action_is_a_programming_error(self):
with pytest.raises(ValueError):
display_restart_required("reinstall", True)
class TestCatalogReadsWhatIsInstalled:
"""Real plugin directories: the repository's plugin-repos/ and fixtures."""
@staticmethod
def _manifests(root):
found = {}
for child in sorted(Path(root).iterdir()):
manifest = child / "manifest.json"
if child.is_dir() and manifest.exists():
data = json.loads(manifest.read_text(encoding="utf-8"))
if isinstance(data, dict) and data.get("id"):
found[data["id"]] = (child, data)
return found
@pytest.mark.parametrize("root", [
PROJECT_ROOT / "plugin-repos",
PROJECT_ROOT / "test" / "fixtures" / "plugins",
], ids=["plugin-repos", "fixtures"])
def test_every_installed_plugin_is_read_from_its_files(self, root):
expected = self._manifests(root)
assert expected, f"no plugins under {root}"
before = set(sys.modules)
schema_manager = SchemaManager(plugins_dir=root, project_root=PROJECT_ROOT)
catalog = PluginCatalog(root, schema_manager=schema_manager)
assert set(catalog.discover_plugins()) == set(expected)
for plugin_id, (plugin_dir, manifest) in expected.items():
assert catalog.get_manifest(plugin_id) == manifest
assert catalog.get_plugin_directory(plugin_id) == str(plugin_dir)
assert catalog.get_installed_version(plugin_id) == manifest.get("version", "")
assert catalog.get_plugin_display_modes(plugin_id) == manifest.get("display_modes", [])
if (plugin_dir / "config_schema.json").exists():
schema = catalog.get_schema(plugin_id, use_cache=False)
assert isinstance(schema, dict) and "properties" in schema, plugin_id
imported = [name for name in set(sys.modules) - before
if str(root) in str(getattr(sys.modules[name], "__file__", "") or "")]
assert imported == [], "reading the catalog imported plugin code"
def test_a_mode_finds_its_plugin(self):
catalog = PluginCatalog(PROJECT_ROOT / "test" / "fixtures" / "plugins")
catalog.discover_plugins()
assert catalog.find_plugin_for_mode("CI-Fixture") == "ci-fixture-plugin"
assert catalog.find_plugin_for_mode("nope") is None
def test_discovery_follows_the_directory(self, tmp_path):
plugins = tmp_path / "plugin-repos"
root = PROJECT_ROOT / "test" / "fixtures" / "plugins" / "ci-fixture-plugin"
shutil.copytree(root, plugins / "ledmatrix-ci-fixture-plugin")
(plugins / "broken").mkdir()
(plugins / "broken" / "manifest.json").write_text("[]", encoding="utf-8")
catalog = PluginCatalog(plugins)
assert catalog.discover_plugins() == ["ci-fixture-plugin"]
# Installed as ledmatrix-<id>, found by its manifest id.
assert catalog.get_plugin_directory("ci-fixture-plugin").endswith("ledmatrix-ci-fixture-plugin")
# Not a plain name: refused, never joined onto the plugins directory.
assert catalog.get_plugin_directory("../plugin-repos") is None
shutil.rmtree(plugins / "ledmatrix-ci-fixture-plugin")
assert catalog.discover_plugins() == []
assert catalog.get_manifest("ci-fixture-plugin") is None
def test_enabled_follows_the_display_rule(self, tmp_path):
config = MagicMock()
config.load_config.return_value = {"a": {"enabled": True}, "b": {}, "c": "junk"}
catalog = PluginCatalog(tmp_path, config_manager=config)
assert catalog.is_enabled("a") is True
# The display runs a plugin only when its section says so.
assert catalog.is_enabled("b") is False
assert catalog.is_enabled("c") is False
assert catalog.is_enabled("missing") is False
assert catalog.get_config("c") == {}
def test_it_has_nothing_that_runs_a_plugin(self, tmp_path):
catalog = PluginCatalog(tmp_path)
for name in ("load_plugin", "unload_plugin", "reload_plugin", "get_plugin",
"plugins", "run_scheduled_updates"):
assert not hasattr(catalog, name), name
+2 -4
View File
@@ -189,7 +189,7 @@ def _make_post(tmp_path, schema, stored, plugin_id="demo"):
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
for k in ("config_manager", "schema_manager", "plugin_catalog")}
pdir = tmp_path / "plugins" / plugin_id
pdir.mkdir(parents=True)
@@ -213,9 +213,7 @@ def _make_post(tmp_path, schema, stored, plugin_id="demo"):
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
a.api_v3.plugin_catalog = pm
app = Flask(__name__)
app.config["TESTING"] = True