// node modules/test/test-ui-shell.js // test-ui-shell: the UI shell must keep exactly one output stream alive across mode switches. // // Why this exists: every mode reparents #terminal-container into its own layout. Four ways that goes // wrong are invisible in a screenshot — teardown that forgets to restore, a mode that clones the // node, a failed mount that leaves the page chrome-less, and a footer that never docks (which strands // a streaming AI answer with no stop button). These assertions pin all four. const fs = require('fs'), path = require('path'); const CORE = path.join(__dirname, '..', 'core'); // ── minimal DOM: only what the shell is allowed to use ──────────────────────────── function makeEl(tag) { const el = { tagName: String(tag || 'div').toUpperCase(), id: '', children: [], parentNode: null, dataset: {}, style: {}, textContent: '', _html: '', get innerHTML() { return this._html; }, set innerHTML(v) { this._html = String(v); for (const c of this.children) c.parentNode = null; this.children = []; }, appendChild(c) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; this.children.push(c); return c; }, append(...cs) { cs.forEach(c => this.appendChild(c)); }, insertBefore(c, ref) { if (c.parentNode) c.parentNode.removeChild(c); const i = ref ? this.children.indexOf(ref) : -1; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); c.parentNode = this; return c; }, removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) { this.children.splice(i, 1); c.parentNode = null; } return c; }, remove() { if (this.parentNode) this.parentNode.removeChild(this); }, contains(n) { if (!n) return false; if (n === this) return true; return this.children.some(c => c.contains && c.contains(n)); }, setAttribute(k, v) { this[k] = v; }, addEventListener() {}, removeEventListener() {}, querySelector(sel) { const want = String(sel).replace(/^#/, ''); const find = n => { if (n.id === want) return n; for (const c of n.children) { const r = find(c); if (r) return r; } return null; }; return find(this); }, classList: { _s: new Set(), add(...c) { c.forEach(x => this._s.add(x)); }, remove(...c) { c.forEach(x => this._s.delete(x)); }, contains(c) { return this._s.has(c); }, toggle(c) { if (this._s.has(c)) this._s.delete(c); else this._s.add(c); } } }; return el; } const head = makeEl('head'), body = makeEl('body'), htmlEl = makeEl('html'); const byId = {}; global.document = { head, body, documentElement: htmlEl, createElement: tag => makeEl(tag), createTextNode: t => ({ textContent: String(t), children: [], parentNode: null }), getElementById: id => { // walk the tree, not a registry: a node the shell forgot to restore must read as absent const find = n => { if (n.id === id) return n; for (const c of n.children) { const r = find(c); if (r) return r; } return null; }; return find(body); }, addEventListener() {}, dispatchEvent() { return true; }, querySelector: sel => body.querySelector(sel) }; global.CustomEvent = class { constructor(type, opts) { this.type = type; this.detail = (opts || {}).detail; } }; global.window = global; global.performance = { now: () => Date.now() }; const store = {}; global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; function stock(id) { const e = makeEl('div'); e.id = id; byId[id] = e; body.appendChild(e); return e; } // canonical order taken from main.html:882-965 ['terminal-container', 'input-area', 'quick-actions', 'status-bar'].forEach(stock); // ── load the module the way the bundle does (class declaration, not module.exports) ── const src = fs.readFileSync(path.join(CORE, 'module-uishell.js'), 'utf8'); const UIShellModule = new Function(src + '\nreturn UIShellModule;')(); let pass = 0, fail = 0; function ok(name, cond, detail) { if (cond) { pass++; console.log(' PASS ' + name); } else { fail++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); } } const printed = []; const fakeTerminal = { print: (m, t) => printed.push({ m, t }), capturePipeOutput: async () => 'captured', _parseArgs: s => String(s).trim().split(/\s+/).filter(Boolean) }; const shell = new UIShellModule(); shell.init(fakeTerminal); // A1 — command registered shell.registerCommands(); ok('A1 ui command registered', typeof shell.commands['ui'] === 'function'); // A2 — mode registry + apply persists and sets the body attribute let mounted = 0, unmounted = 0; shell.registerMode({ id: 'probe', label: 'Probe', level: 3, icon: '🧪', tagline: 'test mode', mount(api) { mounted++; const slot = api.el('div', { id: 'probe-slot' }); api.root.appendChild(slot); api.attach(slot, api.stock.output); // reparent the real terminal // Move a SECOND stock node: every real mode moves the input and the status bar too, and with // only one node moved the `next`-sibling restore heuristic looked correct while being wrong. const slot2 = api.el('div', { id: 'probe-slot2' }); api.root.appendChild(slot2); const input = document.getElementById('input-area'); if (input) slot2.appendChild(input); return () => { unmounted++; }; } }); ok('A2 apply returns true', shell.apply('probe') === true); ok('A2 body data-ui-mode', document.body.dataset.uiMode === 'probe', document.body.dataset.uiMode); ok('A2 persisted', localStorage.getItem('webx11_ui_mode') === 'probe'); ok('A3 mount ran once', mounted === 1); ok('A4 terminal moved into the mode', byId['terminal-container'].parentNode && byId['terminal-container'].parentNode.id === 'probe-slot'); // A5 — switching away must restore the stock node, in order, and not destroy it shell.apply('classic'); ok('A5 teardown ran', unmounted === 1); ok('A5 terminal survived teardown', !!document.getElementById('terminal-container')); // ui-root is the shell's own container and legitimately sits in too — compare stock only const stockOrder = () => body.children.map(c => c.id) .filter(id => id && id !== 'ui-root').join(','); ok('A5 body order restored', stockOrder() === 'terminal-container,input-area,quick-actions,status-bar', stockOrder()); ok('A5 root emptied', (shell._root.children || []).length === 0); ok('A5 body attribute cleared', document.body.dataset.uiMode === 'classic'); // A6 — unknown mode: no persist, warning, still classic store['webx11_ui_mode'] = 'classic'; ok('A6 unknown returns false', shell.apply('nope') === false); ok('A6 unknown does not persist', localStorage.getItem('webx11_ui_mode') === 'classic'); // The warning is emitted asynchronously now: an unknown id is looked up in packages.json first // (lazy load), so the message arrives a tick later — asserted in the async tail below. // A7 — a mode that throws on mount must not leave the page chrome-less shell.registerMode({ id: 'boom', label: 'Boom', level: 9, icon: '💥', tagline: 'broken', mount() { throw new Error('mount exploded'); } }); shell.apply('boom'); ok('A7 fell back to classic', document.body.dataset.uiMode === 'classic', document.body.dataset.uiMode); ok('A7 terminal still present', !!document.getElementById('terminal-container')); // A8 — a mode that deletes the terminal is rejected shell.registerMode({ id: 'killer', label: 'Killer', level: 9, icon: '☠', tagline: 'deletes the pane', mount() { byId['terminal-container'].remove(); return () => {}; } }); shell.apply('killer'); ok('A8 destructive mode rejected', document.body.dataset.uiMode === 'classic'); ok('A8 error reported', printed.some(p => /terminal/i.test(p.m) && p.t === 'error')); // A11 — a same-id clone passes a presence check; identity must reject it shell.registerMode({ id: 'clone', label: 'Clone', level: 9, icon: '👥', tagline: 'same id, different node', mount(api) { byId['terminal-container'].remove(); const fake = document.createElement('div'); fake.id = 'terminal-container'; api.root.appendChild(fake); return () => {}; } }); ok('A11 clone rejected', shell.apply('clone') === false); ok('A11 clone fallback is classic', document.body.dataset.uiMode === 'classic', document.body.dataset.uiMode); ok('A11 no stale body class', !document.body.classList.contains('wx11-ui-clone')); // A10 — the status bar (AI stop + network state) must exist inside every mode shell.apply('probe'); const sb = document.getElementById('status-bar'); ok('A10 status bar docked in ui-foot', !!sb && sb.parentNode.id === 'ui-foot', sb ? sb.parentNode.id : 'missing'); shell.apply('classic'); const sb2 = document.getElementById('status-bar'); ok('A10 status bar returned to body', !!sb2 && sb2.parentNode === document.body, sb2 ? String(sb2.parentNode.id || 'body') : 'missing'); const foot = document.getElementById('ui-foot'); ok('A10 classic leaves no visible footer', !foot || foot.style.display === 'none'); // A9 — capture() routes a shell-authored command through the capture path (async () => { const got = await shell.api().capture('ls -la'); ok('A9 capture returns text', got === 'captured', String(got)); await new Promise(r => setTimeout(r, 100)); // let the failed lazy load report itself ok('A6 warned', printed.some(p => /could not be loaded|not available|not loaded/.test(p.m))); console.log('\n' + pass + '/' + (pass + fail) + ' PASS'); process.exit(fail ? 1 : 0); })();