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 @@ -