diff options
| author | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-09 23:58:35 +0100 |
|---|---|---|
| committer | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-09 23:58:35 +0100 |
| commit | 9f6744dd5cb99b904f9a23fce6f2d98e8332bbf8 (patch) | |
| tree | d3f3a525e8594db010bbf0e2aa6e4c2590445339 /modules/test/test-wpm-index.js | |
| parent | 8e3a6155f6a5a1266ff1fa801c8864484bcdbda9 (diff) | |
fix(newos4): six crash classes from the feedback queue (v4.6.158)
Each item is symptom -> cause -> fix, re-verified against the served bytes.
1. debug-ai panel threw "Cannot read properties of undefined (reading 'substring')"
(RPT-20261108-231622). _updateDebugPanel and two sibling previews called
JSON.stringify(entry.args).substring(); JSON.stringify(undefined) returns
undefined, and _toolCallLog entries carry args: undefined for any tool call
with no arguments. All three previews now go through _formatToolArgs(), which
renders "null" and never throws.
2. test_module threw "Cannot convert undefined or null to object"
(RPT-20261108-232127). Object.keys(instance.commands) assumed every module
initialises a command map. The throw landed after loadModuleFromCode had
already succeeded, so the rolledBack:true branches were skipped and an
unverified module stayed loaded. Both sites now require instance.commands.
3. list_files threw "originalPath.replace is not a function" (RPT-20261109-172742,
3 occurrences in errors.json). A model can send a non-string path despite the
schema. The path family (list_files/read_file/write_file/make_directory/
delete_file) now coerces through _pathArg() and answers the tool's own honest
error instead of a TypeError.
4. wpm search failed for EVERY keyword (RPT-20261109-123907): packages.json entry
`kanban` used `description` while the other 31 use `desc`, and search() called
pkg.desc.toLowerCase() mid-loop. Catalog reads now go through _pkg() /
_packageMap() (desc || description, values coerced to strings), and the kanban
entry carries name/version/desc/commands. Re-running the original loop for
pdf/git/zzz is clean.
5. wx/webxai/webx turned a chat-lock timeout into an unhandled rejection
(RPT-20261107-150726): forwardToAI() called chat(args) in a synchronous
function without returning or catching the promise, so the 10 s busy throw
never reached dispatchCommand's catch. It now returns the promise with a
.catch that prints "AI error: ..." through terminal.print — console.error is
hooked by the debug supervisor and would re-file the same report.
6. CryptoGzip._base64ToBytes threw a raw DOMException on non-base64 input
(RPT-20261102-203616). It now validates first and throws
Error("not valid base64 input (<reason>)"). The duplicate shadowing
definitions at the bottom of the class were deleted — the later copy is the
one that carried the unguarded atob().
Also regenerated modules/core/core-bundle.js (generated, never hand-edited) and
bumped the build v4.6.157 -> v4.6.158 (main.html x3 + module-loader.js).
Verified: 35/35 node tests, including 6 new regression tests each proven red
against the pre-fix source; an independent orchestrator harness 36/36; and
newos4-maintain.py --fix all PASS after regenerating the stale changelog page.
Served bytes are byte-identical for the bundle, main.html and both ext modules,
and a live headless-Chromium run against v4.6.158 shows WpmModule.search('pdf')
returning 1 result, the debug panel rendering a no-arg entry as "null",
_base64ToBytes('grüße') raising a plain Error that names base64, and 0 JS errors
on load.
m.pull (RPT-20261102-211438) is closed as a usage error: no module in the tree
defines pull(); the real interfaces are the webdav-pull and model-pull commands.
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); +}); |