mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(plugins): search, filter and sort for Installed Plugins, on a shared ListFilter helper (#540)
* feat(plugins): add search, filter and sort to Installed Plugins, on a shared helper The Installed Plugins grid had no way to narrow it down: no search, no way to see only what's enabled, disabled, or out of date. On a rig with a couple dozen plugins that means scrolling the whole grid to find one. The two sections below it already solved this, twice, independently — the Plugin Store and Starlark Apps carried a copy-paste fork of the same ~600 lines (filter state, apply-filters-and-sort, page renderer, pagination strip, active-filter badge, listener wiring). Rather than add a third copy, this extracts the shared machinery and builds the new toolbar on it. New: web_interface/static/v3/js/plugins/list_filter.js — ListFilter.create() owns debounced search, filter axes, sort, the active-filter count, Clear, and optional pagination/persistence. Callers keep their own card markup via a `render` callback. Three control types cover every axis the page uses: pills (new), select (store category, starlark author) and cycle (the tri-state All -> Installed -> Not Installed button). Installed Plugins gets a compact toolbar: search box, one-click All / Enabled / Disabled / Updates pills, and a sort dropdown (A-Z, Z-A, updates first, recently updated, category). Filters reset on load, so you never come back to a mysteriously short list. No new CSS — this is the first consumer of the .filter-pill rules already sitting unused in app.css. renderInstalledPlugins() is split so it still publishes canonical state while renderInstalledCards() draws only the visible subset; the filtered list is never assigned to window.installedPlugins, which the toggle handler, isStorePluginInstalled(), runUpdateAllPlugins() and the Alpine config tabs all read as their source of truth. Toggling a plugin while filtered pins its card so it doesn't vanish from under the cursor. The Store and Starlark migrations are behaviour-preserving: same element ids, same localStorage keys (storeSort/storePerPage, starlarkSort/starlarkPerPage), same tri-state button markup, same pagination. Verified by differential tests that run the old and new implementations side by side against identical fixtures and compare every observable after each interaction. The only visible change is the pagination attribute (data-store-page/data-starlark-page -> data-list-page), which nothing outside its own click handler referenced. Net -156 lines in plugins_manager.js while adding a feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(plugins): keep raw search text, and stop the store search refetching Two review findings from CodeRabbit on #540. Do not write the trimmed search value back into the input. setSearch() trimmed before storing, and syncControls() then copied that trimmed value back over what the user had typed. Pausing longer than the debounce after typing a space deleted the space (and reset the caret), making multi-word terms effectively untypable. The raw text is now kept alongside the trimmed one: filtering and activeCount() still use the trimmed value, while the input keeps exactly what was typed. Remove the legacy #plugin-search / #plugin-category listeners in initializePlugins(). They bound searchPluginStore as the event handler, so the DOM event arrived as its `fetchCommitInfo` argument — always truthy, which skipped the cached-filter fast path and refetched /api/v3/plugins/store/list with commit info on every keystroke burst and category change. The store's ListFilter controller already filters the cached list, which is what those two controls should do. This double-binding predates this PR (the old code guarded with _listenerSetup and _storeFilterInit, two different flags, so both sets stayed live); it is fixed here because the refactor owns that wiring now. Both fixes are covered by tests that fail without them: the trailing-space regressions in the installed-plugins DOM suite, and a new whole-file jsdom test that counts fetches while typing (1 request at init, 0 thereafter; previously 1 -> 2 -> 3 -> 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(plugins): build pagination via DOM APIs, drop computed member access Addresses the five Codacy security findings, all in list_filter.js. Pagination no longer assembles an HTML string (3 findings: 2 critical + 1 high, "unsafe assignment to innerHTML"). The interpolated values were only page integers and local class constants, so there was no injection path, but concatenating markup into innerHTML is the pattern the scanners flag and createElement is no less clear. Each button now also owns its click listener directly instead of the container being re-queried afterwards, and the strip is cleared with textContent = '' rather than by assigning empty markup. No innerHTML assignment remains in the file. haystack() now walks Object.entries(item) and keeps the configured fields, instead of reading item[field] per field ("generic object injection sink"). Field order no longer drives the haystack order, which is irrelevant to the substring test. matches() iterates controls with for...of instead of an index ("variable assigned to object injection sink"). The rendered pagination is unchanged: same buttons, labels, page numbers, disabled states and classes. The old-vs-new differential tests now compare pagination structurally (tag, text, page, disabled, sorted class list) rather than as an HTML string, since building nodes legitimately serialises differently — «/» as characters rather than «/», disabled="" rather than a bare attribute. That comparison is stronger than the string one it replaces, and the real-DOM suite still drives the actual page buttons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(plugins): keep configured field order when building the search haystack The previous commit swapped item[field] for Object.entries(item) to clear a static-analysis object-injection warning, and in doing so changed the order of the haystack: entries follow the object's own key insertion order, not the configured `fields` order. Since the values are concatenated, that order decides which values end up adjacent, so a multi-word query spanning a field boundary matched differently. For store fields [name, description, author, id, ...] and API objects keyed {id, name, description, author, ...}, "bob plugin-01" matched before and stopped matching after. That contradicted the behaviour-preservation claim for the store and starlark migrations, and the differential tests missed it because every fixture query was a single word. Values now come out of a Map built from Object.entries, iterated in `fields` order: the original haystack is restored, and there is still no computed member access for the analyser to flag. Regression coverage for the ordering itself, at both levels: - unit: phrases spanning name->id and category->tags, plus the reverse (object-key) order asserted NOT to match - differential: the same class of query compared old-vs-new, with a guard that the phrase actually matches something so a mutual zero-result cannot pass vacuously Verified both fail without this fix (3 unit, 2 differential) and pass with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * test(web): add JS suites for ListFilter and the plugin-manager grids No JS toolchain exists in this repo, so these are plain node scripts with no framework: each prints ok/FAIL lines and exits non-zero. `node test/js/run_all.js` runs everything, skipping the DOM suites (rather than failing) when jsdom is absent or nothing is listening, so it stays useful in a bare checkout. unit/test_list_filter.js ListFilter search/filter/sort/count/sticky, driven through the installed-plugins config eval'd verbatim out of plugins_manager.js so the test cannot drift from the real configuration unit/test_render_cards.js renderInstalledCards markup, both empty states, and escaping of hostile plugin metadata dom/test_installed_dom.js the toolbar in a real DOM, including the HTMX partial re-swap and a getComputedStyle check that .filter-pill[data-active] matches what we emit dom/test_store_dom.js store pagination, per-page, category, tri-state Installed button, persistence across a re-boot dom/test_no_double_fetch.js loads the whole plugins_manager.js and counts requests, so a keystroke cannot refetch the store The DOM suites deliberately fetch the partial and the plugin data from a running web interface instead of using fixtures, so a renamed element id or a changed payload shape fails them loudly. Point them at a rig with a full plugin set when it matters (BASE=http://host:5000); a dev box with two plugins installed passes while exercising very little. Several assertions exist to stop specific bugs recurring: trailing spaces surviving the search debounce, a query spanning two adjacent search fields (haystack field order is load-bearing), and window.installedPlugins staying at full length while the grid is filtered. Others guard against passing vacuously — counting only non-skeleton cards, and checking a search phrase matches something before comparing two result sets. The old-vs-new differential suites that verified the store and starlark migrations are not included: they compared against the pre-refactor code, which now exists only in git history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
// Functional test for ListFilter + the installed-plugins config extracted
|
||||
// verbatim from plugins_manager.js. Runs under node with a minimal DOM shim.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
// ── minimal DOM shim ───────────────────────────────────────────────────────
|
||||
class El {
|
||||
constructor(id, attrs = {}) {
|
||||
this.id = id; this._attrs = { ...attrs }; this.value = '';
|
||||
this.textContent = ''; this.innerHTML = ''; this.title = '';
|
||||
this._classes = new Set(); this.children = []; this._listeners = {};
|
||||
this.parentEl = null;
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...c) => c.forEach(x => self._classes.add(x)),
|
||||
remove: (...c) => c.forEach(x => self._classes.delete(x)),
|
||||
contains: c => self._classes.has(c),
|
||||
toggle: (c, force) => {
|
||||
const on = force === undefined ? !self._classes.has(c) : !!force;
|
||||
if (on) self._classes.add(c); else self._classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
};
|
||||
}
|
||||
setAttribute(k, v) { this._attrs[k] = String(v); }
|
||||
getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; }
|
||||
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
|
||||
fire(type, target) {
|
||||
// Real DOM listeners get `this` === the element the listener is bound to;
|
||||
// the sort/select handlers rely on that, so mirror it.
|
||||
(this._listeners[type] || []).forEach(fn =>
|
||||
fn.call(this, { target: target || this, currentTarget: this, key: undefined,
|
||||
preventDefault() {}, stopPropagation() {} }));
|
||||
}
|
||||
append(child) { child.parentEl = this; this.children.push(child); return child; }
|
||||
querySelectorAll(sel) {
|
||||
const m = /^\[([^\]=]+)\]$/.exec(sel);
|
||||
if (m) return this.children.filter(c => c.getAttribute(m[1]) !== null);
|
||||
return [];
|
||||
}
|
||||
contains(node) { return node === this || this.children.includes(node); }
|
||||
closest(sel) {
|
||||
const m = /^\[([^\]=]+)\]$/.exec(sel);
|
||||
let n = this;
|
||||
while (n) { if (m && n.getAttribute(m[1]) !== null) return n; n = n.parentEl; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new Map();
|
||||
function mk(id, attrs) { const e = new El(id, attrs); if (id) registry.set(id, e); return e; }
|
||||
|
||||
global.document = {
|
||||
getElementById: id => registry.get(id) || null,
|
||||
querySelector: sel => {
|
||||
let m = /^#([\w-]+)$/.exec(sel);
|
||||
if (m) return registry.get(m[1]) || null;
|
||||
m = /^#([\w-]+)\s+\[([\w-]+)="([^"]+)"\]$/.exec(sel);
|
||||
if (m) {
|
||||
const parent = registry.get(m[1]);
|
||||
if (!parent) return null;
|
||||
return parent.children.find(c => c.getAttribute(m[2]) === m[3]) || null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
dispatchEvent() {}, addEventListener() {},
|
||||
};
|
||||
global.CustomEvent = class { constructor(t, o) { this.type = t; Object.assign(this, o); } };
|
||||
global.window = global;
|
||||
|
||||
// ── build the toolbar exactly as plugins.html declares it ─────────────────
|
||||
mk('installed-search');
|
||||
mk('installed-search-clear');
|
||||
mk('installed-sort');
|
||||
mk('installed-clear-filters');
|
||||
mk('installed-count');
|
||||
mk('installed-updates-count');
|
||||
mk('installed-plugins-grid');
|
||||
const pills = mk('installed-filter-pills');
|
||||
['all', 'enabled', 'disabled', 'updates'].forEach(v => {
|
||||
const b = new El(null, { 'data-installed-filter': v });
|
||||
pills.append(b);
|
||||
});
|
||||
document.getElementById('installed-sort').value = 'a-z';
|
||||
|
||||
// ── load the helper ───────────────────────────────────────────────────────
|
||||
const ListFilter = require(path.join(V3, 'js/plugins/list_filter.js'));
|
||||
global.ListFilter = ListFilter;
|
||||
|
||||
// ── extract the installed-plugins config verbatim from plugins_manager.js ──
|
||||
const src = fs.readFileSync(path.join(V3, 'plugins_manager.js'), 'utf8');
|
||||
const start = src.indexOf('function installedSortName(plugin)');
|
||||
const endMark = 'function setupInstalledFilterListeners()';
|
||||
const end = src.indexOf(endMark);
|
||||
if (start < 0 || end < 0) { console.error('FAIL: could not locate installed filter block'); process.exit(1); }
|
||||
const block = src.slice(start, end);
|
||||
|
||||
// deps the block expects from its enclosing IIFE
|
||||
let rendered = null;
|
||||
global.installedPlugins = [];
|
||||
global.pluginLog = () => {};
|
||||
function renderInstalledCards(list, total) { rendered = { list, total }; }
|
||||
|
||||
eval(block); // defines installedSortName/comparators/getInstalledFilter/etc.
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────
|
||||
const P = (id, o) => Object.assign(
|
||||
{ id, name: id, enabled: true, update_available: false, category: 'general',
|
||||
description: '', author: 'someone', tags: [], version: '1.0.0', last_updated: null }, o);
|
||||
|
||||
const FIXTURE = [
|
||||
P('zulu-clock', { name: 'Zulu Clock', enabled: true, category: 'time', last_updated: '2026-01-05' }),
|
||||
P('alpha-weather', { name: 'Alpha Weather', enabled: false, category: 'weather', last_updated: '2026-03-11', update_available: true, latest_version: '2.0.0' }),
|
||||
P('mid-stocks', { name: 'Mid Stocks', enabled: true, category: 'financial', last_updated: null, update_available: true, latest_version: '3.1.0' }),
|
||||
P('no-date', { name: 'No Date Here', enabled: false, category: 'general', last_updated: null }),
|
||||
P('taggy', { name: 'Taggy', enabled: true, category: 'media', last_updated: '2026-06-30', tags: ['hockey', 'nhl'] }),
|
||||
P('bad-date', { name: 'Bad Date', enabled: true, category: 'general', last_updated: 'not-a-date' }),
|
||||
];
|
||||
global.installedPlugins = FIXTURE;
|
||||
window.installedPlugins = FIXTURE;
|
||||
|
||||
const ctl = getInstalledFilter();
|
||||
if (!ctl) { console.error('FAIL: controller not created'); process.exit(1); }
|
||||
ctl.bind();
|
||||
|
||||
// ── assertions ────────────────────────────────────────────────────────────
|
||||
let pass = 0, fail = 0;
|
||||
function ok(label, cond, extra) {
|
||||
if (cond) { pass++; console.log(' ok ' + label); }
|
||||
else { fail++; console.log(' FAIL ' + label + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
||||
}
|
||||
const ids = () => rendered.list.map(p => p.id);
|
||||
const countText = () => document.getElementById('installed-count').textContent;
|
||||
const badge = () => document.getElementById('installed-updates-count');
|
||||
const pillOf = v => pills.children.find(c => c.getAttribute('data-installed-filter') === v);
|
||||
const setPill = v => pills.fire('click', pillOf(v));
|
||||
|
||||
console.log('\n1. default state (no filters)');
|
||||
ctl.apply();
|
||||
ok('renders all 6', rendered.list.length === 6, ids());
|
||||
ok('total is 6', rendered.total === 6);
|
||||
ok('sorted A→Z', ids().join(',') === 'alpha-weather,bad-date,mid-stocks,no-date,taggy,zulu-clock', ids());
|
||||
ok('count reads "6 installed"', countText() === '6 installed', countText());
|
||||
ok('updates badge shows 2', badge().textContent === '2' && !badge().classList.contains('hidden'), badge().textContent);
|
||||
ok('clear button hidden', document.getElementById('installed-clear-filters').classList.contains('hidden'));
|
||||
|
||||
console.log('\n2. pill: enabled / disabled partition');
|
||||
setPill('enabled');
|
||||
const enabledIds = ids();
|
||||
ok('enabled → 4', enabledIds.length === 4, enabledIds);
|
||||
ok('count reads "4 of 6 shown"', countText() === '4 of 6 shown', countText());
|
||||
ok('clear button visible', !document.getElementById('installed-clear-filters').classList.contains('hidden'));
|
||||
ok('enabled pill lit', pillOf('enabled').getAttribute('data-active') === 'true');
|
||||
ok('all pill unlit', pillOf('all').getAttribute('data-active') === 'false');
|
||||
ok('aria-pressed set', pillOf('enabled').getAttribute('aria-pressed') === 'true');
|
||||
setPill('disabled');
|
||||
const disabledIds = ids();
|
||||
ok('disabled → 2', disabledIds.length === 2, disabledIds);
|
||||
ok('partition is exact, no overlap/loss',
|
||||
enabledIds.length + disabledIds.length === 6 &&
|
||||
!enabledIds.some(i => disabledIds.includes(i)));
|
||||
|
||||
console.log('\n3. pill: updates');
|
||||
setPill('updates');
|
||||
ok('updates → only update_available', ids().join(',') === 'alpha-weather,mid-stocks', ids());
|
||||
ok('badge count unaffected by filtering', badge().textContent === '2');
|
||||
|
||||
console.log('\n4. search');
|
||||
setPill('all');
|
||||
const searchEl = document.getElementById('installed-search');
|
||||
function search(text) { searchEl.value = text; ctl.setSearch(text); }
|
||||
search('weather');
|
||||
ok('name match', ids().join(',') === 'alpha-weather', ids());
|
||||
search('WEATHER');
|
||||
ok('case-insensitive', ids().join(',') === 'alpha-weather', ids());
|
||||
search('nhl');
|
||||
ok('matches tags[]', ids().join(',') === 'taggy', ids());
|
||||
search('financial');
|
||||
ok('matches category', ids().join(',') === 'mid-stocks', ids());
|
||||
search('someone');
|
||||
ok('matches author → all 6', ids().length === 6);
|
||||
search(' zulu ');
|
||||
ok('trims whitespace', ids().join(',') === 'zulu-clock', ids());
|
||||
search('zzzznope');
|
||||
ok('no match → empty list, total preserved', rendered.list.length === 0 && rendered.total === 6);
|
||||
ok('count reads "0 of 6 shown"', countText() === '0 of 6 shown', countText());
|
||||
// The haystack joins fields in the CONFIGURED order (name, id, description,
|
||||
// author, category, tags), so a query may straddle a field boundary. The
|
||||
// fixtures deliberately insert `id` before `name`, so reading the object's own
|
||||
// entry order instead would break this.
|
||||
search('zulu clock zulu-clock');
|
||||
ok('phrase spanning name→id matches (field order preserved)',
|
||||
ids().join(',') === 'zulu-clock', ids());
|
||||
search('zulu-clock zulu clock');
|
||||
ok('the reverse (object key order) does NOT match', rendered.list.length === 0, ids());
|
||||
search('taggy media');
|
||||
ok('phrase spanning category→tags respects field order', ids().length === 0, ids());
|
||||
search('');
|
||||
ok('cleared → all 6 back', ids().length === 6);
|
||||
ok('count back to "6 installed"', countText() === '6 installed', countText());
|
||||
|
||||
console.log('\n5. search + pill combine (AND)');
|
||||
setPill('enabled');
|
||||
search('a');
|
||||
const both = rendered.list;
|
||||
ok('all results enabled', both.every(p => p.enabled), ids());
|
||||
ok('all results match "a"', both.every(p => (p.name + p.id + p.category + p.author).toLowerCase().includes('a')));
|
||||
search('');
|
||||
setPill('all');
|
||||
|
||||
console.log('\n6. sorts');
|
||||
const sortEl = document.getElementById('installed-sort');
|
||||
function sort(v) { sortEl.value = v; sortEl.fire('change'); }
|
||||
sort('z-a');
|
||||
ok('z-a reverses', ids().join(',') === 'zulu-clock,taggy,no-date,mid-stocks,bad-date,alpha-weather', ids());
|
||||
sort('status');
|
||||
const st = ids();
|
||||
ok('status: updates first', st.slice(0, 2).sort().join(',') === 'alpha-weather,mid-stocks', st);
|
||||
ok('status: disabled last', st[st.length - 1] === 'no-date', st);
|
||||
sort('recent');
|
||||
const rec = ids();
|
||||
ok('recent: newest first', rec[0] === 'taggy' && rec[1] === 'alpha-weather' && rec[2] === 'zulu-clock', rec);
|
||||
ok('recent: missing/unparseable dates last',
|
||||
['bad-date', 'mid-stocks', 'no-date'].every(i => rec.indexOf(i) >= 3), rec);
|
||||
ok('recent: nothing dropped', rec.length === 6);
|
||||
sort('category');
|
||||
const cat = ids();
|
||||
ok('category groups', cat.join(',') === 'mid-stocks,bad-date,no-date,taggy,zulu-clock,alpha-weather', cat);
|
||||
sort('a-z');
|
||||
|
||||
console.log('\n7. clear filters');
|
||||
search('taggy'); setPill('updates'); sort('z-a');
|
||||
ok('3 axes active', ctl.activeCount() === 3, ctl.activeCount());
|
||||
document.getElementById('installed-clear-filters').fire('click');
|
||||
ok('reset → all 6', ids().length === 6, ids());
|
||||
ok('reset clears search input', searchEl.value === '');
|
||||
ok('reset restores sort to a-z', ctl.state.sort === 'a-z' && sortEl.value === 'a-z');
|
||||
ok('reset relights All pill', pillOf('all').getAttribute('data-active') === 'true');
|
||||
ok('activeCount back to 0', ctl.activeCount() === 0);
|
||||
|
||||
console.log('\n8. sticky: the just-toggled card must not vanish');
|
||||
setPill('enabled');
|
||||
ok('zulu-clock visible while enabled', ids().includes('zulu-clock'));
|
||||
ctl.sticky.add('zulu-clock');
|
||||
FIXTURE[0].enabled = false; // simulate the toggle landing
|
||||
ctl.apply();
|
||||
ok('still visible after being disabled', ids().includes('zulu-clock'), ids());
|
||||
ok('but total unchanged', rendered.total === 6);
|
||||
setPill('enabled'); // user touches toolbar → sticky clears
|
||||
ok('sticky cleared on toolbar use', !ids().includes('zulu-clock'), ids());
|
||||
FIXTURE[0].enabled = true;
|
||||
setPill('all');
|
||||
|
||||
console.log('\n9. state-leak guard: window.installedPlugins must stay whole');
|
||||
search('taggy');
|
||||
ok('grid shows 1', rendered.list.length === 1);
|
||||
ok('window.installedPlugins still 6', window.installedPlugins.length === 6, window.installedPlugins.length);
|
||||
search('');
|
||||
|
||||
console.log('\n10. updates badge when nothing needs updating');
|
||||
const noUpd = FIXTURE.map(p => ({ ...p, update_available: false }));
|
||||
window.installedPlugins = noUpd; global.installedPlugins = noUpd;
|
||||
ctl.apply();
|
||||
ok('badge hidden at 0', badge().classList.contains('hidden'));
|
||||
ok('updates pill dimmed', pillOf('updates').classList.contains('opacity-50'));
|
||||
ok('pill title explains', /No plugins have updates/.test(pillOf('updates').title), pillOf('updates').title);
|
||||
window.installedPlugins = FIXTURE; global.installedPlugins = FIXTURE;
|
||||
ctl.apply();
|
||||
ok('badge back at 2', badge().textContent === '2' && !badge().classList.contains('hidden'));
|
||||
ok('pill undimmed', !pillOf('updates').classList.contains('opacity-50'));
|
||||
|
||||
console.log('\n11. empty installed list');
|
||||
window.installedPlugins = []; global.installedPlugins = [];
|
||||
ctl.apply();
|
||||
ok('renders nothing, total 0', rendered.list.length === 0 && rendered.total === 0);
|
||||
ok('count reads "0 installed"', countText() === '0 installed', countText());
|
||||
|
||||
console.log('\n12. defensive: missing fields');
|
||||
const ragged = [{ id: 'bare' }, { id: 'nulls', name: null, tags: null, category: null, last_updated: null }];
|
||||
window.installedPlugins = ragged; global.installedPlugins = ragged;
|
||||
sort('recent'); ctl.apply();
|
||||
ok('no throw on ragged records, both rendered', rendered.list.length === 2, ids());
|
||||
search('bare');
|
||||
ok('search works on ragged records', ids().join(',') === 'bare', ids());
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,109 @@
|
||||
// Verifies renderInstalledCards (extracted verbatim from plugins_manager.js):
|
||||
// the two empty states, the card markup, and that it never publishes state.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
||||
|
||||
function slice(fromMark, toMark) {
|
||||
const a = src.indexOf(fromMark);
|
||||
const b = src.indexOf(toMark, a + 1);
|
||||
if (a < 0 || b < 0) { console.error('FAIL: cannot locate ' + fromMark); process.exit(1); }
|
||||
return src.slice(a, b);
|
||||
}
|
||||
|
||||
const container = {
|
||||
innerHTML: '',
|
||||
querySelectorAll: () => [], // no skeletons in this harness
|
||||
};
|
||||
// escapeHtml() escapes via a detached element, so mirror what a browser does
|
||||
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
|
||||
class FakeEl {
|
||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||
get textContent() { return this._t || ''; }
|
||||
get innerHTML() {
|
||||
return (this._t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
global.document = {
|
||||
getElementById: id => (id === 'installed-plugins-grid' ? container : null),
|
||||
createElement: () => new FakeEl(),
|
||||
};
|
||||
global.window = global;
|
||||
global.pluginLog = () => {};
|
||||
global.PLUGIN_DEBUG = false;
|
||||
global.debugLog = () => {};
|
||||
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
||||
|
||||
eval(slice('function escapeHtml(text)', '\nfunction ', ));
|
||||
eval(slice('function renderInstalledCards(plugins, total)',
|
||||
'// Set up event delegation for plugin action buttons'));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
const SENTINEL = ['do', 'not', 'touch'];
|
||||
window.installedPlugins = SENTINEL;
|
||||
|
||||
console.log('\n1. no plugins installed at all');
|
||||
renderInstalledCards([], 0);
|
||||
ok('shows "No plugins installed"', /No plugins installed/.test(container.innerHTML));
|
||||
ok('does NOT show the filter empty state', !/No plugins match/.test(container.innerHTML));
|
||||
ok('uses the plug icon', /fa-plug/.test(container.innerHTML));
|
||||
|
||||
console.log('\n2. plugins installed but none match the filters');
|
||||
renderInstalledCards([], 12);
|
||||
ok('shows "No plugins match your filters"', /No plugins match your filters/.test(container.innerHTML));
|
||||
ok('does NOT claim nothing is installed', !/No plugins installed/.test(container.innerHTML));
|
||||
ok('reports the installed total', /12 plugins installed/.test(container.innerHTML), container.innerHTML.match(/\d+ plugins? installed/));
|
||||
ok('offers a Clear filters button', /data-action="clear-installed-filters"/.test(container.innerHTML));
|
||||
ok('uses the filter icon', /fa-filter/.test(container.innerHTML));
|
||||
|
||||
console.log('\n3. singular/plural of the installed total');
|
||||
renderInstalledCards([], 1);
|
||||
ok('"1 plugin installed" (singular)', /1 plugin installed/.test(container.innerHTML) && !/1 plugins/.test(container.innerHTML));
|
||||
|
||||
console.log('\n4. real cards');
|
||||
const plugins = [
|
||||
{ id: 'alpha', name: 'Alpha', author: 'Ann', version: '1.0.0', category: 'weather',
|
||||
description: 'Shows weather', enabled: true, tags: ['a', 'b'], verified: true },
|
||||
{ id: 'beta', name: 'Beta', author: 'Bob', version: '1.0.0', latest_version: '2.0.0',
|
||||
update_available: true, category: 'time', description: 'Clock', enabled: false },
|
||||
];
|
||||
renderInstalledCards(plugins, 5);
|
||||
const html = container.innerHTML;
|
||||
ok('renders 2 cards', (html.match(/class="plugin-card"/g) || []).length === 2);
|
||||
ok('enabled card shows Enabled', /<span>Enabled<\/span>/.test(html));
|
||||
ok('disabled card shows Disabled', /<span>Disabled<\/span>/.test(html));
|
||||
ok('update badge shows target version', /v2\.0\.0 available/.test(html));
|
||||
ok('update button labelled "Update to v2.0.0"', /Update to v2\.0\.0/.test(html));
|
||||
ok('pulsing ring class on the outdated one', /plugin-update-available/.test(html));
|
||||
ok('verified badge present', /Verified/.test(html));
|
||||
ok('tags rendered', /badge-info">a</.test(html) && /badge-info">b</.test(html));
|
||||
ok('per-plugin action hooks present',
|
||||
/data-action="configure"/.test(html) && /data-action="update"/.test(html) &&
|
||||
/data-action="uninstall"/.test(html) && /data-action="toggle"/.test(html));
|
||||
ok('no empty state alongside cards', !/empty-state/.test(html));
|
||||
|
||||
console.log('\n5. it must not publish state');
|
||||
ok('window.installedPlugins untouched', window.installedPlugins === SENTINEL, window.installedPlugins);
|
||||
|
||||
console.log('\n6. total defaults to list length when omitted');
|
||||
renderInstalledCards([], undefined);
|
||||
ok('omitted total + empty list → "no plugins installed"', /No plugins installed/.test(container.innerHTML));
|
||||
|
||||
console.log('\n7. XSS: hostile plugin metadata is escaped');
|
||||
renderInstalledCards([{
|
||||
id: 'evil', name: '<img src=x onerror=alert(1)>', author: '"><script>bad()</script>',
|
||||
version: '1.0.0', category: 'x', description: '<b>nope</b>', enabled: true, tags: ['<i>t</i>'],
|
||||
}], 1);
|
||||
const evil = container.innerHTML;
|
||||
ok('no raw <img', !/<img src=x/.test(evil));
|
||||
ok('no raw <script', !/<script>bad/.test(evil));
|
||||
ok('no raw <b> from description', !/<b>nope<\/b>/.test(evil));
|
||||
ok('no raw <i> from tags', !/<i>t<\/i>/.test(evil));
|
||||
ok('escaped entities present instead', /</.test(evil));
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user