aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-ui-shell.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/test/test-ui-shell.js')
-rw-r--r--modules/test/test-ui-shell.js221
1 files changed, 221 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..530e2e4
--- /dev/null
+++ b/modules/test/test-ui-shell.js
@@ -0,0 +1,221 @@
+// 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',
+ // Deliberately absent: the production terminal has NO _parseArgs, and api.capture() must never
+ // call one (that regression blanked every telemetry panel). A stub here made A9 green anyway.
+};
+
+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 <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);
+})();