From e32d177cbd0bf72a2eec52138728431e0f7bf697 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:18:54 -0400 Subject: [PATCH] fix(web-ui): Plugin Manager - enable aliased installs, Update All, on-demand modes, long installs, categories, GitHub-URL install (#746) * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 42 ++++ test/js/README.md | 4 + test/js/plugins_manager_sandbox.js | 212 +++++++++++++++++ test/js/run_all.js | 7 +- test/js/unit/test_github_url_install.js | 93 ++++++++ test/js/unit/test_install_polling.js | 89 +++++++ test/js/unit/test_store_categories.js | 105 +++++++++ test/js/unit/test_store_install.js | 138 +++++++++++ test/js/unit/test_store_registry_fields.js | 3 +- test/js/unit/test_update_all.js | 63 ++++- ...est_api_v3_install_reports_installed_id.py | 104 +++++++++ test/test_api_v3_installed_display_modes.py | 59 +++++ .../blueprints/api_v3/plugin_store.py | 28 ++- web_interface/blueprints/api_v3/plugins.py | 9 + .../static/v3/js/plugins/install_manager.js | 29 ++- web_interface/static/v3/plugins_manager.js | 221 +++++++++--------- .../templates/v3/partials/plugins.html | 13 +- 17 files changed, 1083 insertions(+), 136 deletions(-) create mode 100644 test/js/plugins_manager_sandbox.js create mode 100644 test/js/unit/test_github_url_install.js create mode 100644 test/js/unit/test_install_polling.js create mode 100644 test/js/unit/test_store_categories.js create mode 100644 test/js/unit/test_store_install.js create mode 100644 test/test_api_v3_install_reports_installed_id.py create mode 100644 test/test_api_v3_installed_display_modes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cfea8ecb..dc7886cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -646,6 +646,48 @@ policies are unchanged. being stopped, blanks the panel within about a second. It used to stay on until the next minute, because the once-a-minute schedule check had already run that minute and the session had overridden its answer. +- Check & Update All updates what is installed now. A second run in the + same page sent the plugins the first run had seen, so a plugin uninstalled + since then failed with "plugin not found" and one installed since was + skipped. After a run the installed cards and the Updates badge show the + new versions; they kept offering "Update to vX" for what had just been + updated until the page was reloaded. +- The Run On-Demand dialog lists a plugin's display modes, so a mode other + than the first can be started, and pinned. `/api/v3/plugins/installed` + never sent `display_modes`, which the dialog reads, so every plugin + offered only its own id under "This plugin exposes a single display + mode", and the display started its first mode. Each entry now carries + `display_modes`, the modes its manifest declares. +- Installing Weather, Music, Stocks or Leaderboard from the Plugin Store + enables it, as installing any other plugin does. Each installs under the + id its manifest declares (`ledmatrix-weather` for the store's `weather`), + but the store enabled the store id, which `/api/v3/plugins/toggle` + answered with "Plugin not found": the plugin stayed disabled behind + "installed, but enabling it failed". `POST /api/v3/plugins/install` now + answers with the installed `plugin_id` (in the operation's result when it + is queued), and the store enables that. +- Reinstalling a plugin from the Plugin Store leaves it enabled or disabled + as it was. Reinstall enabled it as a fresh install does, so a plugin the + user had switched off came back on. +- A Plugin Store install that takes more than a minute is no longer + reported as failed. The store stopped waiting after 60 s and showed + "Install operation timed out" while the server, which allows the + plugin's dependency install 300 s on its own, carried on and usually + succeeded; the plugin was then neither enabled nor listed until the page + was reloaded. The store now waits up to 10 minutes, and if it still has + no answer it reloads the installed list and says the install may still + be running. +- The Plugin Store's category filter lists every category its plugins + have. It offered a fixed seven while the registry uses about twenty, so + plugins filed under productivity, utility, transit and the rest could not + be filtered to, and "Financial" missed the plugin filed under "finance". + The choices are now built from the store's plugins, as the Starlark + section's are. +- The Install button under Install Single Plugin (Plugin Manager > Install + from GitHub) runs one handler per click. It also had an inline `onclick` + whose handler threw a `ReferenceError` on every click; only the other + handler's request went out, and making the inline one work would have + sent every install twice. The inline handler is gone. - `/api/v3/plugins/installed` no longer reports the display's plugins as `live` while `/api/v3/health` says `display_loop: stalled`. The runtime snapshot is written from its own thread, which kept going while the render diff --git a/test/js/README.md b/test/js/README.md index cc0c1b4f..c28d2de8 100644 --- a/test/js/README.md +++ b/test/js/README.md @@ -45,6 +45,10 @@ server has none. |---|---|---| | `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it | | `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it | +| `unit/test_store_install.js` | no | The store's Install button, with the whole of `plugins_manager.js` run by `plugins_manager_sandbox.js` (a vm context, fake DOM and API): a fresh install reloads the list, then enables the id the plugin was installed as -- the answer's `plugin_id`, else the installed entry the store entry matches (Weather installs as `ledmatrix-weather`); a Reinstall leaves the enabled state alone | +| `unit/test_install_polling.js` | no | How long Install waits for a queued install (sandbox): at least the server's 300 s dependency-install timeout; when it stops waiting it reloads the installed list and warns, rather than reporting a failure or enabling anything | +| `unit/test_store_categories.js` | no | The store's category filter (sandbox): the template ships only All Categories, the rest come from the store's plugins (one per category whatever its case), choosing one filters to it, and a swapped-in select is refilled from the cache keeping the choice | +| `unit/test_github_url_install.js` | no | Install Single Plugin (sandbox, the button as `plugins.html` ships it): no inline `onclick`, so a click or Enter sends exactly one `install-from-url` request and raises no error | | `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata | | `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer | | `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 | diff --git a/test/js/plugins_manager_sandbox.js b/test/js/plugins_manager_sandbox.js new file mode 100644 index 00000000..4bd48579 --- /dev/null +++ b/test/js/plugins_manager_sandbox.js @@ -0,0 +1,212 @@ +// 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 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 }; diff --git a/test/js/run_all.js b/test/js/run_all.js index 41122501..1e6a5437 100755 --- a/test/js/run_all.js +++ b/test/js/run_all.js @@ -20,7 +20,12 @@ 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_store_install.js', + 'unit/test_install_polling.js', + 'unit/test_store_categories.js', + 'unit/test_github_url_install.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_restart_banner.js', 'unit/test_page_registry.js', 'unit/test_core_modules.js']; diff --git a/test/js/unit/test_github_url_install.js b/test/js/unit/test_github_url_install.js new file mode 100644 index 00000000..048a6677 --- /dev/null +++ b/test/js/unit/test_github_url_install.js @@ -0,0 +1,93 @@ +// Plugin Manager > Install from GitHub > Install Single Plugin: one click, +// one request, no errors. +// +// The Install button carried an inline onclick calling +// window.handleGitHubPluginInstall, and attachInstallButtonHandler also gave +// it a click listener that installs. Both ran on every click. The inline one +// threw a ReferenceError (it called isGithubUrl, which lives inside the +// plugin-manager IIFE, from outside it), so only the listener's request went +// out -- and fixing that scope alone would have sent every install twice. +// The button now has the listener only. +// +// Runs the whole of plugins_manager.js in the sandbox, with the button as +// partials/plugins.html ships it. + +const { create, templateAttributes } = require('../plugins_manager_sandbox'); + +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).slice(0, 400) : ''))); + +const URL = 'https://github.com/someone/ledmatrix-demo'; + +function route(method, url) { + if (method === 'POST' && url === '/api/v3/plugins/install-from-url') { + return { json: { status: 'success', message: 'Plugin demo installed successfully', plugin_id: 'demo' } }; + } + if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } }; + return { json: { status: 'success' } }; +} + +function page() { + const sb = create({ route }); + const button = templateAttributes('install-plugin-from-url'); + sb.el('install-plugin-from-url', button.tag, button.attrs); + sb.el('github-plugin-url', 'input'); + sb.el('github-plugin-status'); + sb.el('plugin-branch-input', 'input'); + return sb; +} + +const installs = sb => sb.requests.filter(r => r.url === '/api/v3/plugins/install-from-url'); + +(async () => { + console.log('\nthe template'); + { + const { attrs } = templateAttributes('install-plugin-from-url'); + ok('the Install button has no inline onclick', !('onclick' in attrs), attrs.onclick); + } + + console.log('\na click'); + { + const sb = page(); + sb.window.attachInstallButtonHandler(); + // htmx:afterSettle runs it again on every swap; that must not add a handler. + sb.window.attachInstallButtonHandler(); + sb.el('github-plugin-url').value = URL; + sb.window.document.getElementById('install-plugin-from-url').click(); + await sb.settle(); + ok('raises no error', sb.errors.length === 0, sb.errors.map(String)); + ok('sends exactly one install request', installs(sb).length === 1, installs(sb)); + ok('for the URL typed', installs(sb)[0] && installs(sb)[0].body.repo_url === URL, installs(sb)); + ok('and reports the result', /Successfully installed: demo/.test(sb.el('github-plugin-status').innerHTML), + sb.el('github-plugin-status').innerHTML); + } + + console.log('\nEnter in the URL field'); + { + const sb = page(); + sb.window.attachInstallButtonHandler(); + const input = sb.el('github-plugin-url'); + input.value = URL; + input.dispatch('keypress', { key: 'Enter' }); + await sb.settle(); + ok('raises no error', sb.errors.length === 0, sb.errors.map(String)); + ok('sends exactly one install request', installs(sb).length === 1, installs(sb)); + } + + console.log('\na URL that is not GitHub'); + { + const sb = page(); + sb.window.attachInstallButtonHandler(); + sb.el('github-plugin-url').value = 'https://example.com/x'; + sb.window.document.getElementById('install-plugin-from-url').click(); + await sb.settle(); + ok('is refused without a request or an error', + installs(sb).length === 0 && sb.errors.length === 0 && /valid GitHub URL/.test(sb.el('github-plugin-status').innerHTML), + { errors: sb.errors.map(String), status: sb.el('github-plugin-status').innerHTML }); + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/test/js/unit/test_install_polling.js b/test/js/unit/test_install_polling.js new file mode 100644 index 00000000..253edbc4 --- /dev/null +++ b/test/js/unit/test_install_polling.js @@ -0,0 +1,89 @@ +// How long the store's Install waits for a queued install, and what it says +// when it stops waiting. +// +// It polled the operation 60 times, a second apart, then reported "Install +// operation timed out" as an error and did nothing else. The server is +// allowed far longer: the plugin's dependency install alone may take 300 s +// (install_requirements_file in src/plugin_system/store_install.py), after +// a download that fetches the plugin one file at a time. So an install that +// went on to succeed was reported as failed, never enabled, and missing from +// the installed list until the page was reloaded. +// +// Runs the whole of plugins_manager.js in the sandbox; its timers ignore +// their delays, so each poll here stands for one second on a real page. + +const { create } = require('../plugins_manager_sandbox'); + +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).slice(0, 400) : ''))); + +// The server's dependency-install timeout, in polls (one a second). +const DEPENDENCY_INSTALL_TIMEOUT_POLLS = 300; + +function server(completesAfterPolls) { + const state = { polls: 0, installed: [] }; + state.route = (method, url, body) => { + if (url.startsWith('/api/v3/plugins/installed')) { + return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } }; + } + if (method === 'POST' && url === '/api/v3/plugins/install') { + return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } }; + } + if (url === '/api/v3/plugins/operation/op-1') { + state.polls++; + if (completesAfterPolls === null || state.polls < completesAfterPolls) { + return { json: { status: 'success', data: { status: 'running' } } }; + } + state.installed = [{ id: 'clock-simple', name: 'Clock', enabled: false }]; + return { json: { status: 'success', data: { status: 'completed', + result: { success: true, message: 'installed', plugin_id: 'clock-simple' } } } }; + } + if (method === 'POST' && url === '/api/v3/plugins/toggle') { + return { json: { status: 'success', message: 'enabled' } }; + } + return { json: { status: 'success' } }; + }; + return state; +} + +(async () => { + console.log('\nan install that takes longer than a minute'); + { + // 200 s: well inside what the server allows. + const srv = server(200); + const sb = create({ route: srv.route }); + sb.window.installPlugin('clock-simple'); + await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|timed out|still/i.test(t.message)), + 'the install to finish'); + await sb.settle(); + ok('is waited for until it completes', srv.polls === 200, srv.polls); + ok('is not reported as an error', !sb.toasts.some(t => t.type === 'error'), sb.toasts); + ok('and is enabled', sb.requests.some(r => r.url === '/api/v3/plugins/toggle' && r.body.plugin_id === 'clock-simple'), + sb.requests.filter(r => r.method === 'POST')); + } + + console.log('\nan install that never reports back'); + { + const srv = server(null); + const sb = create({ route: srv.route }); + sb.window.installPlugin('clock-simple'); + await sb.until(() => sb.toasts.length >= 3, 'the poller to give up'); + await sb.settle(); + ok(`is polled for at least the ${DEPENDENCY_INSTALL_TIMEOUT_POLLS} s dependency-install timeout`, + srv.polls >= DEPENDENCY_INSTALL_TIMEOUT_POLLS, srv.polls); + ok('...but not forever', srv.polls <= 1200, srv.polls); + const lastPoll = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/operation/op-1'); + ok('then the installed list is reloaded, to show what actually happened', + sb.requests.slice(lastPoll + 1).some(r => r.url === '/api/v3/plugins/installed'), + sb.requests.slice(lastPoll + 1).map(r => r.url)); + const last = sb.toasts[sb.toasts.length - 1]; + ok('it says the install may still be running, as a warning, not a failure', + last && last.type === 'warning' && !/fail|timed out/i.test(last.message), sb.toasts); + ok('nothing is enabled on a guess', !sb.requests.some(r => r.url === '/api/v3/plugins/toggle')); + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/test/js/unit/test_store_categories.js b/test/js/unit/test_store_categories.js new file mode 100644 index 00000000..f5195012 --- /dev/null +++ b/test/js/unit/test_store_categories.js @@ -0,0 +1,105 @@ +// The Plugin Store's category filter offers the categories its plugins have. +// +// The template listed seven fixed categories. The registry uses about +// twenty (productivity, utility, transit, finance, ...), so roughly a third +// of the store could not be filtered to at all, and "Financial" missed the +// plugin filed under "finance". The options are now built from the store's +// plugins, as the Starlark section builds its own; the template ships only +// "All Categories". +// +// Runs the whole of plugins_manager.js in the sandbox. + +const fs = require('fs'); +const path = require('path'); +const { create, templateAttributes } = require('../plugins_manager_sandbox'); + +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).slice(0, 400) : ''))); + +const STORE = [ + { id: 'nfl', name: 'NFL', category: 'sports' }, + { id: 'nba', name: 'NBA', category: 'Sports' }, + { id: 'todo', name: 'Todo', category: 'productivity' }, + { id: 'stocks', name: 'Stocks', category: 'finance' }, + { id: 'crypto', name: 'Crypto', category: 'financial' }, + { id: 'bus', name: 'Bus', category: 'transit' }, + { id: 'mystery', name: 'Mystery' }, +]; + +function route(method, url) { + if (url.startsWith('/api/v3/plugins/store/list')) return { json: { status: 'success', data: { plugins: STORE } } }; + if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } }; + if (url.startsWith('/api/v3/plugins/store/github-status')) { + return { json: { status: 'success', data: { token_status: 'valid', authenticated: true, rate_limit: 5000 } } }; + } + if (url.startsWith('/api/v3/plugins/saved-repositories')) { + return { json: { status: 'success', data: { repositories: [] } } }; + } + if (url.startsWith('/api/v3/display/on-demand/status')) { + return { json: { status: 'success', data: { state: {}, service: {} } } }; + } + return { json: { status: 'success' } }; +} + +const options = sel => sel.children.map(o => o.value); +const cardIds = sb => [...sb.el('plugin-store-grid').innerHTML.matchAll(/]*>([^<]*)<\/h4>/g)].map(m => m[1]); + +(async () => { + console.log('\nthe template'); + { + const html = fs.readFileSync(path.resolve(__dirname, + '../../../web_interface/templates/v3/partials/plugins.html'), 'utf8'); + const start = html.indexOf(' from the template, the store list still cached. + const fresh = new sb.FakeElement('plugin-category', 'select', attrs); + select.parentNode.replaceChild(fresh, select); + sb.window.searchPluginStore(false); + await sb.settle(); + ok('the new select is filled from the cache', + JSON.stringify(options(fresh)) === JSON.stringify(['finance', 'financial', 'productivity', 'sports', 'transit']), + options(fresh)); + ok('keeping the chosen category', fresh.value === 'sports', fresh.value); + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/test/js/unit/test_store_install.js b/test/js/unit/test_store_install.js new file mode 100644 index 00000000..ac421e8f --- /dev/null +++ b/test/js/unit/test_store_install.js @@ -0,0 +1,138 @@ +// The store's Install button: which plugin it enables afterwards, and when. +// +// 1. Weather, Music, Stocks and Leaderboard are registry entries (`weather`) +// whose manifests declare another id (`ledmatrix-weather`). The plugin +// list, its config section and /plugins/toggle know them by that id, but +// the button enabled the registry id: /plugins/toggle answered 404 +// "Plugin not found" and the plugin stayed disabled behind "installed, +// but enabling it failed". It now enables the id the install answer +// names (`plugin_id`), or, from an answer without one, the installed +// entry the store entry matches (its id, plugin_path name or aliases). +// +// 2. Reinstall (the same button on an installed plugin) enabled it too, so +// reinstalling a plugin the user had switched off switched it back on. +// Only a fresh install enables. +// +// Runs the whole of plugins_manager.js in the sandbox against a fake API. + +const { create } = require('../plugins_manager_sandbox'); + +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).slice(0, 400) : ''))); + +const STORE = [ + { id: 'weather', name: 'Weather', category: 'weather', plugin_path: 'plugins/ledmatrix-weather', + aliases: ['ledmatrix-weather'] }, + { id: 'clock-simple', name: 'Clock', category: 'time', plugin_path: 'plugins/clock-simple', aliases: [] }, +]; + +// A server with one install in flight. `queue` false answers the install +// directly; `names` false leaves plugin_id out of the answer (an older +// server); `installsAs` is the id the installed manifest declares. +function server({ installed = [], queue = true, names = true, installsAs }) { + const state = { installed: installed.map(p => ({ ...p })), polls: 0 }; + const done = (id) => { + if (!state.installed.some(p => p.id === installsAs)) { + state.installed.push({ id: installsAs, name: id, enabled: false }); + } + const result = { success: true, message: `Plugin ${id} installed successfully`, restart_required: false }; + if (names) result.plugin_id = installsAs; + return result; + }; + state.route = (method, url, body) => { + if (url.startsWith('/api/v3/plugins/store/list')) { + return { json: { status: 'success', data: { plugins: STORE } } }; + } + if (url.startsWith('/api/v3/plugins/installed')) { + return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } }; + } + if (method === 'POST' && url === '/api/v3/plugins/install') { + if (queue) return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } }; + return { json: { status: 'success', message: 'Plugin installed successfully', ...done(body.plugin_id) } }; + } + if (url === '/api/v3/plugins/operation/op-1') { + state.polls++; + if (state.polls < 3) return { json: { status: 'success', data: { status: 'running' } } }; + return { json: { status: 'success', data: { status: 'completed', result: done('weather') } } }; + } + if (method === 'POST' && url === '/api/v3/plugins/toggle') { + const plugin = state.installed.find(p => p.id === body.plugin_id); + if (!plugin) return { status: 404, json: { status: 'error', message: 'Plugin not found' } }; + plugin.enabled = body.enabled; + return { json: { status: 'success', message: `Plugin ${body.plugin_id} enabled successfully` } }; + } + return { json: { status: 'success' } }; + }; + return state; +} + +async function install(pluginId, opts) { + const srv = server(opts); + const sb = create({ route: srv.route }); + sb.window.searchPluginStore(); + await sb.until(() => sb.requests.some(r => r.url.startsWith('/api/v3/plugins/store/list')), 'store list'); + await sb.window.pluginManager.loadInstalledPlugins(true); + await sb.settle(); + sb.requests.length = 0; + sb.toasts.length = 0; + sb.window.installPlugin(pluginId); + await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|reinstalled/.test(t.message)), + 'the install to finish'); + await sb.settle(); + const toggles = sb.requests.filter(r => r.url === '/api/v3/plugins/toggle').map(r => r.body); + return { sb, srv, toggles }; +} + +(async () => { + console.log('\n1. a fresh install enables the id the plugin was installed as'); + { + const { srv, toggles, sb } = await install('weather', { installsAs: 'ledmatrix-weather' }); + ok('enables ledmatrix-weather, not the registry id', + JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles); + ok('...which the server enabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === true, srv.installed); + ok('says so', sb.toasts.some(t => t.type === 'success' && /installed and enabled/.test(t.message)), sb.toasts); + ok('no "Plugin not found"', !sb.toasts.some(t => /not found|failed/.test(t.message)), sb.toasts); + const lastList = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/installed'); + const toggleAt = sb.requests.findIndex(r => r.url === '/api/v3/plugins/toggle'); + ok('the installed list is reloaded before enabling, so the new card is there to update', + lastList >= 0 && lastList < toggleAt, sb.requests.map(r => r.method + ' ' + r.url)); + } + { + const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', names: false }); + ok('an answer without plugin_id: the installed entry the store entry matches (its alias)', + JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles); + } + { + const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', queue: false }); + ok('without the operation queue, from the direct answer', + JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles); + } + { + const { toggles } = await install('clock-simple', { installsAs: 'clock-simple', names: false }); + ok('a plugin installed under its registry id is enabled by that id', + JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'clock-simple', enabled: true }]), toggles); + } + + console.log('\n2. a reinstall leaves the plugin as the user had it'); + { + const { srv, toggles, sb } = await install('weather', { + installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: false }], + }); + ok('sends no toggle', toggles.length === 0, toggles); + ok('the plugin stays disabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === false); + ok('says it was reinstalled', sb.toasts.some(t => t.type === 'success' && /reinstalled/.test(t.message)), sb.toasts); + ok('and reloads the list', + sb.requests.some(r => r.url === '/api/v3/plugins/installed'), sb.requests.map(r => r.url)); + } + { + const { toggles } = await install('weather', { + installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: true }], + }); + ok('an enabled plugin is not toggled either', toggles.length === 0, toggles); + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/test/js/unit/test_store_registry_fields.js b/test/js/unit/test_store_registry_fields.js index 54a57ec5..968cd35f 100644 --- a/test/js/unit/test_store_registry_fields.js +++ b/test/js/unit/test_store_registry_fields.js @@ -52,7 +52,8 @@ global.installedPlugins = []; // eslint-disable-next-line no-eval eval([ 'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {', - 'function isStorePluginInstalled(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {', + 'function isStorePluginInstalled(pluginIdOrPlugin) {', + 'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {', ].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;' + '\nglobal.isStorePluginInstalled = isStorePluginInstalled;'); diff --git a/test/js/unit/test_update_all.js b/test/js/unit/test_update_all.js index ef0cf610..b3fbaa79 100644 --- a/test/js/unit/test_update_all.js +++ b/test/js/unit/test_update_all.js @@ -52,7 +52,7 @@ function fakeApi(behaviour = {}) { }; } -function setup(api, { stateList, windowList } = {}) { +function setup(api, { stateList, windowList, pluginManager } = {}) { global.window = { PluginAPI: api, installedPlugins: windowList, @@ -60,6 +60,7 @@ function setup(api, { stateList, windowList } = {}) { installedPlugins: stateList, loadInstalledPlugins: async () => stateList, }, + pluginManager, }; } @@ -92,12 +93,68 @@ const noSleep = { sleep: async () => {} }; ok('progress total counts only what is sent', progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress); } + { + // A page without the plugin manager has no window.installedPlugins. + const api = fakeApi(); + setup(api, { stateList: INSTALLED }); + await Manager.updateAll(null, noSleep); + ok('the PluginStateManager list (no live list) is filtered the same way', + JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls); + } + + console.log('\na second run sends the live list, not the first run\'s snapshot'); + { + // Run 1 leaves PluginStateManager holding a, b, c. Then c is uninstalled + // and d installed: plugins_manager.js publishes that only as + // window.installedPlugins. Run 2 used to send a, b, c -- c failed as + // "plugin not found" and d, which had an update waiting, was skipped. + const api = fakeApi({ + c: () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'Plugin update failed: plugin not found' }; }, + }); + const stale = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + setup(api, { stateList: stale, windowList: [{ id: 'a' }, { id: 'b' }, { id: 'd' }] }); + const results = await Manager.updateAll(null, noSleep); + ok('sends exactly what is installed now', + JSON.stringify(api.calls) === JSON.stringify(['a', 'b', 'd']), api.calls); + ok('...so nothing fails over an uninstalled plugin', results.every(r => r.success), results); + } { const api = fakeApi(); setup(api, { stateList: INSTALLED, windowList: [] }); + const results = await Manager.updateAll(null, noSleep); + ok('an empty live list means nothing is installed: nothing is sent', + api.calls.length === 0 && results.length === 0, api.calls); + } + + console.log('\nthe end-of-run refresh redraws the installed grid'); + { + // PluginStateManager's refresh replaced window.installedPlugins and + // nothing else: the cards kept "Update to vX" and the Updates badge + // kept its count. The plugin manager's load renders the grid. + const loads = []; + let stateLoads = 0; + const pluginManager = { loadInstalledPlugins: async (force) => { loads.push(force); } }; + setup(fakeApi(), { stateList: INSTALLED, windowList: INSTALLED, pluginManager }); + window.PluginStateManager.loadInstalledPlugins = async () => { stateLoads++; }; await Manager.updateAll(null, noSleep); - ok('the PluginStateManager list is filtered the same way', - JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls); + ok('reloads through the plugin manager once, forced past its caches', + JSON.stringify(loads) === JSON.stringify([true]), loads); + ok('...instead of PluginStateManager', stateLoads === 0, stateLoads); + } + { + const pluginManager = { loadInstalledPlugins: async () => { throw new Error('offline'); } }; + const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true }; + setup(fakeApi({ 'ledmatrix-flights': () => answer }), { windowList: INSTALLED, pluginManager }); + const warn = console.warn; + console.warn = () => {}; + let results; + try { + results = await Manager.updateAll(null, noSleep); + } finally { + console.warn = warn; + } + ok('a failed plugin-manager reload still returns the results with their restart flag', + Array.isArray(results) && Manager.restartRequest(results) === answer); } { const api = fakeApi(); diff --git a/test/test_api_v3_install_reports_installed_id.py b/test/test_api_v3_install_reports_installed_id.py new file mode 100644 index 00000000..3e50af80 --- /dev/null +++ b/test/test_api_v3_install_reports_installed_id.py @@ -0,0 +1,104 @@ +"""POST /plugins/install says which id the plugin was installed as. + +A registry entry can install under another id: `weather` (aliases +`ledmatrix-weather`) installs a directory whose manifest declares +`ledmatrix-weather`, and that is the id the plugin list, the plugin's config +section and /plugins/toggle know it by. The store's Install button enabled +the new plugin by the registry id, which /plugins/toggle answered with 404 +"Plugin not found", so Weather, Music, Stocks and Leaderboard installed +disabled behind an "enabling it failed" warning. + +The answer -- the queued operation's result, or the direct response -- +carries `plugin_id`: the id the installed manifest declares, found the way +the store's update and uninstall find an install. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401 + +INSTALL = "/api/v3/plugins/install" + + +@pytest.fixture +def store(api_v3_module, tmp_path): + manager = api_v3_module.api_v3.plugin_store_manager + manager.install_plugin.return_value = True + manager.get_registry_info.return_value = None + manager._find_plugin_path.return_value = None + + def installed_as(directory, manifest): + path = tmp_path / directory + path.mkdir() + (path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + manager._find_plugin_path.side_effect = ( + lambda pid: path if pid == "weather" else None) + return path + + manager.installed_as = installed_as + return manager + + +@pytest.fixture +def queued(api_v3_module): + queue = MagicMock() + + def enqueue(operation_type, plugin_id, operation_callback=None): + queue.callback_result = operation_callback(MagicMock()) + return "op-1" + + queue.enqueue_operation.side_effect = enqueue + api_v3_module.api_v3.operation_queue = queue + return queue + + +class TestDirectInstall: + def test_an_aliased_entry_reports_the_manifest_id(self, api_v3_client, store): + store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"}) + body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + assert body["status"] == "success" + assert body["plugin_id"] == "ledmatrix-weather" + store._find_plugin_path.assert_called_with("weather") + + def test_an_entry_installed_under_its_own_id_reports_that(self, api_v3_client, store): + store.installed_as("weather", {"id": "weather"}) + body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + assert body["plugin_id"] == "weather" + + def test_an_install_that_cannot_be_found_reports_the_requested_id(self, api_v3_client, store): + body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + assert body["status"] == "success" + assert body["plugin_id"] == "weather" + + def test_a_manifest_id_that_is_not_a_plain_name_is_not_passed_on(self, api_v3_client, store): + store.installed_as("ledmatrix-weather", {"id": "../elsewhere"}) + body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + assert body["plugin_id"] == "weather" + + def test_an_unreadable_manifest_reports_the_requested_id(self, api_v3_client, store): + path = store.installed_as("ledmatrix-weather", {}) + (path / "manifest.json").write_text("[not json", encoding="utf-8") + body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + assert body["plugin_id"] == "weather" + + def test_the_restart_fields_are_still_sent(self, api_v3_client, store): + store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"}) + body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + assert "restart_required" in body + + +class TestQueuedInstall: + def test_the_operation_result_names_the_manifest_id(self, api_v3_client, store, queued): + store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"}) + body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + assert body["data"]["operation_id"] == "op-1" + assert queued.callback_result["success"] is True + assert queued.callback_result["plugin_id"] == "ledmatrix-weather" + + def test_an_install_that_cannot_be_found_names_the_requested_id( + self, api_v3_client, store, queued): + api_v3_client.post(INSTALL, json={"plugin_id": "weather"}) + assert queued.callback_result["plugin_id"] == "weather" diff --git a/test/test_api_v3_installed_display_modes.py b/test/test_api_v3_installed_display_modes.py new file mode 100644 index 00000000..439fc86f --- /dev/null +++ b/test/test_api_v3_installed_display_modes.py @@ -0,0 +1,59 @@ +"""GET /api/v3/plugins/installed carries each plugin's ``display_modes``. + +The on-demand modal (plugins_manager.js) fills its Display Mode list from +``plugin.display_modes``, but the route never included the field, so every +plugin offered one option -- its own id -- under "This plugin exposes a +single display mode". The display turns that id into the plugin's first +mode, so a multi-mode plugin could only be started, and pinned, on that one. + +The modes come from the plugin catalog (the manifests the web process +discovered), the same source /display/modes and on-demand/start use. +""" + +from unittest.mock import MagicMock + +import pytest + +from test._api_v3_test_helpers import ( # noqa: F401 - fixtures + api_v3_client, api_v3_module, +) + + +@pytest.fixture +def installed(api_v3_module, api_v3_client, tmp_path): + def _get(declared_modes): + api = api_v3_module.api_v3 + # The listing's own metadata says nothing about modes: what the + # route reports must come from the catalog. + info = {'id': 'football-scoreboard', 'name': 'Football', 'version': '1.0.0'} + 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_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes) + 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') + assert response.status_code == 200 + plugins = [p for p in response.get_json()['data']['plugins'] + if p['id'] == 'football-scoreboard'] + assert len(plugins) == 1 + api.plugin_catalog.get_plugin_display_modes.assert_any_call('football-scoreboard') + return plugins[0] + return _get + + +def test_every_declared_mode_is_listed_in_order(installed): + modes = ['nfl_live', 'nfl_recent', 'nfl_upcoming'] + assert installed(modes)['display_modes'] == modes + + +def test_a_single_mode_plugin_lists_its_one_mode(installed): + assert installed(['clock-simple'])['display_modes'] == ['clock-simple'] + + +def test_no_declared_modes_is_an_empty_list(installed): + # The modal falls back to the plugin id for an empty list. + assert installed([])['display_modes'] == [] + + +def test_a_hand_edited_manifest_cannot_put_non_strings_in_the_list(installed): + assert installed(['nfl_live', 7, None, {'x': 1}])['display_modes'] == ['nfl_live'] diff --git a/web_interface/blueprints/api_v3/plugin_store.py b/web_interface/blueprints/api_v3/plugin_store.py index 23aca5a9..e776918d 100644 --- a/web_interface/blueprints/api_v3/plugin_store.py +++ b/web_interface/blueprints/api_v3/plugin_store.py @@ -42,6 +42,29 @@ def _store_incompatibility(plugin: dict) -> Optional[str]: return reason if isinstance(reason, str) and reason else None +def _installed_plugin_id(plugin_id: str) -> str: + """The id the plugin installed for store entry ``plugin_id`` declares. + + A registry entry can install under another id: ``weather`` installs a + directory whose manifest says ``ledmatrix-weather``, and that is the id + the plugin list, the config section and /plugins/toggle know it by. The + install is found the way the store's update and uninstall find it (the + entry's id, ``aliases`` and ``plugin_path`` name); ``plugin_id`` itself + when its manifest can't be read. + """ + try: + plugin_dir = api_v3.plugin_store_manager._find_plugin_path(plugin_id) + manifest_path = (resolve_under(plugin_dir, 'manifest.json') + if isinstance(plugin_dir, Path) else None) + if manifest_path is None or not manifest_path.is_file(): + return plugin_id + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest_id = json.load(f).get('id') + except Exception: # noqa: BLE001 - only names the install for the client + return plugin_id + return manifest_id if isinstance(manifest_id, str) and safe_path_component(manifest_id) else plugin_id + + def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]: """The entry of ``base`` called ``name``, or None. @@ -487,8 +510,10 @@ def install_plugin(): ) branch_msg = f" (branch: {branch})" if branch else "" + # plugin_id: the id to enable it by (see _installed_plugin_id). return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}', + 'plugin_id': _installed_plugin_id(plugin_id), **_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))} else: error_msg = f'Failed to install plugin {plugin_id}' @@ -546,7 +571,8 @@ def install_plugin(): branch_msg = f" (branch: {branch})" if branch else "" return success_response( message=f'Plugin installed successfully{branch_msg}', - extra=_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))) + extra={'plugin_id': _installed_plugin_id(plugin_id), + **_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}) else: error_msg = f'Failed to install plugin {plugin_id}' if branch: diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 8c6bd2a3..ec319e3e 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -145,6 +145,14 @@ def get_installed_plugins(): vegas_participation, vegas_participation_source = _vegas_participation( plugin_id, plugin_config, plugin_info) + # The modes the manifest declares, from the catalog as /display/modes + # and on-demand/start read them. The on-demand modal offers these; + # without them it offered only the plugin id, which the display + # turns into the first mode. Strings only: a manifest is hand-edited. + declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id) + display_modes = ([m for m in declared_modes if isinstance(m, str)] + if isinstance(declared_modes, list) else []) + return { 'id': plugin_id, 'name': plugin_info.get('name', plugin_id), @@ -158,6 +166,7 @@ def get_installed_plugins(): # The tab nav uses this as the element's Font Awesome class # (app-shell.js / app-early.js); only a string can be one. 'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None, + 'display_modes': display_modes, 'enabled': enabled, 'verified': verified, # loaded, state, error_info, loaded_version, loaded_at: the diff --git a/web_interface/static/v3/js/plugins/install_manager.js b/web_interface/static/v3/js/plugins/install_manager.js index 8ed36398..7641ef8c 100644 --- a/web_interface/static/v3/js/plugins/install_manager.js +++ b/web_interface/static/v3/js/plugins/install_manager.js @@ -48,12 +48,15 @@ const PluginInstallManager = { * @returns {Promise} Update results, one per plugin sent */ async updateAll(onProgress, options = {}) { - // Prefer PluginStateManager if populated, fall back to window.installedPlugins - // (plugins_manager.js populates window.installedPlugins independently) - const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins; - const listed = (stateManagerPlugins && stateManagerPlugins.length > 0) - ? stateManagerPlugins - : (window.installedPlugins || []); + // window.installedPlugins is the live list: plugins_manager.js + // republishes it after every install, uninstall and refresh. + // PluginStateManager's copy is written only by the refresh at the end + // of a run, so preferring it sent a second run the first run's + // plugins -- an uninstalled one failed, a new one was skipped. It is + // the fallback for a page without the plugin manager. + const listed = Array.isArray(window.installedPlugins) + ? window.installedPlugins + : ((window.PluginStateManager && window.PluginStateManager.installedPlugins) || []); // Snapshot: the list can be replaced while this loop is awaiting. const plugins = this.updatablePlugins(listed); @@ -102,10 +105,18 @@ const PluginInstallManager = { } // Reload plugin list once at the end. A failed refresh must not - // lose the results: they carry the restart flags. - if (window.PluginStateManager) { + // lose the results: they carry the restart flags. The plugin + // manager's load, forced past its caches, also redraws the installed + // grid and its Updates badge; PluginStateManager's only replaced + // window.installedPlugins, so the cards kept offering "Update to vX" + // for what had just been updated. + const pluginManager = window.pluginManager; + const refresh = (pluginManager && typeof pluginManager.loadInstalledPlugins === 'function') + ? () => pluginManager.loadInstalledPlugins(true) + : (window.PluginStateManager ? () => window.PluginStateManager.loadInstalledPlugins() : null); + if (refresh) { try { - await window.PluginStateManager.loadInstalledPlugins(); + await refresh(); } catch (error) { console.warn('Could not refresh the installed plugin list after updating:', error); } diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 1664dabd..d9ae0300 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -34,8 +34,8 @@ * * Layout: a few handlers defined up front, outside any IIFE, because the * cards and other scripts call them through window (configurePlugin, - * togglePlugin, the GitHub token helpers, handleGitHubPluginInstall, - * checkGitHubAuthStatus); then the plugin-manager IIFE (private state: + * togglePlugin, the GitHub token helpers, checkGitHubAuthStatus); then the + * plugin-manager IIFE (private state: * installedPlugins, the store cache, the on-demand poller); then the * Starlark IIFE. * @@ -386,103 +386,6 @@ window.toggleGithubTokenContent = function(e) { } }; -// Simple standalone handler for GitHub plugin installation -// Defined early and globally to ensure it's always available -debugLog('[DEFINE] Defining handleGitHubPluginInstall function...'); -window.handleGitHubPluginInstall = function() { - debugLog('[handleGitHubPluginInstall] Function called!'); - - const urlInput = document.getElementById('github-plugin-url'); - const statusDiv = document.getElementById('github-plugin-status'); - const branchInput = document.getElementById('plugin-branch-input'); - const installBtn = document.getElementById('install-plugin-from-url'); - - if (!urlInput) { - console.error('[handleGitHubPluginInstall] URL input not found'); - alert('Error: Could not find URL input field'); - return; - } - - const repoUrl = urlInput.value.trim(); - debugLog('[handleGitHubPluginInstall] Repo URL:', repoUrl); - - if (!repoUrl) { - if (statusDiv) { - statusDiv.innerHTML = 'Please enter a GitHub URL'; - } - return; - } - - if (!isGithubUrl(repoUrl)) { - if (statusDiv) { - statusDiv.innerHTML = 'Please enter a valid GitHub URL'; - } - return; - } - - // Disable button and show loading - if (installBtn) { - installBtn.disabled = true; - installBtn.innerHTML = 'Installing...'; - } - if (statusDiv) { - statusDiv.innerHTML = 'Installing plugin...'; - } - - const branch = branchInput?.value?.trim() || null; - const requestBody = { repo_url: repoUrl }; - if (branch) { - requestBody.branch = branch; - } - - debugLog('[handleGitHubPluginInstall] Sending request:', requestBody); - - fetch('/api/v3/plugins/install-from-url', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestBody) - }) - .then(response => { - debugLog('[handleGitHubPluginInstall] Response status:', response.status); - return response.json(); - }) - .then(data => { - debugLog('[handleGitHubPluginInstall] Response data:', data); - if (data.status === 'success') { - if (statusDiv) { - statusDiv.innerHTML = `Successfully installed: ${window.LEDEscape.html(data.plugin_id)}`; - } - urlInput.value = ''; - - showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success'); - window.noteRestartRequired(data); - - setTimeout(() => window.pluginManager.loadInstalledPlugins(true).catch(() => {}), 1000); - } else { - if (statusDiv) { - statusDiv.innerHTML = `${window.LEDEscape.html(data.message || 'Installation failed')}`; - } - showNotification(data.message || 'Installation failed', 'error'); - } - }) - .catch(error => { - console.error('[handleGitHubPluginInstall] Error:', error); - if (statusDiv) { - statusDiv.innerHTML = `Error: ${window.LEDEscape.html(error.message)}`; - } - showNotification('Error installing plugin: ' + error.message, 'error'); - }) - .finally(() => { - if (installBtn) { - installBtn.disabled = false; - installBtn.innerHTML = 'Install'; - } - }); -}; -debugLog('[DEFINE] handleGitHubPluginInstall defined and ready'); - // GitHub Authentication Status - Define early so it's available in IIFE // Shows warning banner only when token is missing or invalid // The token itself is never exposed to the frontend for security @@ -2083,6 +1986,13 @@ window.uninstallPlugin = function(pluginId) { }); } +// How many times the store's Install polls a queued install, a second apart. +// The server allows the plugin's dependency install 300 s on its own +// (install_requirements_file in src/plugin_system/store_install.py), after a +// download that fetches the plugin a file at a time; the 60 the poller +// defaults to reported installs that then succeeded as timed out. +const INSTALL_POLL_MAX_ATTEMPTS = 600; + function pollOperationStatus(operationId, pluginId, pluginName, options = {}) { const maxAttempts = options.maxAttempts || 60; const attempt = options.attempt || 0; @@ -2114,9 +2024,10 @@ function pollOperationStatus(operationId, pluginId, pluginName, options = {}) { if (status === 'completed') { // The operation's result says whether the display picks - // the change up by itself or needs a restart. + // the change up by itself or needs a restart, and for an + // install which id the plugin was installed as. window.noteRestartRequired(operation.result); - onComplete(); + onComplete(operation.result); } else if (status === 'failed') { onFailed(operation.error || operation.message); } else { @@ -2270,10 +2181,18 @@ function showStoreLoading(show) { // ── Plugin Store: Client-Side Filter/Sort/Pagination ──────────────────────── function isStorePluginInstalled(pluginIdOrPlugin) { + return Boolean(findInstalledStorePlugin(pluginIdOrPlugin)); +} + +// The installed-list entry for a store plugin, or undefined. A registry entry +// can be installed under another id -- `weather` is listed as the +// `ledmatrix-weather` its manifest declares -- so its own id is tried first, +// then its plugin_path name, then its aliases. +function findInstalledStorePlugin(pluginIdOrPlugin) { const installed = window.installedPlugins || installedPlugins || []; // Accept either a plain ID string or a store plugin object (which may have plugin_path) if (typeof pluginIdOrPlugin === 'string') { - return installed.some(p => p.id === pluginIdOrPlugin); + return installed.find(p => p.id === pluginIdOrPlugin); } const storeId = pluginIdOrPlugin.id; // Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather") @@ -2281,8 +2200,9 @@ function isStorePluginInstalled(pluginIdOrPlugin) { const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null; // Newer registries also list the other ids outright (the manifest id). const aliases = Array.isArray(pluginIdOrPlugin.aliases) ? pluginIdOrPlugin.aliases : []; - return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId) - || aliases.includes(p.id)); + return installed.find(p => p.id === storeId) + || (pathDerivedId ? installed.find(p => p.id === pathDerivedId) : undefined) + || installed.find(p => aliases.includes(p.id)); } // ── Plugin Store: search / filter / sort ──────────────────────────────── @@ -2392,8 +2312,43 @@ function getStoreFilter() { return _storeFilter; } +// The category filter offers the categories the store's plugins have, as the +// Starlark section does. The template ships only "All Categories": a fixed +// list offered 7 of the registry's ~20 categories, so most plugins could not +// be filtered to, and "Financial" missed the plugin filed under "finance". +// One option per category whatever its case (the filter ignores case), and +// rebuilt only when the set changes, or for a select freshly swapped in. +function syncStoreCategoryOptions() { + const select = document.getElementById('plugin-category'); + if (!select) return; + const ctl = getStoreFilter(); + const selected = String((ctl ? ctl.state.filterCategory : select.value) || ''); + const byKey = new Map(); + (pluginStoreCache || []).forEach(plugin => { + const category = plugin && typeof plugin.category === 'string' ? plugin.category : ''; + if (category.trim() && !byKey.has(category.toLowerCase())) { + byKey.set(category.toLowerCase(), category); + } + }); + // The current choice stays selectable even if no plugin has it any more. + if (selected && !byKey.has(selected.toLowerCase())) byKey.set(selected.toLowerCase(), selected); + const categories = [...byKey.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + const key = categories.join('\n'); + if (select._storeCategories === key) return; + select._storeCategories = key; + select.innerHTML = ''; + categories.forEach(category => { + const option = document.createElement('option'); + option.value = category; + option.textContent = category.charAt(0).toUpperCase() + category.slice(1); + select.appendChild(option); + }); + select.value = selected; +} + function applyStoreFiltersAndSort(skipPageReset) { if (!pluginStoreCache) return; + syncStoreCategoryOptions(); const ctl = getStoreFilter(); if (ctl) { ctl.apply(skipPageReset); @@ -2510,11 +2465,45 @@ window.installPlugin = function(pluginId, branch = null) { requestBody.branch = branch; } - function enableAfterInstall() { + const storeEntry = (pluginStoreCache || []).find(p => p && p.id === pluginId) || { id: pluginId }; + // Decided before the install changes the list, by the same match that + // labelled the button Install or Reinstall. A reinstall keeps the plugin + // as the user had it: enabling it here switched a deliberately disabled + // plugin back on. + const isReinstall = isStorePluginInstalled(storeEntry); + + // The id the plugin was installed as, which can differ from the store's: + // `weather` installs as the `ledmatrix-weather` its manifest declares, + // and that is the id /plugins/toggle knows. The install answer names it + // (plugin_id); from one that doesn't, the installed entry the store + // entry matches, as for the Installed badge. + function installedPluginId(result) { + if (result && typeof result.plugin_id === 'string' && result.plugin_id) { + return result.plugin_id; + } + const match = findInstalledStorePlugin(storeEntry); + return match ? match.id : pluginId; + } + + function afterInstall(result) { + // Reload first, so the new card exists (and, without plugin_id in the + // answer, so the installed id can be found), then redraw the store's + // badges from that list. + loadInstalledPlugins(true).catch(() => {}).then(() => { + applyStoreFiltersAndSort(true); + if (isReinstall) { + showNotification(`${pluginId} reinstalled`, 'success'); + return; + } + enableAfterInstall(installedPluginId(result)); + }); + } + + function enableAfterInstall(installedId) { // Enable immediately so install -> enable is one step; only nudge // for a restart once enablement actually succeeded (persistent // toast; duration 0 = stays until dismissed). - Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => { + Promise.resolve(window.togglePlugin(installedId, true)).then(toggleResult => { if (toggleResult && toggleResult.status === 'success') { showNotification( `${pluginId} installed and enabled — restart the display to show it`, @@ -2532,9 +2521,6 @@ window.installPlugin = function(pluginId, branch = null) { ); } }); - // Refresh installed plugins list, then re-render store to update badges - loadInstalledPlugins().catch(() => {}); - setTimeout(() => applyStoreFiltersAndSort(true), 500); } fetch('/api/v3/plugins/install', { @@ -2555,14 +2541,25 @@ window.installPlugin = function(pluginId, branch = null) { // live: "installation queued" followed immediately by a failed // enable). Wait for the operation to actually finish first. pollOperationStatus(data.data.operation_id, pluginId, pluginId, { - onComplete: enableAfterInstall, + onComplete: afterInstall, onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'), - onTimeout: () => showNotification(`Install operation timed out for ${pluginId}`, 'error') + maxAttempts: INSTALL_POLL_MAX_ATTEMPTS, + // Out of patience is not a failure: the server may still be + // installing. Show the list as it is now and say so; nothing + // is enabled without the operation's answer. + onTimeout: () => { + showNotification( + `${pluginId} is still installing — it will appear in the installed list when it finishes`, + 'warning' + ); + loadInstalledPlugins(true).catch(() => {}) + .then(() => applyStoreFiltersAndSort(true)); + } }); } else { // No operation queue configured - install already completed synchronously. window.noteRestartRequired(data); - enableAfterInstall(); + afterInstall(data); } }) .catch(error => { diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index cec9a472..72c718c7 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -250,13 +250,7 @@ @@ -467,8 +461,9 @@ -