mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
@@ -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
@@ -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'];
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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); });
|
||||
|
||||
@@ -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}), \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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": {}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user