mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix: settings the display ignored, a memory leak, and the plugin card handler (#605)
* fix(errors): stop affected_plugins growing without bound Each repeat of an error pattern appended every plugin in the time window to the pattern's list again, so a plugin failing in a loop grew the display process's memory without limit: 3,000 errors from three plugins reached 2.5 million entries. Keep the list unique. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): load a BDF font at its native size instead of PIL's default FreeType rejects any size but a BDF strike's own, and FontManager answered that with ImageFont.load_default() -- a different typeface -- so 5x7.bdf requested at 8 or 10px rendered as PIL's default font. Retry at the native strike, as element_style already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): plugin toggle failures no longer claim "operation in progress" Every exception in POST /plugins/toggle was mapped to PLUGIN_OPERATION_CONFLICT, so any failure told the user "A plugin operation is already in progress". Report the failure as what it is, and record the plugin id in the operation history for form posts too (it read a `data` variable that only the JSON path set). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): route plugin card clicks through handlePluginAction The document-level delegation checked `typeof handlePluginAction`, which is scoped inside the plugin-manager IIFE and so never visible to it. Every card click took a copied fallback that stopped propagation (the grid's own listener never ran), confirmed an uninstall twice, and sent Starlark app uninstalls to POST /plugins/uninstall instead of DELETE /starlark/apps/<id>. Expose the handler on window and delegate to it. Also run every test/js/unit suite under pytest: they need only node, but CI ran one of the eight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): apply Rotation durations, WiFi messages and Vegas settings Three settings the web UI saves never reached the display: - Rotation & Durations: display.display_durations was never read. Every plugin inherits get_display_duration() and the plugin was asked first. A saved value now wins. The page shows unsaved screens blank with the plugin's own duration as a placeholder, and saving a blank removes the override, so one save no longer pins every screen. - WiFi status overlay: the controller looked for wifi_status.json one directory above the repo. Both sides now use wifi_manager.get_wifi_status_path(). The message is written by rename so the display never reads it half-written, and the resumed plugin redraws the whole panel afterwards. - Vegas: nothing called coordinator.update_config(), so saved Vegas settings never reached a running scroll. They are now queued when display.vegas_scroll changes, and applied while Vegas is stopped too, so a disable then re-enable works. The follower's scroll-speed default (75) now matches VegasModeConfig's (50). Also throttles Vegas's per-frame live-priority scan to 4Hz. It cost 139us per frame on a Pi 4 with two scoreboards (1.7% of a 125fps frame) and grows with each plugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep affected_plugins order when serialized; guard non-Element targets ErrorPattern.to_dict() ran the now-ordered list through set(), so get_error_summary() listed plugins in an unstable order. The document-level card-action listener called event.target.closest() without checking the target is an Element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -15,7 +15,8 @@ npm install # jsdom, for the DOM suites only
|
||||
node run_all.js
|
||||
```
|
||||
|
||||
The unit suites need nothing but node. The DOM suites additionally need a
|
||||
The unit suites need nothing but node; `test/test_js_unit_suites.py` runs every
|
||||
`unit/*.js` under pytest, so CI covers them. The DOM suites additionally need a
|
||||
running web interface, because they test against the **real** server-rendered
|
||||
HTML and the **real** API rather than fixtures:
|
||||
|
||||
@@ -41,6 +42,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
|
||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||
'unit/test_style_editor_layout_leaf_columns.js',
|
||||
'unit/test_style_editor_layout_leaf_collision.js',
|
||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js'];
|
||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
|
||||
'unit/test_plugin_action_delegation.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,127 @@
|
||||
// Installed-plugin card actions go through handlePluginAction exactly once.
|
||||
//
|
||||
// The document-level delegation in plugins_manager.js tested
|
||||
// `typeof handlePluginAction`, which lives inside the plugin-manager IIFE and
|
||||
// so was never visible to it. Every click took a copied fallback instead,
|
||||
// which stopped propagation (the grid's own listener never ran), asked to
|
||||
// confirm an uninstall twice, and sent Starlark app uninstalls to the plugin
|
||||
// endpoint instead of DELETE /starlark/apps/<id>.
|
||||
//
|
||||
// Runs the shipped global delegation and handlePluginAction, sliced out of
|
||||
// plugins_manager.js, against a minimal fake DOM.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const SRC = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
|
||||
|
||||
function slice(startMarker, endMarker) {
|
||||
const a = SRC.indexOf(startMarker);
|
||||
const b = SRC.indexOf(endMarker, a);
|
||||
if (a < 0 || b < 0) throw new Error(`could not find ${startMarker} .. ${endMarker}`);
|
||||
return SRC.slice(a, b);
|
||||
}
|
||||
|
||||
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin');
|
||||
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
|
||||
|
||||
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 setup() {
|
||||
const listeners = {};
|
||||
const calls = { confirm: 0, fetch: [], uninstallPlugin: [], togglePlugin: [] };
|
||||
const window = {
|
||||
installedPlugins: [{ id: 'clock', enabled: false }],
|
||||
uninstallPlugin: id => calls.uninstallPlugin.push(id),
|
||||
togglePlugin: (id, on) => calls.togglePlugin.push([id, on]),
|
||||
};
|
||||
const ctx = {
|
||||
window,
|
||||
document: {
|
||||
addEventListener: (type, fn, capture) => { (listeners[type] = listeners[type] || []).push(fn); },
|
||||
},
|
||||
confirm: () => { calls.confirm++; return true; },
|
||||
fetch: (url, opts) => { calls.fetch.push([url, opts && opts.method]); return new Promise(() => {}); },
|
||||
alert: () => {},
|
||||
console,
|
||||
setTimeout,
|
||||
debugLog: () => {},
|
||||
getInstalledFilter: () => null,
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
// The handler lives inside the plugin-manager IIFE in the real file, so it
|
||||
// runs in one here too: the global delegation must not see it by name.
|
||||
vm.runInContext(GLOBAL_DELEGATION + '\n(function() {\n' + HANDLER + '\n})();', ctx);
|
||||
|
||||
const click = (action, pluginId) => {
|
||||
const el = {
|
||||
getAttribute: name => ({ 'data-action': action, 'data-plugin-id': pluginId })[name],
|
||||
type: 'button',
|
||||
};
|
||||
let stopped = false;
|
||||
const event = {
|
||||
type: 'click',
|
||||
target: { closest: () => el },
|
||||
preventDefault() {},
|
||||
stopPropagation() { stopped = true; },
|
||||
};
|
||||
for (const fn of listeners.click || []) fn(event);
|
||||
return stopped;
|
||||
};
|
||||
return { window, calls, click, listeners };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('\n-- plugin card action delegation --');
|
||||
|
||||
{
|
||||
const t = setup();
|
||||
ok('handlePluginAction is exposed on window', typeof t.window.handlePluginAction === 'function');
|
||||
ok('document-level click listener registered', (t.listeners.click || []).length === 1);
|
||||
}
|
||||
|
||||
{
|
||||
// A non-Element target (a text node, the document) has no closest().
|
||||
const t = setup();
|
||||
let threw = null;
|
||||
try { for (const fn of t.listeners.click) fn({ type: 'click', target: {} }); } catch (e) { threw = e; }
|
||||
ok('non-Element event target is ignored', threw === null, threw && threw.message);
|
||||
}
|
||||
|
||||
{
|
||||
const t = setup();
|
||||
t.click('uninstall', 'starlark:analogclock');
|
||||
ok('Starlark uninstall confirms once', t.calls.confirm === 1, t.calls.confirm);
|
||||
ok('Starlark uninstall hits DELETE /starlark/apps/<id>',
|
||||
t.calls.fetch.length === 1 && t.calls.fetch[0][0] === '/api/v3/starlark/apps/analogclock'
|
||||
&& t.calls.fetch[0][1] === 'DELETE', t.calls.fetch);
|
||||
ok('Starlark uninstall does not go to the plugin uninstaller', t.calls.uninstallPlugin.length === 0);
|
||||
}
|
||||
|
||||
{
|
||||
const t = setup();
|
||||
t.click('uninstall', 'clock');
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
ok('plugin uninstall: handler itself does not confirm (uninstallPlugin does)', t.calls.confirm === 0,
|
||||
t.calls.confirm);
|
||||
ok('plugin uninstall calls uninstallPlugin once', t.calls.uninstallPlugin.length === 1
|
||||
&& t.calls.uninstallPlugin[0] === 'clock', t.calls.uninstallPlugin);
|
||||
}
|
||||
|
||||
{
|
||||
const t = setup();
|
||||
const stopped = t.click('toggle', 'clock');
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
ok('toggle flips the stored state', JSON.stringify(t.calls.togglePlugin) === '[["clock",true]]',
|
||||
t.calls.togglePlugin);
|
||||
ok('handled once (propagation stopped)', stopped === true);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})();
|
||||
Reference in New Issue
Block a user