mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web-ui): Update All sends the live installed list and redraws the grid updateAll() preferred PluginStateManager.installedPlugins over window.installedPlugins. Only updateAll's own end-of-run refresh ever fills PluginStateManager, so from the second run on it sent the first run's plugins: one uninstalled since failed with "plugin not found" and one installed since was never updated. That refresh also only replaced window.installedPlugins, so the installed cards and the Updates badge kept offering "Update to vX" for what had just been updated. Read window.installedPlugins, the list plugins_manager.js republishes after every install, uninstall and refresh, keeping PluginStateManager as the fallback for a page without it, and refresh through pluginManager.loadInstalledPlugins(true), which redraws the grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web-ui): list each plugin's display modes in /plugins/installed The on-demand modal fills its Display Mode select from plugin.display_modes, but /plugins/installed never sent the field. Every plugin offered one option, its own id, under "This plugin exposes a single display mode"; the display resolved that id to the plugin's first mode, so a multi-mode plugin could only be started, or pinned, there. Add display_modes to each entry, read from the plugin catalog (get_plugin_display_modes), the same declared list /display/modes and on-demand/start use, keeping only strings. Single-mode plugins still get one option and the same hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web-ui): enable a store install by its installed id, and not on reinstall The store's Install button enabled the new plugin by the registry id it installed. Weather, Music, Stocks and Leaderboard install under the id their manifests declare (weather -> ledmatrix-weather); the plugin list, the config section and /plugins/toggle know only that id, so the toggle answered 404 "Plugin not found" and the plugin stayed disabled behind "installed, but enabling it failed". The same button on an installed plugin (Reinstall) enabled it too, switching a plugin the user had turned off back on. POST /plugins/install now names the installed plugin: plugin_id in the direct answer and in the queued operation's result, read from the installed manifest found the way the store's update and uninstall find it (_find_plugin_path: id, aliases, plugin_path name), else the requested id. The client reloads the list, then enables that id; from an answer without it, the installed entry the store entry matches (findInstalledStorePlugin, which isStorePluginInstalled now uses). A reinstall, decided by the same match that labelled the button, reloads the list and leaves the enabled state alone. test/js/plugins_manager_sandbox.js runs the whole of plugins_manager.js in a vm context against a fake DOM and API, for suites that drive its real flows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web-ui): wait for long store installs; on timeout reload, not fail pollOperationStatus gave a queued install 60 polls, a second apart, then reported "Install operation timed out" as an error and stopped. The server allows the plugin's dependency install 300 s on its own (install_requirements_file in store_install.py), after a download that fetches the plugin a file at a time, so installs that went on to succeed were reported as failed, never enabled, and left out of the installed list until the page was reloaded. Give installs INSTALL_POLL_MAX_ATTEMPTS (600, ten minutes). When even that runs out, reload the installed list and the store badges and warn that the install may still be running; nothing is enabled without the operation's answer. Uninstall keeps the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web-ui): build the store's category filter from the store's plugins The #plugin-category select listed seven fixed categories while the registry uses about twenty (productivity, utility, transit, finance, ...), so roughly a third of the store could not be filtered to, and "Financial" missed the plugin filed under "finance". The template now ships only "All Categories"; syncStoreCategoryOptions, run by applyStoreFiltersAndSort, adds one option per category the cached store plugins have (case folded, as the filter compares), keeps the current choice, and rebuilds only when the set changes or the partial was swapped in afresh -- the way the Starlark section builds its own. The test sandbox gains window.addEventListener (initPluginsPage needs it) and quiets the script's "element not found" warnings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web-ui): one handler for the GitHub-URL Install button #install-plugin-from-url had an inline onclick calling window.handleGitHubPluginInstall, and attachInstallButtonHandler also gave it a click listener that installs, so both ran on every click (and on Enter, which clicks it). The inline handler threw a ReferenceError -- it called isGithubUrl, which is local to the plugin-manager IIFE, from outside it -- so only the listener's request went out; correcting that scope alone would have sent every install twice. Remove the inline onclick and the window.handleGitHubPluginInstall it called, which nothing else uses. The listener, which already sent the only request, is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
8.4 KiB
JavaScript
213 lines
8.4 KiB
JavaScript
// The whole of plugins_manager.js (and list_filter.js before it, as the page
|
|
// loads them), evaluated in a node vm context against a small fake DOM.
|
|
//
|
|
// For suites that drive the plugin manager's real flows -- install, polling,
|
|
// store filters, the GitHub-URL button -- rather than one function sliced
|
|
// out of the file. Nothing is mocked inside the script: only what the page
|
|
// gives it (document, fetch, timers, showNotification, LEDEscape).
|
|
//
|
|
// const sb = create({ route: (method, url, body) => ({ status, json }) });
|
|
// sb.el('plugin-store-grid'); // make an element exist by id
|
|
// sb.window.installPlugin('weather');
|
|
// await sb.until(() => sb.requests.some(r => r.url.includes('/toggle')));
|
|
//
|
|
// Timers ignore their delays and run on the next turn, so a poll loop that
|
|
// would take minutes in a browser finishes in milliseconds. The page is in
|
|
// readyState "loading" with no #installed-plugins-grid, so the script's own
|
|
// start-up does nothing until a suite asks for it (window.initPluginsPage()).
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
const ledEscape = require('./led_escape');
|
|
|
|
const V3 = path.resolve(__dirname, '../../web_interface/static/v3');
|
|
const PLUGINS_HTML = path.resolve(__dirname, '../../web_interface/templates/v3/partials/plugins.html');
|
|
|
|
class FakeClassList {
|
|
constructor() { this.set = new Set(); }
|
|
add(...c) { c.forEach(x => this.set.add(x)); }
|
|
remove(...c) { c.forEach(x => this.set.delete(x)); }
|
|
contains(c) { return this.set.has(c); }
|
|
toggle(c, force) {
|
|
const on = force === undefined ? !this.set.has(c) : !!force;
|
|
if (on) this.set.add(c); else this.set.delete(c);
|
|
return on;
|
|
}
|
|
}
|
|
|
|
function create({ route } = {}) {
|
|
const elements = new Map();
|
|
const requests = [];
|
|
const toasts = [];
|
|
const errors = [];
|
|
const restartNotes = [];
|
|
|
|
class FakeElement {
|
|
constructor(id, tag = 'div', attributes = {}) {
|
|
this.id = id;
|
|
this.tagName = tag.toUpperCase();
|
|
this.attributes = { ...attributes };
|
|
this.listeners = {};
|
|
this.children = [];
|
|
this.classList = new FakeClassList();
|
|
this.style = { removeProperty() {} };
|
|
this.dataset = {};
|
|
this.value = '';
|
|
this.textContent = '';
|
|
this.disabled = false;
|
|
this.parentNode = null;
|
|
this._html = '';
|
|
}
|
|
get innerHTML() { return this._html; }
|
|
set innerHTML(v) { this._html = String(v); this.children = []; }
|
|
getAttribute(n) { return n in this.attributes ? this.attributes[n] : null; }
|
|
setAttribute(n, v) { this.attributes[n] = String(v); }
|
|
hasAttribute(n) { return n in this.attributes; }
|
|
removeAttribute(n) { delete this.attributes[n]; }
|
|
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); }
|
|
removeEventListener(type, fn) {
|
|
this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn);
|
|
}
|
|
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
|
querySelector() { return null; }
|
|
querySelectorAll() { return []; }
|
|
closest() { return null; }
|
|
cloneNode() {
|
|
const copy = new FakeElement(this.id, this.tagName, this.attributes);
|
|
copy._html = this._html;
|
|
copy.value = this.value;
|
|
return copy;
|
|
}
|
|
replaceChild(next, prev) {
|
|
next.parentNode = this;
|
|
prev.parentNode = null;
|
|
if (next.id) elements.set(next.id, next);
|
|
return prev;
|
|
}
|
|
replaceWith(next) { if (this.parentNode) this.parentNode.replaceChild(next, this); }
|
|
// A browser runs an inline on<type> attribute first (it was set before
|
|
// any listener was added), then the listeners, and an exception in one
|
|
// does not stop the next: it is reported, which is what `errors` holds.
|
|
dispatch(type, init = {}) {
|
|
const event = {
|
|
type, target: this, currentTarget: this, key: init.key,
|
|
defaultPrevented: false,
|
|
preventDefault() { this.defaultPrevented = true; },
|
|
stopPropagation() {}, stopImmediatePropagation() {},
|
|
};
|
|
const inline = this.getAttribute('on' + type);
|
|
const handlers = [];
|
|
if (inline !== null) {
|
|
handlers.push(vm.runInContext(`(function(event) {\n${inline}\n})`, ctx));
|
|
}
|
|
handlers.push(...(this.listeners[type] || []));
|
|
for (const h of handlers) {
|
|
try { h.call(this, event); } catch (e) { errors.push(e); }
|
|
}
|
|
return event;
|
|
}
|
|
click() { return this.dispatch('click'); }
|
|
}
|
|
|
|
function el(id, tag, attributes) {
|
|
if (!elements.has(id)) {
|
|
const parent = new FakeElement(null);
|
|
parent.appendChild(new FakeElement(id, tag, attributes));
|
|
elements.set(id, parent.children[0]);
|
|
}
|
|
return elements.get(id);
|
|
}
|
|
|
|
const timers = [];
|
|
const ctx = {
|
|
// Warnings are the script noting elements this fake page doesn't have.
|
|
console: { log: console.log.bind(console), error: console.error.bind(console),
|
|
warn: () => {}, info: () => {}, debug: () => {} },
|
|
debugLog: () => {},
|
|
addEventListener() {},
|
|
URL,
|
|
document: {
|
|
readyState: 'loading',
|
|
body: { addEventListener() {} },
|
|
getElementById: id => elements.get(id) || null,
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
addEventListener() {},
|
|
dispatchEvent() { return true; },
|
|
createElement: tag => new FakeElement(null, tag),
|
|
},
|
|
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
|
|
setTimeout: (fn, _ms, ...args) => { timers.push(setImmediate(() => fn(...args))); return timers.length; },
|
|
clearTimeout: () => {},
|
|
setInterval: () => 0,
|
|
clearInterval: () => {},
|
|
requestAnimationFrame: fn => setImmediate(fn),
|
|
getComputedStyle: () => ({ display: 'block' }),
|
|
scrollTo() {},
|
|
sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
confirm: () => true,
|
|
alert: () => {},
|
|
showNotification: (message, type) => {
|
|
toasts.push({ message: String(message),
|
|
type: type && typeof type === 'object' ? type.type : type });
|
|
},
|
|
noteRestartRequired: (body) => { restartNotes.push(body); },
|
|
fetch: async (url, opts = {}) => {
|
|
const method = (opts.method || 'GET').toUpperCase();
|
|
let body = null;
|
|
try { body = opts.body ? JSON.parse(opts.body) : null; } catch (e) { body = opts.body; }
|
|
requests.push({ method, url: String(url), body });
|
|
const answer = (route && route(method, String(url), body)) || { status: 200, json: { status: 'success' } };
|
|
const status = answer.status || 200;
|
|
return { ok: status < 400, status, json: async () => answer.json };
|
|
},
|
|
};
|
|
ctx.window = ctx;
|
|
vm.createContext(ctx);
|
|
ledEscape.install(ctx);
|
|
for (const file of ['js/plugins/list_filter.js', 'plugins_manager.js']) {
|
|
vm.runInContext(fs.readFileSync(path.join(V3, file), 'utf8'), ctx, { filename: file });
|
|
}
|
|
|
|
// Resolves once cond() is true, letting timers and promises run between
|
|
// checks; rejects if it never is.
|
|
async function until(cond, label = 'condition', turns = 20000) {
|
|
for (let i = 0; i < turns; i++) {
|
|
if (cond()) return;
|
|
await new Promise(r => setImmediate(r));
|
|
}
|
|
throw new Error('timed out waiting for ' + label);
|
|
}
|
|
|
|
// Lets every pending timer and promise run.
|
|
async function settle(turns = 50) {
|
|
for (let i = 0; i < turns; i++) await new Promise(r => setImmediate(r));
|
|
}
|
|
|
|
return { window: ctx, el, FakeElement, requests, toasts, errors, restartNotes, until, settle };
|
|
}
|
|
|
|
// The attributes of the element with this id in partials/plugins.html, as
|
|
// the template ships them (no Jinja on the tags these suites read).
|
|
function templateAttributes(id) {
|
|
const html = fs.readFileSync(PLUGINS_HTML, 'utf8');
|
|
const at = html.indexOf(`id="${id}"`);
|
|
if (at < 0) throw new Error(`no element with id ${id} in plugins.html`);
|
|
const start = html.lastIndexOf('<', at);
|
|
let end = start, quote = null;
|
|
for (; end < html.length; end++) {
|
|
const ch = html[end];
|
|
if (quote) { if (ch === quote) quote = null; } else if (ch === '"' || ch === "'") quote = ch;
|
|
else if (ch === '>') break;
|
|
}
|
|
const tag = html.slice(start, end + 1);
|
|
const attrs = {};
|
|
const re = /([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
let m;
|
|
while ((m = re.exec(tag))) attrs[m[1]] = m[3] !== undefined ? m[3] : m[4];
|
|
return { tag: tag.match(/^<(\w+)/)[1], attrs, source: tag };
|
|
}
|
|
|
|
module.exports = { create, templateAttributes };
|