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 | |
| 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.
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 61 | ||||
| -rw-r--r-- | modules/core/module-aiasm.js | 17 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 42 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/ext/cryptogzip.js | 42 | ||||
| -rw-r--r-- | modules/ext/wpm.js | 56 | ||||
| -rw-r--r-- | modules/test/test-ai-path-args.js | 143 | ||||
| -rw-r--r-- | modules/test/test-ai-test-module-commands.js | 105 | ||||
| -rw-r--r-- | modules/test/test-aiasm-rejection.js | 95 | ||||
| -rw-r--r-- | modules/test/test-cryptogzip-guard.js | 136 | ||||
| -rw-r--r-- | modules/test/test-debug-args-preview.js | 112 | ||||
| -rw-r--r-- | modules/test/test-wpm-index.js | 164 | ||||
| -rw-r--r-- | packages.json | 8 |
14 files changed, 930 insertions, 59 deletions
@@ -1350,13 +1350,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.157"></script> +<script src="modules/core/core-bundle.js?v=v4.6.158"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.157"></script> +<script src="modules/core/module-loader.js?v=v4.6.158"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.157"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.158"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 2b95828..69b5e1a 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -10496,6 +10496,28 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } + // Tool-call args are whatever the model sent: often a JSON string, but + // undefined when the call carried no arguments and an object when the + // caller passed one directly. JSON.stringify(undefined) is undefined, + // so the old inline `JSON.stringify(x).substring(...)` threw a + // TypeError inside the debug panel. Never throw here. + _formatToolArgs(args, max) { + let s; + if (typeof args === 'string') { + s = args; + } else { + try { s = JSON.stringify(args); } catch (_e) { s = String(args); } + if (s === undefined || s === null) s = 'null'; + } + const cap = (typeof max === 'number' && max > 0) ? max : 80; + return s.substring(0, cap); + } + // Path arguments are schema-typed as strings, but a model can send a + // number/array/object anyway. Coerce instead of letting .replace() / + // .startsWith() throw "originalPath.replace is not a function". + _pathArg(v) { + return typeof v === 'string' ? v : (v == null ? '' : String(v)); + } _updateDebugPanel() { if (!this._debugPanelEnabled) return; const panel = document.getElementById('debug-ai-panel'); @@ -10505,7 +10527,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re let toolHtml = ''; for (const entry of recent) { const statusClass = entry.error ? 'debug-entry-error' : 'debug-entry-ok'; - const argsStr = typeof entry.args === 'string' ? entry.args.substring(0, 60) : JSON.stringify(entry.args).substring(0, 60); + const argsStr = this._formatToolArgs(entry.args, 60); toolHtml += '<div class="debug-entry ' + statusClass + '">' + '<span class="debug-turn">#' + entry.turn + '</span> ' + '<span class="debug-name">' + this._escapeHtml(entry.name) + '</span> ' + @@ -10544,7 +10566,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' + '<span>🔧 AI Debug <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' + '<span style="display:flex;gap:4px;">' + - '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+m._formatToolArgs(e.args,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'📥 Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📥</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'💾 Debug log appended — \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">💾</button>' + '<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-ai-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">✕</span>' + @@ -11518,7 +11540,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { - const originalPath = a.path || ''; + const originalPath = this._pathArg(a.path); const p = originalPath.replace(/^\//, '').replace(/\/+$/, ''); const fsapi = this.terminal.fs; const saved = [...fsapi.currentPath]; @@ -11535,7 +11557,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size }))); } case 'read_file': { - const rp = a.path || ''; + const rp = this._pathArg(a.path); const savedR = [...this.terminal.fs.currentPath]; if (rp.startsWith('/')) this.terminal.fs.currentPath = []; const path = rp.replace(/^\//, ''); @@ -11574,7 +11596,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re }); } case 'write_file': { - const wp = a.path || ''; + const wp = this._pathArg(a.path); const savedW = [...this.terminal.fs.currentPath]; if (wp.startsWith('/')) this.terminal.fs.currentPath = []; const path = wp.replace(/^\//, ''); @@ -11593,7 +11615,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return `File written: ${wp} (${content.length} chars)`; } case 'make_directory': { - const mp = a.path || ''; + const mp = this._pathArg(a.path); const savedM = [...this.terminal.fs.currentPath]; if (mp.startsWith('/')) this.terminal.fs.currentPath = []; const path = mp.replace(/^\//, ''); @@ -11603,7 +11625,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify({ error: `Cannot create: ${path}` }); } case 'delete_file': { - const dp = a.path || ''; + const dp = this._pathArg(a.path); const savedD = [...this.terminal.fs.currentPath]; if (dp.startsWith('/')) this.terminal.fs.currentPath = []; const path = dp.replace(/^\//, ''); @@ -12523,7 +12545,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); // Save current module state for rollback const oldInstance = this.terminal.moduleManager.modules[modName]; - const oldCommands = oldInstance ? Object.keys(oldInstance.commands) : []; + const oldCommands = (oldInstance && oldInstance.commands) ? Object.keys(oldInstance.commands) : []; // Load new version const ok = await this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); if (!ok) { @@ -12538,7 +12560,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re } // Verify commands registered const newInstance = this.terminal.moduleManager.modules[modName]; - const newCommands = newInstance ? Object.keys(newInstance.commands) : []; + const newCommands = (newInstance && newInstance.commands) ? Object.keys(newInstance.commands) : []; const lostCommands = oldCommands.filter(c => !newCommands.includes(c)); if (lostCommands.length > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; @@ -13247,7 +13269,7 @@ maxFetchChars: this.maxFetchChars, for (var i = 0; i < this._toolCallLog.length; i++) { var e = this._toolCallLog[i]; var err = e.error ? ' ⚠️' : ''; - var argsStr = typeof e.args === 'string' ? e.args.substring(0, 80) : JSON.stringify(e.args).substring(0, 80); + var argsStr = this._formatToolArgs(e.args, 80); out += ' #' + e.turn + ' ' + e.name + '(' + argsStr + ') ' + e.duration + 's' + err + '\n'; } out += '── End ──'; @@ -14113,11 +14135,20 @@ maxFetchChars: this.maxFetchChars, if (!args[0]) { this.terminal.print("Usage: webxai <your-question>", "warning"); return; } - if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { - this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); - } else { - this.terminal.ollamaManager.chat(args.join(' ')); - } + const ai = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + let p; + try { + p = ai ? ai.chat(args) : this.terminal.ollamaManager.chat(args.join(' ')); + } catch (e) { + // Synchronous throw — report it through the same path as a rejection. + p = Promise.reject(e); + } + // Chat-lock timeouts are normal UX (user sent twice). Print, don't let the rejection + // reach window.unhandledrejection → debug-supervisor auto-report. chat() releases its + // own lock in finally, so nothing is cleared here. + return Promise.resolve(p).catch((e) => { + this.terminal.print('AI error: ' + (e && e.message ? e.message : e), 'error'); + }); } } diff --git a/modules/core/module-aiasm.js b/modules/core/module-aiasm.js index 60e1757..34c1582 100644 --- a/modules/core/module-aiasm.js +++ b/modules/core/module-aiasm.js @@ -17,10 +17,19 @@ if (!args[0]) { this.terminal.print("Usage: webxai <your-question>", "warning"); return; } - if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { - this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); - } else { - this.terminal.ollamaManager.chat(args.join(' ')); + const ai = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + let p; + try { + p = ai ? ai.chat(args) : this.terminal.ollamaManager.chat(args.join(' ')); + } catch (e) { + // Synchronous throw — report it through the same path as a rejection. + p = Promise.reject(e); } + // Chat-lock timeouts are normal UX (user sent twice). Print, don't let the rejection + // reach window.unhandledrejection → debug-supervisor auto-report. chat() releases its + // own lock in finally, so nothing is cleared here. + return Promise.resolve(p).catch((e) => { + this.terminal.print('AI error: ' + (e && e.message ? e.message : e), 'error'); + }); } } diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index a3ffb6f..fb4329f 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -1551,6 +1551,28 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } + // Tool-call args are whatever the model sent: often a JSON string, but + // undefined when the call carried no arguments and an object when the + // caller passed one directly. JSON.stringify(undefined) is undefined, + // so the old inline `JSON.stringify(x).substring(...)` threw a + // TypeError inside the debug panel. Never throw here. + _formatToolArgs(args, max) { + let s; + if (typeof args === 'string') { + s = args; + } else { + try { s = JSON.stringify(args); } catch (_e) { s = String(args); } + if (s === undefined || s === null) s = 'null'; + } + const cap = (typeof max === 'number' && max > 0) ? max : 80; + return s.substring(0, cap); + } + // Path arguments are schema-typed as strings, but a model can send a + // number/array/object anyway. Coerce instead of letting .replace() / + // .startsWith() throw "originalPath.replace is not a function". + _pathArg(v) { + return typeof v === 'string' ? v : (v == null ? '' : String(v)); + } _updateDebugPanel() { if (!this._debugPanelEnabled) return; const panel = document.getElementById('debug-ai-panel'); @@ -1560,7 +1582,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re let toolHtml = ''; for (const entry of recent) { const statusClass = entry.error ? 'debug-entry-error' : 'debug-entry-ok'; - const argsStr = typeof entry.args === 'string' ? entry.args.substring(0, 60) : JSON.stringify(entry.args).substring(0, 60); + const argsStr = this._formatToolArgs(entry.args, 60); toolHtml += '<div class="debug-entry ' + statusClass + '">' + '<span class="debug-turn">#' + entry.turn + '</span> ' + '<span class="debug-name">' + this._escapeHtml(entry.name) + '</span> ' + @@ -1599,7 +1621,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' + '<span>🔧 AI Debug <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' + '<span style="display:flex;gap:4px;">' + - '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+m._formatToolArgs(e.args,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'📥 Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📥</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'💾 Debug log appended — \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">💾</button>' + '<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-ai-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">✕</span>' + @@ -2573,7 +2595,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { - const originalPath = a.path || ''; + const originalPath = this._pathArg(a.path); const p = originalPath.replace(/^\//, '').replace(/\/+$/, ''); const fsapi = this.terminal.fs; const saved = [...fsapi.currentPath]; @@ -2590,7 +2612,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size }))); } case 'read_file': { - const rp = a.path || ''; + const rp = this._pathArg(a.path); const savedR = [...this.terminal.fs.currentPath]; if (rp.startsWith('/')) this.terminal.fs.currentPath = []; const path = rp.replace(/^\//, ''); @@ -2629,7 +2651,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re }); } case 'write_file': { - const wp = a.path || ''; + const wp = this._pathArg(a.path); const savedW = [...this.terminal.fs.currentPath]; if (wp.startsWith('/')) this.terminal.fs.currentPath = []; const path = wp.replace(/^\//, ''); @@ -2648,7 +2670,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return `File written: ${wp} (${content.length} chars)`; } case 'make_directory': { - const mp = a.path || ''; + const mp = this._pathArg(a.path); const savedM = [...this.terminal.fs.currentPath]; if (mp.startsWith('/')) this.terminal.fs.currentPath = []; const path = mp.replace(/^\//, ''); @@ -2658,7 +2680,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify({ error: `Cannot create: ${path}` }); } case 'delete_file': { - const dp = a.path || ''; + const dp = this._pathArg(a.path); const savedD = [...this.terminal.fs.currentPath]; if (dp.startsWith('/')) this.terminal.fs.currentPath = []; const path = dp.replace(/^\//, ''); @@ -3578,7 +3600,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); // Save current module state for rollback const oldInstance = this.terminal.moduleManager.modules[modName]; - const oldCommands = oldInstance ? Object.keys(oldInstance.commands) : []; + const oldCommands = (oldInstance && oldInstance.commands) ? Object.keys(oldInstance.commands) : []; // Load new version const ok = await this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); if (!ok) { @@ -3593,7 +3615,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re } // Verify commands registered const newInstance = this.terminal.moduleManager.modules[modName]; - const newCommands = newInstance ? Object.keys(newInstance.commands) : []; + const newCommands = (newInstance && newInstance.commands) ? Object.keys(newInstance.commands) : []; const lostCommands = oldCommands.filter(c => !newCommands.includes(c)); if (lostCommands.length > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; @@ -4302,7 +4324,7 @@ maxFetchChars: this.maxFetchChars, for (var i = 0; i < this._toolCallLog.length; i++) { var e = this._toolCallLog[i]; var err = e.error ? ' ⚠️' : ''; - var argsStr = typeof e.args === 'string' ? e.args.substring(0, 80) : JSON.stringify(e.args).substring(0, 80); + var argsStr = this._formatToolArgs(e.args, 80); out += ' #' + e.turn + ' ' + e.name + '(' + argsStr + ') ' + e.duration + 's' + err + '\n'; } out += '── End ──'; diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 685b5fd..43c7e06 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.157'; + const LATEST_VER = 'v4.6.158'; const CACHE = '?v=' + LATEST_VER; // Exposed so ext modules can register their own URL with the same cache-busting version // instead of an unversioned path the browser may already have cached (shell.js did). diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js index cc275df..25b6ca4 100644 --- a/modules/ext/cryptogzip.js +++ b/modules/ext/cryptogzip.js @@ -190,8 +190,31 @@ class CryptoGzipModule { for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s); } + // Validate before atob(): atob() throws a raw DOMException (InvalidCharacterError / "characters + // outside of the Latin1 range") for arbitrary input, which escaped as an unhandledrejection when + // called from the node/JS console (RPT-20261102-203616-e2641bff). Everything atob() would reject + // is rejected here first, as a plain Error the command wrappers can print. + _base64InvalidReason(s) { + if (typeof s !== 'string') return 'expected a string, got ' + (s === null ? 'null' : typeof s); + const t = s.replace(/[\t\n\f\r ]/g, ''); // atob() ignores exactly these ASCII whitespace chars + if (t.length === 0) return null; // atob('') === '' — keep the empty case working + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(t)) { + const bad = t.match(/[^A-Za-z0-9+/=]/); + if (bad) return 'illegal character ' + JSON.stringify(bad[0]) + ' — expected A-Z a-z 0-9 + / ='; + return 'misplaced or excessive "=" padding'; + } + const rem = t.length % 4; + if (rem === 1) return 'length ' + t.length + ' is not a valid base64 length'; + // Padding may be omitted (atob accepts that too), but when present the length must be a + // multiple of 4 — otherwise atob() throws "not correctly encoded". + if (t.indexOf('=') !== -1 && rem !== 0) return 'length ' + t.length + ' has incomplete "=" padding'; + return null; + } + _isBase64(s) { return this._base64InvalidReason(s) === null; } _base64ToBytes(b64) { - const s = atob(b64); + const reason = this._base64InvalidReason(b64); + if (reason) throw new Error('not valid base64 input (' + reason + ')'); + const s = atob(b64.replace(/[\t\n\f\r ]/g, '')); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; @@ -582,19 +605,10 @@ class CryptoGzipModule { } // ============================================================ - // Base64 Helpers - // ============================================================ - - _bytesToBase64(bytes) { - const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join(''); - return btoa(bin); - } - - _base64ToBytes(b64) { - const bin = atob(b64); - return Uint8Array.from(bin, (c) => c.charCodeAt(0)); - } - + // Base64 Helpers — defined once, above, next to the gzip code. + // (A second, later copy of _bytesToBase64/_base64ToBytes used to + // live here and silently shadow the first; it carried the unguarded + // atob() call. Do not re-add a duplicate.) // ============================================================ registerCommands() { diff --git a/modules/ext/wpm.js b/modules/ext/wpm.js index 68ac9cb..e9b9b50 100644 --- a/modules/ext/wpm.js +++ b/modules/ext/wpm.js @@ -21,6 +21,35 @@ class WpmModule { saveInstalled() { localStorage.setItem('webx11_wpm_installed', JSON.stringify(this.installed)); } + // ── Catalog normalisation ─────────────────────────────────────────────── + // packages.json is hand-edited JSON and its field names have drifted: `kanban` + // shipped with `description` while the other 31 entries use `desc` + // (RPT-20261109-123907-5a6273ab), and an entry can be missing `category` / + // `commands` entirely. Reading pkg.desc.toLowerCase() straight out of the index + // threw a TypeError out of `wpm search` for EVERY keyword (the bad entry is only + // reached mid-loop), so all catalog reads go through _pkg()/_packageMap(). + // `desc` wins when both keys exist; values are coerced so .toLowerCase() is safe. + _pkg(name, raw) { + const src = (raw && typeof raw === 'object') ? raw : {}; + const str = (v) => (v === undefined || v === null) ? '' : String(v); + let commands = src.commands; + if (typeof commands === 'string') commands = [commands]; + if (!Array.isArray(commands)) commands = []; + return Object.assign({}, src, { + name: str(src.name) || str(name), + version: str(src.version) || '?', + desc: str(src.desc) || str(src.description), + category: str(src.category), + url: str(src.url), + commands: commands, + }); + } + _packageMap(idx) { + const pkgs = (idx && idx.packages && typeof idx.packages === 'object') ? idx.packages : {}; + const out = {}; + Object.keys(pkgs).forEach(k => { out[k] = this._pkg(k, pkgs[k]); }); + return out; + } registerCommands() { this.commands['wpm'] = (args) => this.handle(args); } @@ -52,8 +81,9 @@ class WpmModule { if (!name) return this.terminal.print('Usage: wpm install <package>', 'warning'); const idx = await this.loadIndex(); if (!idx) return; - const pkg = idx.packages[name]; - if (!pkg) return this.terminal.print(`Package "${name}" not found. Try: wpm search <keyword>`, 'error'); + const raw = idx.packages && idx.packages[name]; + if (!raw) return this.terminal.print(`Package "${name}" not found. Try: wpm search <keyword>`, 'error'); + const pkg = this._pkg(name, raw); if (this.installed[name]) return this.terminal.print(`Package "${name}" already installed. Use wpm update to reinstall.`, 'warning'); this.terminal.print(`Installing ${pkg.name} v${pkg.version}...`, 'info'); this.terminal.print(` ${pkg.desc}`, 'info'); @@ -106,8 +136,9 @@ class WpmModule { }); } this.terminal.print('\n=== Available Packages ===', 'info'); - Object.keys(idx.packages).sort().forEach(name => { - const pkg = idx.packages[name]; + const pkgs = this._packageMap(idx); + Object.keys(pkgs).sort().forEach(name => { + const pkg = pkgs[name]; const installed = this.installed[name] ? ' ✅' : ''; this.terminal.print(` ${name} - ${pkg.desc}${installed}`); }); @@ -116,10 +147,10 @@ class WpmModule { if (!keyword) return this.terminal.print('Usage: wpm search <keyword>', 'warning'); const idx = await this.loadIndex(); if (!idx) return; - const kw = keyword.toLowerCase(); + const kw = String(keyword).toLowerCase(); const results = []; - Object.entries(idx.packages).forEach(([name, pkg]) => { - if (name.includes(kw) || pkg.desc.toLowerCase().includes(kw) || pkg.category.toLowerCase().includes(kw)) { + Object.entries(this._packageMap(idx)).forEach(([name, pkg]) => { + if (name.toLowerCase().includes(kw) || pkg.desc.toLowerCase().includes(kw) || pkg.category.toLowerCase().includes(kw)) { results.push({ name, ...pkg }); } }); @@ -137,8 +168,9 @@ class WpmModule { if (!this.index) return; this.terminal.print('Index refreshed.', 'success'); const outdated = []; + const pkgs = this._packageMap(this.index); Object.keys(this.installed).forEach(name => { - const pkg = this.index.packages[name]; + const pkg = pkgs[name]; if (pkg && pkg.version !== this.installed[name].version) { outdated.push(name); } @@ -151,7 +183,8 @@ class WpmModule { if (!name) return this.terminal.print('Usage: wpm remove <package>', 'warning'); if (!this.installed[name]) return this.terminal.print(`Package "${name}" is not installed via wpm`, 'warning'); const idx = await this.loadIndex(); - const className = idx && idx.packages[name] ? idx.packages[name].name.replace(/\s+/g, '') + 'Module' : null; + const raw = idx && idx.packages ? idx.packages[name] : null; + const className = raw ? this._pkg(name, raw).name.replace(/\s+/g, '') + 'Module' : null; if (className && this.terminal.moduleManager.unregisterModule(className)) { this.terminal.print(`Module ${className} unregistered`, 'info'); } @@ -163,8 +196,9 @@ class WpmModule { if (!name) return this.terminal.print('Usage: wpm info <package>', 'warning'); const idx = await this.loadIndex(); if (!idx) return; - const pkg = idx.packages[name]; - if (!pkg) return this.terminal.print(`Package "${name}" not found`, 'error'); + const raw = idx.packages && idx.packages[name]; + if (!raw) return this.terminal.print(`Package "${name}" not found`, 'error'); + const pkg = this._pkg(name, raw); this.terminal.print(`=== ${pkg.name} v${pkg.version} ===`, 'info'); this.terminal.print(`Description: ${pkg.desc}`); this.terminal.print(`Category: ${pkg.category}`); diff --git a/modules/test/test-ai-path-args.js b/modules/test/test-ai-path-args.js new file mode 100644 index 0000000..878f6b0 --- /dev/null +++ b/modules/test/test-ai-path-args.js @@ -0,0 +1,143 @@ +// node modules/test/test-ai-path-args.js +// Regression for the filed report "originalPath.replace is not a function" +// (data/debug/errors.json, 3 occurrences, last 2026-11-09T19:46Z). +// The tool schema says path is a string, but a model can send a number/array/ +// object. The old `const p = a.path || ''` handed that straight to .replace()/ +// .startsWith(), which threw inside runTool and turned every such call into a +// TypeError instead of a usable answer. _pathArg() coerces first. +const fsMod = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..', 'core'); +const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js'); + +const store = {}; +global.localStorage = { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; } +}; +global.window = global; +global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } }; +global.document = { getElementById: () => null, createElement: () => ({ style: {} }), body: { appendChild() {}, removeChild() {} } }; +global.navigator = global.navigator || {}; + +const load = (file, cls) => new Function(fsMod.readFileSync(path.join(ROOT, file), 'utf8') + '\nreturn ' + cls + ';')(); +const VFS = load('virtual-fs.js', 'VirtualFileSystem'); +const AI = new Function(fsMod.readFileSync(SRC, 'utf8') + '\nreturn WebX11AIAssistantModule;')(); + +function mk() { + const out = []; + const vfs = new VFS(); + vfs.saveToStorage = () => true; + const ai = Object.create(AI.prototype); // skip the DOM-touching constructor + ai.terminal = { + print: (m, t) => out.push({ text: String(m), type: t || '' }), + log: () => {}, printHTML: () => {}, + fs: vfs, moduleManager: { modules: {} } + }; + ai.checkGuardrail = () => null; + ai._errorLog = []; + ai._toolCallLog = []; + ai._toolCache = new Map(); + ai._toolEpoch = 0; + ai._currentTag = 'main'; + ai._toolLimit = () => 8000; + return { ai, vfs, out }; +} + +let fails = 0, passes = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +// The old defect surfaced through runTool's catch-all as +// {"error":"originalPath.replace is not a function"} — so "did not throw" alone +// is not enough; the result must also be free of that TypeError. +function parse(label, raw) { + let out = null, threw = null; + try { out = JSON.parse(raw); } catch (e) { threw = e; } + ok(label + ': returned a JSON string', threw === null && out !== null, threw ? threw.message : String(raw).slice(0, 200)); + ok(label + ': no string-method TypeError leaked', + typeof raw === 'string' && !/is not a function/.test(raw), String(raw).slice(0, 200)); + return out; +} +function isEntriesOrError(v) { + return Array.isArray(v) || (v && typeof v === 'object' && typeof v.error === 'string'); +} +const call = async (ai, name, args) => { + let res, threw = null; + try { res = await ai.runTool(name, args); } catch (e) { threw = e; } + ok(name + ' ' + JSON.stringify(args) + ': runTool did not throw', threw === null, threw && threw.message); + return res; +}; + +(async () => { + // A. list_files with a numeric path + { + const { ai } = mk(); + const res = await call(ai, 'list_files', JSON.stringify({ path: 123 })); + const v = parse('list_files path:123', res); + ok('list_files path:123 is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200)); + } + + // B. list_files with an object path + { + const { ai } = mk(); + const res = await call(ai, 'list_files', JSON.stringify({ path: {} })); + const v = parse('list_files path:{}', res); + ok('list_files path:{} is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200)); + } + + // C. list_files with an explicitly undefined path (JSON.stringify drops the + // key, so this must behave like "list the current directory") + { + const { ai } = mk(); + const res = await call(ai, 'list_files', JSON.stringify({ path: undefined })); + const v = parse('list_files path:undefined', res); + ok('list_files path:undefined still lists the current dir', Array.isArray(v), String(res).slice(0, 200)); + } + + // D. an object passed directly (runTool accepts an object as argsStr) + { + const { ai } = mk(); + const res = await call(ai, 'list_files', { path: [1, 2] }); + const v = parse('list_files path:[1,2]', res); + ok('list_files path:[1,2] is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200)); + } + + // E. read_file with a numeric path must answer "not found", not TypeError + { + const { ai } = mk(); + const res = await call(ai, 'read_file', JSON.stringify({ path: 123 })); + const v = parse('read_file path:123', res); + ok('read_file path:123 reports File not found', !!v && /File not found/.test(String(v.error)), String(res).slice(0, 200)); + } + + // F. the other path-family tools must survive the same input + for (const tool of ['delete_file', 'make_directory', 'write_file']) { + const { ai } = mk(); + const args = tool === 'write_file' ? { path: 123, content: 'x' } : { path: 123 }; + const res = await call(ai, tool, JSON.stringify(args)); + ok(tool + ' path:123 has no string-method TypeError', + typeof res === 'string' && !/is not a function/.test(res), String(res).slice(0, 200)); + } + + // G. control: a real string path still works exactly as before + { + const { ai, vfs } = mk(); + vfs.mkdirRecursive('/demo'); + vfs.writeFile('/demo/keep.txt', 'hello'); + const res = await call(ai, 'list_files', JSON.stringify({ path: '/demo' })); + const v = parse('list_files path:/demo', res); + ok('string path still lists entries', Array.isArray(v) && v.some(e => e.name === 'keep.txt'), + String(res).slice(0, 200)); + const read = await call(ai, 'read_file', JSON.stringify({ path: '/demo/keep.txt' })); + const rv = parse('read_file path:/demo/keep.txt', read); + ok('string path still reads content', !!rv && rv.content === 'hello', String(read).slice(0, 200)); + } + + console.log('test-ai-path-args: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); diff --git a/modules/test/test-ai-test-module-commands.js b/modules/test/test-ai-test-module-commands.js new file mode 100644 index 0000000..9683011 --- /dev/null +++ b/modules/test/test-ai-test-module-commands.js @@ -0,0 +1,105 @@ +// node modules/test/test-ai-test-module-commands.js +// test_module used `oldInstance ? Object.keys(oldInstance.commands) : []`. A module +// that has no command map (or whose reloaded instance does not re-register one) +// made Object.keys throw "Cannot convert undefined or null to object". The throw +// happened AFTER loadModuleFromCode() succeeded, so runTool's catch returned an +// error and the rollback branches were skipped: the unverified module stayed +// loaded. The guards keep the rollback path reachable. +const fsMod = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..', 'core'); +const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js'); + +const store = {}; +global.localStorage = { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; } +}; +global.window = global; +global.document = { getElementById: () => null, createElement: () => ({ style: {} }), body: { appendChild() {}, removeChild() {} } }; +global.navigator = global.navigator || {}; + +const AI = new Function(fsMod.readFileSync(SRC, 'utf8') + '\nreturn WebX11AIAssistantModule;')(); + +// `next` is what loadModuleFromCode installs as the reloaded instance. +function mk(oldInstance, next) { + const out = []; + const modules = { Demo: oldInstance }; + const ai = Object.create(AI.prototype); + ai.terminal = { + print: (m, t) => out.push({ text: String(m), type: t || '' }), + log: () => {}, printHTML: () => {}, + fs: { readFile: () => '// demo module source', currentPath: [] }, + moduleManager: { + modules, + loadModuleFromCode: async (code, url) => { modules.Demo = next; return true; } + } + }; + ai.checkGuardrail = () => null; + ai._errorLog = []; + ai._toolCallLog = []; + ai._captureConsoleErrors = async () => []; + ai._captureCommandOutput = async () => 'ok'; + ai._captureShellCommand = async () => 'ok'; + return { ai, modules, out }; +} + +let fails = 0, passes = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +(async () => { + // A. reloaded instance lost its command map: must report + roll back, + // not throw and leave the bad instance installed. + { + const oldInstance = { commands: { 'demo-a': {}, 'demo-b': {} } }; + const next = { /* no commands property at all */ }; + const { ai, modules } = mk(oldInstance, next); + const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' })); + let res = null; + try { res = JSON.parse(raw); } catch (e) {} + ok('A1 result is JSON', res !== null, String(raw).slice(0, 200)); + ok('A2 reports Commands lost after edit', !!res && res.error === 'Commands lost after edit', + String(raw).slice(0, 200)); + ok('A3 lists both lost commands', !!res && Array.isArray(res.lost) && res.lost.length === 2, + JSON.stringify(res && res.lost)); + ok('A4 marks rolledBack', !!res && res.rolledBack === true, String(raw).slice(0, 200)); + ok('A5 old instance restored', modules.Demo === oldInstance, 'modules.Demo is ' + (modules.Demo === next ? 'the new instance' : 'neither')); + ok('A6 no Object.keys TypeError', !/Cannot convert undefined or null to object/.test(String(raw)), + String(raw).slice(0, 200)); + } + + // B. the OLD instance itself has no command map: Object.keys(old.commands) + // used to throw before the new module was even loaded. + { + const oldInstance = { /* no commands */ }; + const next = { commands: { 'demo-a': {} } }; + const { ai } = mk(oldInstance, next); + const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' })); + let res = null; + try { res = JSON.parse(raw); } catch (e) {} + ok('B1 does not throw on a command-less old instance', res !== null, String(raw).slice(0, 200)); + ok('B2 reports success (nothing was lost)', !!res && res.success === true, String(raw).slice(0, 200)); + ok('B3 no Object.keys TypeError', !/Cannot convert undefined or null to object/.test(String(raw)), + String(raw).slice(0, 200)); + } + + // C. control: unchanged command map still passes the verification + { + const oldInstance = { commands: { 'demo-a': {} } }; + const next = { commands: { 'demo-a': {} } }; + const { ai, modules } = mk(oldInstance, next); + const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' })); + let res = null; + try { res = JSON.parse(raw); } catch (e) {} + ok('C1 success', !!res && res.success === true, String(raw).slice(0, 200)); + ok('C2 new instance stays loaded', modules.Demo === next); + } + + console.log('test-ai-test-module-commands: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); diff --git a/modules/test/test-aiasm-rejection.js b/modules/test/test-aiasm-rejection.js new file mode 100644 index 0000000..d6e18f6 --- /dev/null +++ b/modules/test/test-aiasm-rejection.js @@ -0,0 +1,95 @@ +// node modules/test/test-aiasm-rejection.js +// test-aiasm-rejection — the wx / webxai / webx aliases must not turn the AI chat-lock timeout +// into an unhandled promise rejection (RPT-20261107-150726-bf727247). +// +// Why this file exists: forwardToAI() used to call the assistant's chat(args) with no await and no +// .catch() inside a non-async function. When the assistant was already mid-turn, chat() waited +// _chatLockTimeoutMs (10s) and then rejected with "AI is still busy with another turn…". Nothing +// was attached to that promise, so it surfaced on window.unhandledrejection and the debug +// supervisor auto-filed a bug report for what is normal "the user typed twice" UX. +const fs = require('fs'); +const path = require('path'); + +const SRC = path.join(__dirname, '..', 'core', 'module-aiasm.js'); +const Alias = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn WebXAIAliasModule;')(); + +const BUSY = 'AI is still busy with another turn (waited 10s). Wait for it to finish, or run ' + + "'ai-stop' and retry — the running turn's lock is left alone."; + +const unhandled = []; +process.on('unhandledRejection', (e) => unhandled.push(e)); + +let passes = 0, fails = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +function mk(chatImpl) { + const printed = []; + const term = { + print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }), + moduleManager: { modules: { WebX11AIAssistantModule: chatImpl ? { chat: chatImpl } : null } }, + ollamaManager: { chat: () => Promise.resolve('ok') } + }; + const m = new Alias(); + m.terminal = term; + m.registerCommands(); + return { m, printed, term }; +} + +const tick = (ms) => new Promise(r => setTimeout(r, ms || 25)); + +(async () => { + // A1-A5: the busy rejection is printed, the returned promise settles, nothing escapes + const a = mk(() => Promise.reject(new Error(BUSY))); + const r = a.m.forwardToAI(['hi']); + ok('A1 forwardToAI returns a promise (callers can await it)', !!r && typeof r.then === 'function', typeof r); + let awaited; + try { awaited = await r; } catch (e) { awaited = 'REJECTED:' + e.message; } + ok('A2 the returned promise settles instead of rejecting', String(awaited).indexOf('REJECTED') !== 0, String(awaited)); + const errLine = a.printed.find(p => /^AI error: /.test(p.msg)); + ok('A3 the busy timeout is printed to the terminal', !!errLine && /still busy/.test(errLine.msg), + JSON.stringify(a.printed)); + ok('A4 it is printed as an error line', !!errLine && errLine.kind === 'error', errLine && errLine.kind); + await tick(); + ok('A5 no unhandledRejection escaped', unhandled.length === 0, + unhandled.map(e => (e && e.message) || String(e)).join('; ')); + + // A6-A7: the same through the command map the terminal dispatcher actually awaits + const b = mk(() => Promise.reject(new Error(BUSY))); + let cmdErr = null; + try { await b.m.commands['wx'](['second question']); } catch (e) { cmdErr = e; } + ok('A6 `wx` command does not reject', !cmdErr, cmdErr && cmdErr.message); + await tick(); + ok('A7 `wx` prints the busy error', b.printed.some(p => /AI error: .*still busy/.test(p.msg)), + JSON.stringify(b.printed)); + + // A8-A10: happy path — a resolving chat must print nothing at all + const c = mk(() => Promise.resolve({ role: 'assistant', content: 'pong' })); + let res; + try { res = await c.m.forwardToAI(['ping']); } catch (e) { res = 'REJECTED:' + e.message; } + await tick(); + ok('A8 a resolving chat prints nothing', c.printed.length === 0, JSON.stringify(c.printed)); + ok('A9 happy path does not throw', String(res).indexOf('REJECTED') !== 0, String(res)); + ok('A10 happy path leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length)); + + // A11-A12: ollama fallback (no assistant module) is handled the same way + const d = mk(null); + d.term.ollamaManager.chat = () => Promise.reject(new Error('ollama offline')); + await d.m.forwardToAI(['hi']); + await tick(); + ok('A11 ollama fallback failure is printed', d.printed.some(p => /AI error: ollama offline/.test(p.msg)), + JSON.stringify(d.printed)); + ok('A12 ollama fallback leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length)); + + // A13: usage is still printed when the question is missing + const e = mk(null); + e.m.forwardToAI([]); + ok('A13 usage printed when args are empty', e.printed.some(p => /Usage: webxai/.test(p.msg)), + JSON.stringify(e.printed)); + + console.log('test-aiasm-rejection: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); diff --git a/modules/test/test-cryptogzip-guard.js b/modules/test/test-cryptogzip-guard.js new file mode 100644 index 0000000..14f50d3 --- /dev/null +++ b/modules/test/test-cryptogzip-guard.js @@ -0,0 +1,136 @@ +// node modules/test/test-cryptogzip-guard.js +// test-cryptogzip-guard — CryptoGzipModule._base64ToBytes must reject non-base64 input with a +// plain, actionable Error, not a raw DOMException out of atob() (RPT-20261102-203616-e2641bff: +// "Failed to execute 'atob' on 'Window': The string to be decoded contains characters outside of +// the Latin1 range"), which escaped as an unhandledrejection when called from the node console. +// +// It also pins the happy path byte-for-byte (including unpadded base64 and newline-wrapped +// base64, both of which atob already accepted) and the two gunzip callers. +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +global.window = {}; // the file's auto-registration tail is window-guarded + +const SRC = path.join(__dirname, '..', 'ext', 'cryptogzip.js'); +const CryptoGzipModule = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn CryptoGzipModule;')(); + +let passes = 0, fails = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +const m = new CryptoGzipModule(); +const isDomException = (e) => typeof DOMException === 'function' && e instanceof DOMException; + +function mkTerm(files) { + const store = Object.assign({}, files || {}); + const printed = []; + const term = { + print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }), + fs: { + readFile: (p) => (p in store ? store[p] : null), + writeFile: (p, c) => { store[p] = c; return true; }, + deleteFile: (p) => { delete store[p]; }, + readDir: () => [] + }, + inputCallback: null, + _onCommand: null + }; + const mod = new CryptoGzipModule(); + mod.terminal = term; + mod.registerCommands(); + return { mod, term, printed, store }; +} + +(async () => { + // C1-C2: happy path + const hello = m._base64ToBytes('aGVsbG8='); + ok('C1 decodes base64 to the right bytes', Buffer.from(hello).toString() === 'hello', + Buffer.from(hello).toString()); + ok('C2 returns a 5-byte Uint8Array', hello instanceof Uint8Array && hello.length === 5, + hello.constructor && hello.constructor.name + ' len ' + hello.length); + + // C3: every input that used to reach atob() must now be a plain Error mentioning base64 + const bad = [ + ['hello world!', 'non-base64 text'], + ['☃', 'non-Latin1 character'], + [null, 'null'], + [undefined, 'undefined'], + [123, 'number'], + [{}, 'object'], + ['abcde=', 'bad padding'], + ['ab=', 'incomplete padding'] + ]; + for (const [value, label] of bad) { + let err = null; + try { m._base64ToBytes(value); } catch (e) { err = e; } + const plainError = err instanceof Error && !isDomException(err) && err.name !== 'InvalidCharacterError'; + ok('C3 ' + label + ' throws a plain Error that mentions base64', + plainError && /base64/.test(err.message), + err ? err.name + ': ' + err.message : 'did not throw'); + } + + // C4: long valid base64 with CRLF/LF wrapping still decodes (atob ignores ASCII whitespace) + const long = 'The quick brown fox jumps over the lazy dog. '.repeat(40); + const b64 = Buffer.from(long, 'utf8').toString('base64'); + const wrapped = b64.replace(/(.{60})/g, '$1\r\n') + '\n'; + const out = Buffer.from(m._base64ToBytes(wrapped)).toString('utf8'); + ok('C4 newline-wrapped base64 decodes', out === long, out.length + ' vs ' + long.length + ' chars'); + + // C5: byte-identical to atob() for every form atob accepted before (incl. unpadded) + const corpus = ['aGVsbG8=', 'aGVsbG8', 'ab==', 'abc=', 'abcd', '', ' aGVs bG8=\n', 'aGVsbG8g\td29ybGQ=']; + let same = true, why = ''; + for (const c of corpus) { + let want, got; + try { want = Buffer.from(atob(c), 'binary').toString('hex'); } catch (e) { want = 'THROW'; } + try { got = Buffer.from(m._base64ToBytes(c)).toString('hex'); } catch (e) { got = 'THROW'; } + if (want !== got) { same = false; why = JSON.stringify(c) + ' atob=' + want + ' guard=' + got; break; } + } + ok('C5 happy path is byte-identical to atob()', same, why); + + // C6: _gunzipString round-trip (base64 gzip payload) + const gz = zlib.gzipSync(Buffer.from('hi hi hi', 'utf8')); + const text = await m._gunzipString(gz.toString('base64')); + ok('C6 _gunzipString round-trips a real gzip payload', text === 'hi hi hi', JSON.stringify(text)); + + // C7: _gunzipString on garbage rejects with the guarded Error, not a DOMException + let gzErr = null; + try { await m._gunzipString('hello world!'); } catch (e) { gzErr = e; } + ok('C7 _gunzipString on non-base64 throws a guarded Error', + !!gzErr && gzErr instanceof Error && !isDomException(gzErr) && /base64/.test(gzErr.message), + gzErr ? gzErr.name + ': ' + gzErr.message : 'did not throw'); + + // C8: `gunzip` command path prints its existing "gunzip error:" line instead of crashing + const t8 = mkTerm({ 'notbase64.gz': 'hello world!' }); + let cmdThrew = null; + try { await t8.mod.commands['gunzip'](['notbase64.gz']); } catch (e) { cmdThrew = e; } + const line = t8.printed.find(p => /^gunzip error: /.test(p.msg)); + ok('C8 gunzip prints "gunzip error: … not valid base64 …" and does not throw', + !cmdThrew && !!line && /base64/.test(line.msg) && t8.store['notbase64.gz'] === 'hello world!', + cmdThrew ? 'threw ' + cmdThrew.message : JSON.stringify(t8.printed)); + + // C9: legacy path — a base64 .gz written by `gzip` still decompresses + const t9 = mkTerm({ 'note.txt.gz': zlib.gzipSync(Buffer.from('gunzip me', 'utf8')).toString('base64') }); + await t9.mod.commands['gunzip'](['note.txt.gz']); + ok('C9 gunzip still decompresses a base64 .gz', t9.store['note.txt'] === 'gunzip me' + && !('note.txt.gz' in t9.store), JSON.stringify(Object.keys(t9.store))); + + // C10-C11: the VFS helpers that call _base64ToBytes keep their behaviour + const gzRaw = zlib.gzipSync(Buffer.from('latin1 payload', 'utf8')); + const latin1 = Buffer.from(gzRaw).toString('latin1'); // what _vfsWriteBytes stores + const t10 = mkTerm({ 'blob.gz': latin1 }); + const raw = t10.mod._vfsReadBytes('blob.gz'); // guard throws -> Latin-1 fallback + ok('C10 _vfsReadBytes falls back to raw Latin-1 bytes (guard rejection is swallowed)', + t10.mod._isGzip(raw) && Buffer.from(raw).equals(gzRaw), raw.length + ' bytes'); + + const t11 = mkTerm({ 'b64.gz': gzRaw.toString('base64') }); + const dec = t11.mod._vfsReadBytes('b64.gz'); // heuristic -> base64 decode + ok('C11 _vfsReadBytes still base64-decodes a stored payload', + t11.mod._isGzip(dec) && Buffer.from(dec).equals(gzRaw), dec.length + ' bytes'); + + console.log('test-cryptogzip-guard: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); diff --git a/modules/test/test-debug-args-preview.js b/modules/test/test-debug-args-preview.js new file mode 100644 index 0000000..12292eb --- /dev/null +++ b/modules/test/test-debug-args-preview.js @@ -0,0 +1,112 @@ +// node modules/test/test-debug-args-preview.js +// Regression for the filed report: +// "Error in WebX11AIAssistantModule.debug-ai: Cannot read properties of undefined (reading 'substring')" +// Cause: _toolCallLog entries carry `args: undefined` (a tool call with no +// arguments) or no `args` key at all (_recordToolCall never sets one), and both +// the debug panel and the tool-call log did `JSON.stringify(args).substring(...)`. +// JSON.stringify(undefined) === undefined, so .substring threw inside the render +// path and the panel died before printing anything. +const fsMod = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..', 'core'); +const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js'); + +// --- browser stubs (the class is a browser module) ------------------------- +const store = {}; +global.localStorage = { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; } +}; +global.window = global; +const panelEl = { + attrs: {}, innerHTML: '', + getAttribute(k) { return (k in this.attrs) ? this.attrs[k] : null; }, + setAttribute(k, v) { this.attrs[k] = String(v); } +}; +global.document = { + getElementById: id => (id === 'debug-ai-panel' ? panelEl : null), + createElement: () => ({ style: {}, click() {}, setAttribute() {} }), + body: { appendChild() {}, removeChild() {} } +}; + +const src = fsMod.readFileSync(SRC, 'utf8'); +const AI = new Function(src + '\nreturn WebX11AIAssistantModule;')(); + +// Bypass the constructor (it builds prompts / touches DOM) but keep the real +// prototype methods under test. +function mk() { + const out = []; + const ai = Object.create(AI.prototype); + ai._toolCallLog = []; + ai._debugPanelEnabled = true; + ai._lastRawResponse = null; + ai._lastRawMessages = null; + ai._errorLog = []; + ai.terminal = { + print: (m, t) => out.push({ text: String(m), type: t || '' }), + log: () => {}, printHTML: () => {} + }; + return { ai, out }; +} + +let fails = 0, passes = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +// 1-4: helper contract (guarded so a missing helper reports FAIL instead of +// aborting the run — this is what proves the test catches the old code). +{ + const { ai } = mk(); + const fmt = (v, m) => (typeof ai._formatToolArgs === 'function' ? ai._formatToolArgs(v, m) : '<no _formatToolArgs>'); + ok('1 _formatToolArgs(undefined) === "null"', fmt(undefined, 60) === 'null', JSON.stringify(fmt(undefined, 60))); + ok('2 _formatToolArgs(null) === "null"', fmt(null, 60) === 'null', JSON.stringify(fmt(null, 60))); + ok('3 _formatToolArgs({a:1}) === \'{"a":1}\'', fmt({ a: 1 }, 60) === '{"a":1}', fmt({ a: 1 }, 60)); + const long = 'x'.repeat(200); + const cut = fmt(long, 10); + ok('4 truncates to max', cut === 'xxxxxxxxxx', JSON.stringify(cut)); +} + +// 5: the debug panel renders an entry whose args is undefined (the live shape +// pushed at the tool-call site) and one with no args key at all (_recordToolCall). +{ + const { ai } = mk(); + ai._toolCallLog.push({ turn: 1, name: 'list_files', args: undefined, duration: 0.2, error: false }); + ai._toolCallLog.push({ turn: 2, name: 'get_status', duration: 0.1, ok: true }); + panelEl.attrs['data-tab'] = 'tools'; + panelEl.innerHTML = ''; + let threw = null; + try { ai._updateDebugPanel(); } catch (e) { threw = e; } + ok('5 _updateDebugPanel does not throw on args:undefined', threw === null, threw && threw.message); + ok('5b panel rendered the "null" preview', !threw && /debug-args">null</.test(panelEl.innerHTML), + String(panelEl.innerHTML).slice(0, 400)); +} + +// 6: the terminal tool-call log (var e = this._toolCallLog[i]) does not throw. +{ + const { ai, out } = mk(); + ai._toolCallLog.push({ turn: 1, name: 'list_files', args: undefined, duration: 0.2, error: false }); + ai._toolCallLog.push({ turn: 2, name: 'read_file', args: '{"path":"/a.txt"}', duration: 0.1 }); + let threw = null; + try { ai._showToolCallLog(); } catch (e) { threw = e; } + const printed = out.map(o => o.text).join('\n'); + ok('6 _showToolCallLog does not throw on args:undefined', threw === null, threw && threw.message); + ok('6b log shows null for the argument-less call', /list_files\(null\)/.test(printed), printed.slice(0, 300)); + ok('6c real args still shown', /read_file\(\{"path":"\/a\.txt"\}\)/.test(printed), printed.slice(0, 300)); +} + +// 7: a circular argument object must not throw either (JSON.stringify throws there). +{ + const { ai } = mk(); + const circ = {}; circ.self = circ; + let threw = null, got = ''; + try { got = ai._formatToolArgs(circ, 40); } catch (e) { threw = e; } + ok('7 circular args degrade instead of throwing', threw === null && typeof got === 'string' && got.length > 0, + threw ? threw.message : got); +} + +console.log('test-debug-args-preview: ' + passes + ' passed, ' + fails + ' failed'); +process.exit(fails ? 1 : 0); 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); +}); diff --git a/packages.json b/packages.json index 6d27184..de53fe0 100644 --- a/packages.json +++ b/packages.json @@ -291,8 +291,14 @@ "category": "ui" }, "kanban": { - "url": "https://webx11.duckdns.org/spaces/newos4/modules/ext/kanban.js", + "name": "Kanban Board", + "version": "1.0.0", + "desc": "Kanban board — todo/doing/done task management with priorities", "description": "Kanban board — todo/doing/done task management with priorities", + "url": "https://webx11.duckdns.org/spaces/newos4/modules/ext/kanban.js", + "commands": [ + "kanban" + ], "category": "productivity", "size": "6kb" }, |