// node modules/test/test-wpm-index.js 'use strict'; // Regression test — RPT-20261109-123907-5a6273ab (source tool: wpm_search) // // packages.json `kanban` was authored with `description` while the other 31 entries // use `desc`, and modules/ext/wpm.js read `pkg.desc.toLowerCase()` inside search(). // Result: EVERY `wpm search ` threw // TypeError: Cannot read properties of undefined (reading 'toLowerCase') // for any keyword (pdf, git, zzz) because the bad entry sits mid-loop. // // This test loads the real served catalog + the real wpm.js (no network, no browser) // and asserts that search/list/info/remove/install cannot throw on it, and that an // entry with no desc/category at all is also survivable. // // Run: node modules/test/test-wpm-index.js const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..', '..'); const WPM_SRC = path.join(ROOT, 'modules', 'ext', 'wpm.js'); const CATALOG = path.join(ROOT, 'packages.json'); let checks = 0; let failures = 0; function ok(cond, label) { checks++; if (cond) { console.log(' ok ' + label); return true; } failures++; console.error(' FAIL ' + label); return false; } // ── Load wpm.js exactly as the browser would, minus the browser ────────────── // window is an empty object (a real browser has window.webx11 undefined until // module-loader.js runs), so the self-registration block at the bottom is skipped. const code = fs.readFileSync(WPM_SRC, 'utf8'); const store = {}; const fakeLocalStorage = { getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; }, }; const noNetwork = () => Promise.reject(new Error('network disabled in test')); const WpmModule = new Function('window', 'localStorage', 'fetch', code + '\n;return WpmModule;') ({}, fakeLocalStorage, noNetwork); function makeWpm(catalog) { const logs = []; const m = new WpmModule(); m.terminal = { print: (...args) => { logs.push(args.map((a) => String(a)).join(' ')); }, moduleManager: { loadModuleFromURL: async () => true, unregisterModule: () => true, }, }; m.installed = {}; m.saveInstalled = () => {}; // Pre-seed the index: loadIndex() returns this.index and never fetches. m.index = catalog || JSON.parse(fs.readFileSync(CATALOG, 'utf8')); return { m, logs }; } async function main() { const raw = JSON.parse(fs.readFileSync(CATALOG, 'utf8')); const catalog = raw.packages; const names = Object.keys(catalog); console.log('\n[1] catalog shape + normalisation (' + names.length + ' packages)'); ok(names.length === 32, 'catalog still has 32 packages'); ok(!!catalog.kanban, 'kanban entry exists (the entry that broke search)'); const { m: probe } = makeWpm(); const map = probe._packageMap(probe.index); const missingDesc = names.filter((n) => !map[n] || typeof map[n].desc !== 'string' || map[n].desc.length === 0); const missingCat = names.filter((n) => !map[n] || typeof map[n].category !== 'string' || map[n].category.length === 0); ok(missingDesc.length === 0, 'every entry normalises to a non-empty string desc' + (missingDesc.length ? ' — bad: ' + missingDesc.join(',') : '')); ok(missingCat.length === 0, 'every entry normalises to a non-empty string category' + (missingCat.length ? ' — bad: ' + missingCat.join(',') : '')); names.forEach((n) => { ok(typeof catalog[n].url === 'string' && catalog[n].url.length > 0, n + ': raw url present'); }); ok(typeof catalog.kanban.desc === 'string' && catalog.kanban.desc.length > 0, 'kanban now carries the canonical `desc` field'); ok(catalog.kanban.description === undefined || catalog.kanban.description === catalog.kanban.desc, 'kanban legacy `description` (kept for other readers) agrees with desc'); console.log('\n[2] search over the real catalog for the keywords that used to throw'); for (const kw of ['pdf', 'git', 'zzz']) { const { m, logs } = makeWpm(); let err = null; try { await m.search(kw); } catch (e) { err = e; } ok(!err, 'search("' + kw + '") did not throw' + (err ? ' — ' + err.name + ': ' + err.message : '')); ok(logs.length > 0, 'search("' + kw + '") printed something (' + logs.length + ' lines)'); ok(!logs.some((l) => l.includes('undefined')), 'search("' + kw + '") printed no "undefined"'); if (kw === 'pdf') { // search() prints the entry's display `name` ("File Manager"), not the map key. ok(logs.some((l) => /pdf/i.test(l) && l.includes('File Manager')), 'search("pdf") finds the PDF-previewing file manager'); } else { ok(logs.some((l) => l.includes('No packages matching')), 'search("' + kw + '") reports no match cleanly'); } } console.log('\n[3] the other catalog readers (list / info / remove / install) survive kanban'); { const { m, logs } = makeWpm(); let err = null; try { await m.list(); await m.info('kanban'); } catch (e) { err = e; } ok(!err, 'list()+info("kanban") did not throw' + (err ? ' — ' + err.message : '')); ok(!logs.some((l) => l.includes('undefined')), 'list()+info("kanban") printed no "undefined"'); ok(logs.some((l) => l.includes('kanban - ')), 'list() renders the kanban line'); m.installed.kanban = { version: '1.0.0', installed: '2026-11-09T00:00:00Z' }; let rmErr = null; try { await m.remove('kanban'); } catch (e) { rmErr = e; } ok(!rmErr, 'remove("kanban") did not throw (name falls back to the map key)' + (rmErr ? ' — ' + rmErr.message : '')); const inst = makeWpm(); let inErr = null; try { await inst.m.install('kanban'); } catch (e) { inErr = e; } ok(!inErr, 'install("kanban") did not throw (catalog fetch is stubbed to fail)'); ok(!inst.logs.some((l) => l.includes('undefined')), 'install("kanban") printed no "undefined"'); ok(inst.logs.some((l) => l.includes('Installing Kanban Board v1.0.0')), 'install("kanban") prints the normalised name/version'); } console.log('\n[4] an entry with no desc/category/name/version at all cannot throw'); { const { m, logs } = makeWpm(); m.index.packages.brokenpkg = { url: 'modules/ext/brokenpkg.js' }; let err = null; try { await m.search('zzz'); await m.search('broken'); await m.list(); await m.info('brokenpkg'); } catch (e) { err = e; } ok(!err, 'field-less entry: search/list/info did not throw' + (err ? ' — ' + err.name + ': ' + err.message : '')); ok(logs.length > 0, 'field-less entry: something was printed'); ok(!logs.some((l) => l.includes('undefined')), 'field-less entry: no "undefined" leaked to the terminal'); ok(logs.some((l) => l.includes('brokenpkg [')), 'field-less entry: search hit renders empty category, not a crash'); } // also: an index whose `packages` key is missing entirely console.log('\n[5] malformed index (no `packages` object) cannot throw'); { const { m, logs } = makeWpm({ name: 'broken-index' }); let err = null; try { await m.search('pdf'); await m.list(); } catch (e) { err = e; } ok(!err, 'index without packages: search/list did not throw' + (err ? ' — ' + err.message : '')); ok(logs.some((l) => l.includes('No packages matching')), 'index without packages: clean "no match" output'); } console.log('\n' + (failures ? 'FAIL' : 'PASS') + ' — ' + (checks - failures) + '/' + checks + ' checks'); process.exit(failures ? 1 : 0); } main().catch((e) => { console.error('FAIL — unexpected error: ' + (e && e.stack || e)); process.exit(1); });