// 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(`
${partial}
`, { 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 => '
').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); });