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