// "Check & Update All" -- which ids it sends to POST /api/v3/plugins/update. // // Pins two bugs seen on a real device (core 3.4.0): // // 1. /plugins/installed lists installed Starlark apps as virtual // `starlark:` entries. Update-all sent those to the plugin // updater, which answered 500 "plugin not found" for each one. // // 2. A web-service restart landed mid-run. The request in flight died with // the old process and the next one (stock-news) was refused while the // service was coming back; both were recorded as failures and never sent // again, so stock-news -- installed, disabled, with an update waiting -- // was silently not updated. // // Runs the shipped install_manager.js (it exports itself under node) against // a fake PluginAPI. The installed list is the device's, trimmed. const path = require('path'); const V3 = path.resolve(__dirname, '../../../web_interface/static/v3'); let pass = 0, fail = 0; const ok = (label, cond, extra) => cond ? (pass++, console.log(' ok ' + label)) : (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : ''))); global.window = {}; const Manager = require(path.join(V3, 'js/plugins/install_manager.js')); const INSTALLED = [ { id: 'ledmatrix-flights', enabled: true, version: '1.14.0' }, { id: 'stock-news', enabled: false, version: '2.6.2', latest_version: '2.8.0', update_available: true }, { id: 'static-image', enabled: false, version: '1.1.3' }, { id: 'starlark-apps', enabled: false, version: '1.0.0' }, // a real plugin -- keep it { id: 'pomodoro-timer', enabled: false, version: '1.3.6' }, { id: 'starlark:analogtime', enabled: false, version: 'starlark', is_starlark_app: true }, { id: 'starlark:analogclock', enabled: true, version: 'starlark', is_starlark_app: true }, ]; const EXPECTED = ['ledmatrix-flights', 'stock-news', 'static-image', 'starlark-apps', 'pomodoro-timer']; const netErr = () => ({ error_code: 'NETWORK_ERROR', message: 'Failed to fetch' }); function fakeApi(behaviour = {}) { const calls = []; return { calls, updatePlugin: async (id) => { calls.push(id); const b = behaviour[id]; if (typeof b === 'function') return b(calls.filter(c => c === id).length); return { status: 'success', message: `Plugin ${id} updated successfully` }; }, }; } function setup(api, { stateList, windowList, pluginManager } = {}) { global.window = { PluginAPI: api, installedPlugins: windowList, PluginStateManager: stateList === undefined ? undefined : { installedPlugins: stateList, loadInstalledPlugins: async () => stateList, }, pluginManager, }; } const noSleep = { sleep: async () => {} }; (async () => { console.log('\nselection'); ok('starlark app entries are not updatable', !Manager.isUpdatablePlugin(INSTALLED[5]) && !Manager.isUpdatablePlugin(INSTALLED[6])); ok('a starlark: id without the flag is still excluded', !Manager.isUpdatablePlugin({ id: 'starlark:foo' })); ok('the starlark-apps plugin itself is updatable', Manager.isUpdatablePlugin(INSTALLED[3])); ok('a disabled plugin with an update is updatable', Manager.isUpdatablePlugin(INSTALLED[1])); ok('entries without a usable id are skipped', !Manager.isUpdatablePlugin({}) && !Manager.isUpdatablePlugin(null) && !Manager.isUpdatablePlugin({ id: '' })); const sel = Manager.updatablePlugins(INSTALLED).map(p => p.id); ok('updatablePlugins keeps list order and drops only starlark apps', JSON.stringify(sel) === JSON.stringify(EXPECTED), sel); console.log('\nupdateAll sends only plugin ids'); { const api = fakeApi(); setup(api, { windowList: INSTALLED }); const progress = []; const results = await Manager.updateAll((i, n, id) => progress.push([i, n, id]), noSleep); ok('POSTs exactly the non-starlark ids, in order', JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls); ok('no starlark: id reached the plugin updater', !api.calls.some(id => id.startsWith('starlark:'))); ok('one result per plugin sent', results.length === EXPECTED.length, results.length); 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('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(); setup(api, { windowList: INSTALLED.filter(p => p.is_starlark_app) }); const results = await Manager.updateAll(null, noSleep); ok('a list of only starlark apps sends nothing', api.calls.length === 0 && results.length === 0); } console.log('\nweb service restarting mid-run (the stock-news case)'); { // ledmatrix-flights is in flight when the service stops (reset); stock-news // is refused twice while it comes back; then everything answers. const api = fakeApi({ 'ledmatrix-flights': (n) => { if (n === 1) throw netErr(); return { status: 'success', message: 'ok' }; }, 'stock-news': (n) => { if (n <= 2) throw netErr(); return { status: 'success', message: 'Plugin stock-news updated successfully' }; }, }); setup(api, { windowList: INSTALLED }); const slept = []; const results = await Manager.updateAll(null, { sleep: async ms => { slept.push(ms); }, retryDelaysMs: [5, 10, 20] }); const byId = Object.fromEntries(results.map(r => [r.pluginId, r])); ok('stock-news is sent again until the server answers', api.calls.filter(id => id === 'stock-news').length === 3, api.calls); ok('stock-news ends up updated, not skipped', byId['stock-news'] && byId['stock-news'].success === true, byId['stock-news']); ok('the request lost with the old process is re-sent too', byId['ledmatrix-flights'] && byId['ledmatrix-flights'].success === true); ok('it backs off between attempts', JSON.stringify(slept) === JSON.stringify([5, 5, 10]), slept); ok('every plugin still gets exactly one result', JSON.stringify(results.map(r => r.pluginId)) === JSON.stringify(EXPECTED), results.map(r => r.pluginId)); } { const api = fakeApi({ 'stock-news': () => { throw netErr(); } }); setup(api, { windowList: INSTALLED }); const results = await Manager.updateAll(null, { sleep: async () => {}, retryDelaysMs: [1, 1] }); const r = results.find(x => x.pluginId === 'stock-news'); ok('a server that never comes back is retried a bounded number of times', api.calls.filter(id => id === 'stock-news').length === 3, api.calls); ok('...then reported as a failure, and the run continues', r && r.success === false && api.calls[api.calls.length - 1] === 'pomodoro-timer'); } { const api = fakeApi({ 'static-image': () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'check logs' }; } }); setup(api, { windowList: INSTALLED }); const results = await Manager.updateAll(null, { sleep: async () => { throw new Error('must not sleep'); } }); ok('an HTTP error answer is not retried', api.calls.filter(id => id === 'static-image').length === 1, api.calls); ok('...and is reported as a failure', results.find(x => x.pluginId === 'static-image').success === false); } console.log('\nthe real api_client.js: only a missing HTTP answer is retried'); { // The fake PluginAPI above decides error_code itself. This runs the shipped // client so its classification is what gets tested: a proxy's 502 page or // a JSON 500 without error_code used to come back as NETWORK_ERROR and be // re-sent five more times. const PluginAPI = require(path.join(V3, 'js/plugins/api_client.js')); const run = async (makeFetch) => { let requests = 0, sleeps = 0; global.fetch = async () => { requests++; return makeFetch(requests); }; global.window = { PluginAPI, installedPlugins: [{ id: 'stock-news' }] }; const results = await Manager.updateAll(null, { sleep: async () => { sleeps++; }, retryDelaysMs: [1, 1, 1] }); return { requests, sleeps, result: results[0] }; }; const httpAnswer = (status, json) => ({ ok: status < 400, status, json }); let r = await run(() => httpAnswer(502, async () => { throw new SyntaxError('Unexpected token <'); })); ok('a 502 with an HTML body is sent once', r.requests === 1 && r.sleeps === 0, r); ok('...and is an API_ERROR carrying the status, not NETWORK_ERROR', r.result.success === false && r.result.error.error_code === 'API_ERROR' && r.result.error.status === 502, r.result.error); r = await run(() => httpAnswer(500, async () => ({ status: 'error', message: 'boom' }))); ok('a JSON 500 without error_code is sent once', r.requests === 1 && r.sleeps === 0, r); ok('...and keeps the server message', r.result.error.message === 'boom', r.result.error); r = await run(() => httpAnswer(500, async () => ({ status: 'error', error_code: 'PLUGIN_UPDATE_FAILED', message: 'x' }))); ok('a structured error is passed through unchanged', r.requests === 1 && r.result.error.error_code === 'PLUGIN_UPDATE_FAILED', r.result.error); r = await run((n) => { if (n === 1) throw new TypeError('Failed to fetch'); return httpAnswer(200, async () => ({ status: 'success', message: 'ok', data: { update_status: 'updated' } })); }); ok('a fetch() that rejects is NETWORK_ERROR and re-sent', r.requests === 2 && r.sleeps === 1 && r.result.success, r); r = await run(() => httpAnswer(200, async () => { throw new SyntaxError('Unexpected end of JSON input'); })); ok('an unreadable 200 is not retried either', r.requests === 1 && r.result.error.error_code === 'API_ERROR', r); // Every endpoint is one of the client's own API paths; one that could // leave baseURL never reaches fetch(), and plugin ids are encoded. const urls = []; global.fetch = async (url) => { urls.push(url); return httpAnswer(200, async () => ({ status: 'success' })); }; const refusal = async (endpoint) => { try { await PluginAPI.request(endpoint, 'POST'); return null; } catch (e) { return e.error_code; } }; const bad = ['//evil.example/x', '/plugins/../../x', '/a\b', '/a b', 'plugins', null]; const codes = []; for (const endpoint of bad) codes.push(await refusal(endpoint)); ok('an endpoint that could leave the API path is refused before fetch()', codes.every(c => c === 'INVALID_ENDPOINT') && urls.length === 0, { codes, urls }); await PluginAPI.resetPluginConfig('a/../b&x=1'); ok('a plugin id is encoded into the URL, not spliced into it', urls[0] === '/api/v3/plugins/config/reset?plugin_id=a%2F..%2Fb%26x%3D1', urls); delete global.fetch; } console.log('\nsummary: a no-op update is not counted as updated'); { const answer = (update_status, message) => ({ success: true, result: { status: 'success', message, data: { update_status } } }); const results = [ answer('updated', 'Plugin a updated to version 2.8.0'), // ZIP-installed monorepo plugin already at the registry version: the // route used to call this "updated successfully". answer('up_to_date', 'Plugin stock-news already up to date (version 2.8.0)'), answer('up_to_date', 'Plugin clock already up to date (commit abcdef1)'), answer('local_only', 'Plugin mine is managed locally and does not receive registry updates'), { success: false, error: { error_code: 'PLUGIN_UPDATE_FAILED' } }, ]; const s = Manager.summarizeUpdateResults(results); ok('counts come from update_status', s.updated === 1 && s.upToDate === 2 && s.localOnly === 1 && s.failed === 1, s); ok('toast text names each outcome', s.text === '1 updated, 2 already up to date, 1 managed locally, 1 failed', s.text); ok('a failure alongside an update is a warning', s.type === 'warning', s.type); ok('an older server that only says so in the message is still up to date', Manager.updateOutcome({ success: true, result: { message: 'Plugin x already up to date (commit 1234567)' } }) === 'up_to_date'); ok('a success without a status or telltale message counts as updated', Manager.updateOutcome({ success: true, result: { message: 'Plugin x updated successfully' } }) === 'updated'); const allNoop = Manager.summarizeUpdateResults([answer('up_to_date', ''), answer('up_to_date', '')]); ok('nothing to do is a success toast with no "updated"', allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop); } console.log('\nrestart banner: driven by the server\'s restart_required'); { const body = (restart_required, restart_message) => ({ success: true, result: { status: 'success', data: { update_status: 'updated' }, restart_required, restart_message }, }); const needed = body(true, 'Plugin updated — restart the display to run the new version'); ok('an update the display is running asks for the banner, with its wording', Manager.restartRequest([body(false), needed, body(true, 'second')]) === needed.result); ok('updates the display does not run need no restart', Manager.restartRequest([body(false), body(false)]) === null); ok('a failed request never raises the banner', Manager.restartRequest([{ success: false, error: { restart_required: true } }]) === null); ok('an older server that sends no flag raises nothing', Manager.restartRequest([{ success: true, result: { status: 'success' } }]) === null); ok('no results, no banner', Manager.restartRequest(undefined) === null); // The first request's answer was lost; the re-sent one finds nothing to do. const lost = (enabled, update_status = 'up_to_date') => ({ pluginId: 'clock', success: true, afterLostAnswer: true, enabled, result: { status: 'success', data: { update_status }, restart_required: false }, }); const maybe = Manager.restartRequest([body(false), lost(true)]); ok('an enabled plugin up to date after a lost answer may have been updated: banner', maybe && maybe.restart_required === true && /clock/.test(maybe.restart_message), maybe); ok('...but an explicit answer still wins, with its wording', Manager.restartRequest([lost(true), needed]) === needed.result); ok('a disabled one needs no restart (enabling it loads it)', Manager.restartRequest([lost(false)]) === null); ok('nor does one that was not retried', Manager.restartRequest([{ ...lost(true), afterLostAnswer: undefined }]) === null); } console.log('\nupdateAll keeps what the banner needs'); { // ledmatrix-flights (enabled) loses its first answer, then is up to date. const api = fakeApi({ 'ledmatrix-flights': (n) => { if (n === 1) throw netErr(); return { status: 'success', data: { update_status: 'up_to_date' }, restart_required: false }; }, }); setup(api, { windowList: INSTALLED }); const results = await Manager.updateAll(null, noSleep); const flights = results.find(r => r.pluginId === 'ledmatrix-flights'); ok('a retried entry is marked, with the plugin\'s enabled flag', flights.afterLostAnswer === true && flights.enabled === true, flights); ok('an entry answered first time is not marked', results.filter(r => r.afterLostAnswer).length === 1, results); ok('...so the run asks for a restart', Manager.restartRequest(results) !== null); } { const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true }; const api = fakeApi({ 'ledmatrix-flights': () => answer }); setup(api, { stateList: INSTALLED }); window.PluginStateManager.loadInstalledPlugins = async () => { throw new Error('refresh failed'); }; const warn = console.warn; console.warn = () => {}; let results; try { results = await Manager.updateAll(null, noSleep); } catch (e) { results = e; } finally { console.warn = warn; } ok('a failed list refresh still returns the results', Array.isArray(results) && results.length === EXPECTED.length, String(results)); ok('...with the restart flag intact', Array.isArray(results) && Manager.restartRequest(results) === answer); } console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); })().catch(e => { console.error(e); process.exit(1); });