mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
A new "Web UI JS tests" job installs jsdom, starts the web interface in emulator mode and runs test/js/run_all.js with REQUIRE_DOM=1, which makes a DOM suite that can't run a failure rather than a silent skip. (The unit suites were already covered through pytest.) Two suites failed against main when run for real: - test_tools_sections rendered the Tools partial without LEDEscape, which base.html's app-early.js defines; it now installs it in beforeParse, and supplies two sample Starlark apps (one id with a quote) when the server has none, instead of assuming a device with apps and Pixlet. - test_store_dom assumed the live registry had at most 48 plugins; it now checks pagination whichever side of 48 it is. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
13 KiB
JavaScript
227 lines
13 KiB
JavaScript
// Real-DOM (jsdom) test of the migrated Plugin Store toolbar, against the
|
||
// server's actual plugin registry (48+ plugins) — enough data for 4 pages, so pagination,
|
||
// the ellipsis strip and per-page changes are exercised for real.
|
||
// The card renderer itself is stubbed: Step 2 did not touch it, and stubbing
|
||
// keeps the assertions on the parts that did change.
|
||
const fs = require('fs');
|
||
const http = require('http');
|
||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||
|
||
const path = require('path');
|
||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||
const get = path => new Promise((res, rej) =>
|
||
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||
|
||
function slice(src, a, b) {
|
||
const i = src.indexOf(a), j = src.indexOf(b, i + 1);
|
||
if (i < 0 || j < 0) throw new Error('cannot slice: ' + a);
|
||
return src.slice(i, j);
|
||
}
|
||
|
||
(async () => {
|
||
const partial = await get('/partials/plugins');
|
||
const storeJson = JSON.parse(await get('/api/v3/plugins/store/list'));
|
||
const storePlugins = (storeJson.data && storeJson.data.plugins) || storeJson.plugins || [];
|
||
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
|
||
|
||
const jsErrors = [];
|
||
const vc = new VirtualConsole();
|
||
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
|
||
|
||
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
|
||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
|
||
const { window } = dom;
|
||
const { document } = window;
|
||
|
||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
||
|
||
function boot() {
|
||
const s = document.createElement('script');
|
||
// IIFE-wrapped so a second boot gets a fresh closure instead of clashing on
|
||
// the top-level `const ListFilter`; it still publishes via window.ListFilter.
|
||
s.textContent = '(function(){\n' + fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8') + '\n})();';
|
||
document.body.appendChild(s);
|
||
|
||
const s2 = document.createElement('script');
|
||
s2.textContent = `(function(){
|
||
${slice(src, 'const safeLocalStorage = {', '\n// ')}
|
||
var installedPlugins = ${JSON.stringify(installed)};
|
||
window.installedPlugins = installedPlugins;
|
||
var pluginStoreCache = ${JSON.stringify(storePlugins)};
|
||
window.__renderCalls = 0;
|
||
// Stand-in for renderPluginStore: real DOM nodes, minimal markup.
|
||
function renderPluginStore(plugins) {
|
||
window.__renderCalls++;
|
||
const grid = document.getElementById('plugin-store-grid');
|
||
grid.innerHTML = (plugins || []).map(p =>
|
||
'<div class="plugin-card" data-id="' + p.id + '"></div>').join('');
|
||
}
|
||
${slice(src, 'function isStorePluginInstalled(', '// Expose searchPluginStore')}
|
||
window.__t = { applyStoreFiltersAndSort, setupStoreFilterListeners, getStoreFilter };
|
||
})();`;
|
||
document.body.appendChild(s2);
|
||
}
|
||
boot();
|
||
if (jsErrors.length) { console.log('FAIL loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
|
||
|
||
const T = window.__t;
|
||
const $ = id => document.getElementById(id);
|
||
const cards = () => $('plugin-store-grid').querySelectorAll('.plugin-card').length;
|
||
const ids = () => [...$('plugin-store-grid').querySelectorAll('.plugin-card')].map(c => c.dataset.id);
|
||
const info = () => $('store-results-info').textContent.trim();
|
||
const infoBot = () => $('store-results-info-bottom') ? $('store-results-info-bottom').textContent.trim() : null;
|
||
const pagTop = () => $('store-pagination-top').innerHTML;
|
||
const pageBtns = () => [...$('store-pagination-top').querySelectorAll('[data-list-page]')];
|
||
const clickPage = label => {
|
||
const b = pageBtns().find(x => x.textContent.trim() === String(label));
|
||
if (!b) throw new Error('no page button labelled ' + label);
|
||
b.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
};
|
||
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
|
||
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
|
||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||
|
||
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, 240) : '')));
|
||
|
||
console.log(`\n── store, real DOM, ${storePlugins.length} plugins from the live registry ──`);
|
||
|
||
T.setupStoreFilterListeners();
|
||
T.applyStoreFiltersAndSort();
|
||
|
||
const N = storePlugins.length;
|
||
ok('page 1 holds 12 cards', cards() === 12, cards());
|
||
ok('results info counts all ' + N, info() === `Showing 1–12 of ${N} plugins`, info());
|
||
ok('bottom info matches top', infoBot() === info(), { top: info(), bottom: infoBot() });
|
||
ok('pagination rendered', pageBtns().length > 0);
|
||
ok('per-page control shows 12', $('store-per-page').value === '12');
|
||
|
||
const totalPages = Math.ceil(N / 12);
|
||
ok(`last page button is ${totalPages}`,
|
||
pageBtns().some(b => b.textContent.trim() === String(totalPages)), pagTop().slice(0, 200));
|
||
ok('prev is disabled on page 1', pageBtns()[0].hasAttribute('disabled'));
|
||
|
||
const p1 = ids();
|
||
clickPage(2);
|
||
ok('page 2 renders 12 different cards', cards() === 12 && ids().join() !== p1.join());
|
||
ok('page 2 info', info() === `Showing 13–24 of ${N} plugins`, info());
|
||
clickPage(totalPages);
|
||
ok('last page info ends at ' + N, new RegExp(`–${N} of ${N} plugins$`).test(info()), info());
|
||
ok('next is disabled on the last page',
|
||
pageBtns()[pageBtns().length - 1].hasAttribute('disabled'));
|
||
ok('ellipsis appears for a far page', /…|…/.test(pagTop()), pagTop().slice(0, 120));
|
||
clickPage(1);
|
||
ok('back on page 1 shows the original slice', ids().join() === p1.join());
|
||
|
||
// ── per-page ───────────────────────────────────────────────────────────
|
||
change($('store-per-page'), '48');
|
||
ok('per-page 48 shows all in one page', cards() === Math.min(48, N), cards());
|
||
// The live registry outgrew 48 plugins, so check whichever case it is.
|
||
if (N <= 48) {
|
||
ok('pagination hidden when one page', pagTop().trim() === '' || pageBtns().length === 0, pagTop().slice(0, 80));
|
||
} else {
|
||
ok('pagination still shown when 48 per page leaves more', pageBtns().length > 0, pagTop().slice(0, 80));
|
||
}
|
||
ok('per-page persisted', window.localStorage.getItem('storePerPage') === '48');
|
||
change($('store-per-page'), '12');
|
||
ok('back to 12 restores pagination', cards() === 12 && pageBtns().length > 0);
|
||
|
||
// ── sorts ──────────────────────────────────────────────────────────────
|
||
const firstOf = () => ids()[0];
|
||
change($('store-sort'), 'a-z');
|
||
const azFirst = firstOf();
|
||
change($('store-sort'), 'z-a');
|
||
ok('z-a changes the leading card', firstOf() !== azFirst, { az: azFirst, za: firstOf() });
|
||
ok('sort persisted to localStorage', window.localStorage.getItem('storeSort') === 'z-a');
|
||
for (const s of ['category', 'author', 'newest']) {
|
||
change($('store-sort'), s);
|
||
ok(`sort "${s}" still fills a page`, cards() === 12, cards());
|
||
}
|
||
change($('store-sort'), 'a-z');
|
||
|
||
// ── category ───────────────────────────────────────────────────────────
|
||
const cat = [...$('plugin-category').options].map(o => o.value).find(v => v && storePlugins.some(p => (p.category || '').toLowerCase() === v.toLowerCase()));
|
||
if (cat) {
|
||
change($('plugin-category'), cat);
|
||
const expect = storePlugins.filter(p => (p.category || '').toLowerCase() === cat.toLowerCase()).length;
|
||
ok(`category "${cat}" filters to ${expect}`, cards() === Math.min(expect, 12), { cards: cards(), expect });
|
||
ok('badge reports active filters', /filter/.test($('store-active-filters').textContent),
|
||
$('store-active-filters').textContent);
|
||
change($('plugin-category'), '');
|
||
ok('category cleared restores', cards() === 12);
|
||
} else { console.log(' -- no usable category option in the template, skipped'); }
|
||
|
||
// ── tri-state installed button ─────────────────────────────────────────
|
||
const instBtn = $('store-filter-installed');
|
||
ok('starts as All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'), instBtn.innerHTML);
|
||
click(instBtn);
|
||
ok('→ Installed, green', /Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-green-50')
|
||
&& !instBtn.classList.contains('bg-white'), instBtn.innerHTML);
|
||
const instCount = storePlugins.filter(p => installed.some(i => i.id === p.id ||
|
||
(p.plugin_path && i.id === p.plugin_path.split('/').pop()))).length;
|
||
ok('Installed count matches the installed set', cards() === Math.min(instCount, 12), { cards: cards(), expect: instCount });
|
||
click(instBtn);
|
||
ok('→ Not Installed, red', /Not Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-red-50'), instBtn.innerHTML);
|
||
ok('Not Installed count is the complement', cards() === Math.min(N - instCount, 12), { cards: cards(), expect: N - instCount });
|
||
click(instBtn);
|
||
ok('→ back to All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'));
|
||
|
||
// ── search ─────────────────────────────────────────────────────────────
|
||
const term = (storePlugins[0].name || storePlugins[0].id).slice(0, 4);
|
||
type($('plugin-search'), term);
|
||
await tick(400);
|
||
ok(`search "${term}" narrows the set`, cards() > 0 && cards() <= 12, cards());
|
||
type($('plugin-search'), 'zzz-definitely-nothing');
|
||
await tick(400);
|
||
ok('no matches → 0 cards', cards() === 0);
|
||
ok('no-match info text', info() === 'No plugins match your filters', info());
|
||
type($('plugin-search'), '');
|
||
await tick(400);
|
||
ok('search cleared restores', cards() === 12);
|
||
|
||
// ── clear filters ──────────────────────────────────────────────────────
|
||
change($('store-per-page'), '24');
|
||
change($('store-sort'), 'z-a');
|
||
if (cat) change($('plugin-category'), cat);
|
||
click(instBtn);
|
||
type($('plugin-search'), term);
|
||
await tick(400);
|
||
ok('clear button visible with filters active', !$('store-clear-filters').classList.contains('hidden'));
|
||
click($('store-clear-filters'));
|
||
ok('clear resets search', $('plugin-search').value === '');
|
||
ok('clear resets sort to a-z', $('store-sort').value === 'a-z');
|
||
ok('clear resets category', $('plugin-category').value === '');
|
||
ok('clear resets installed button to All', /All/.test(instBtn.innerHTML));
|
||
ok('clear PRESERVES per-page 24', $('store-per-page').value === '24', $('store-per-page').value);
|
||
ok('clear shows a full 24-card page', cards() === 24, cards());
|
||
ok('clear hides itself', $('store-clear-filters').classList.contains('hidden'));
|
||
|
||
// ── persistence across a reload ────────────────────────────────────────
|
||
change($('store-sort'), 'author');
|
||
change($('store-per-page'), '48');
|
||
const saved = { sort: window.localStorage.getItem('storeSort'), per: window.localStorage.getItem('storePerPage') };
|
||
ok('prefs written under the original keys', saved.sort === 'author' && saved.per === '48', saved);
|
||
|
||
// Re-swap the partial (fresh controls at defaults) and re-boot the module, as
|
||
// a page reload would.
|
||
document.getElementById('app').innerHTML = partial;
|
||
ok('fresh markup starts at defaults',
|
||
$('store-sort').value === 'a-z' && $('store-per-page').value === '12');
|
||
const controllerBefore = window.__t;
|
||
boot(); // fresh module instances, fresh localStorage read
|
||
ok('re-boot produced a new module instance', window.__t !== controllerBefore);
|
||
window.__t.setupStoreFilterListeners();
|
||
window.__t.applyStoreFiltersAndSort();
|
||
ok('reload restores sort=author into the control', $('store-sort').value === 'author', $('store-sort').value);
|
||
ok('reload restores per-page=48 into the control', $('store-per-page').value === '48', $('store-per-page').value);
|
||
ok('reload renders with the restored page size', cards() === Math.min(48, N), cards());
|
||
|
||
ok('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3));
|
||
|
||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||
process.exit(fail ? 1 : 0);
|
||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|