diff options
Diffstat (limited to 'modules/test/test-wpm-index.js')
| -rw-r--r-- | modules/test/test-wpm-index.js | 164 |
1 files changed, 164 insertions, 0 deletions
diff --git a/modules/test/test-wpm-index.js b/modules/test/test-wpm-index.js new file mode 100644 index 0000000..ae9cc35 --- /dev/null +++ b/modules/test/test-wpm-index.js @@ -0,0 +1,164 @@ +// 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 <kw>` 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); +}); |