aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-ui-shell.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-07 18:19:47 +0100
committerAgando <roduccomm@gmail.com>2026-11-07 18:19:47 +0100
commitaac2b1c0689aefeb140e08c24278ec486611fe10 (patch)
tree09f9f238378a453acc9c3515e8007622d8efdd71 /modules/test/test-ui-shell.js
parenta64aae243cb95f021f21af791f29645f67a098d1 (diff)
feat(newos4): six interface modes (easy -> cockpit) over one terminal
Symptom: newOS4 had exactly one layout. A first-time user had no way in that did not require learning commands, and an operator had no surface showing live runtime state; the only way to see heap/VFS/tool-call telemetry was to type cat /sys/... Root cause: no UI layer existed. main.html hard-codes one flex column, and terminal.js binds the stock nodes by id at construction, so an alternative layout had to either duplicate the output stream or reparent the real nodes. Fix: - modules/core/module-uishell.js (new): mode registry, #ui-root, stock-node snapshot/restore, identity check that reverts to classic when a mode removes or clones #terminal-container, shell-docked #ui-foot (the AI stop button exists in every mode), ui command, gated first-run picker, ?ui= honoured only if the mode actually registers, lazy mode loading via the same loader ext modules use. - modules/ext/ui-{easy,chat,studio,workspace,commander,cockpit}.js (new): six reparenting skins with scoped CSS. Workspace/commander/cockpit read real /sys/* mounts and captured command output; no value on screen is invented. - modules/test/test-ui-shell.js (new): 25 assertions - teardown, clone rejection, failed-mount fallback, footer docking, destructive-mode rejection. - terminal.js: register the shell; make one failing ext module stop aborting the rest of the serial load loop (it did, silently, which is why six ui-* files at the end of extKeys never loaded). - module-loader.js: LATEST_VER v4.6.152. The six ui-* keys are deliberately NOT in extKeys - see the rate-limit note below. - admin/scripts/probe-ui-modes.py (new): 6 modes x 2 viewports plus a --cycle pass that switches all seven states on ONE page and asserts the output node keeps its identity. Also fixed, found while testing (pre-existing, production-affecting): - nginx limit_req zone=webx11 burst 50 -> 200. One newOS4 boot fans out ~40 assets; the bucket emptied mid-boot and every later module got 503. Measured in webx11.error.log: "limiting requests, excess: 50.696 by zone webx11" against modules/ext/ui-chat.js. A reload within ~50 s silently broke modules. Sustained rate unchanged at 60r/m. Verified: node modules/test/test-ui-shell.js -> 25/25; removing both _restoreStock() calls measures 20/25 with five FAILs; probe-ui-modes.py --cycle against production -> ALL PASS, 13 checks; served bytes: v4.6.152 x3 in main.html, class UIShellModule x1 in core-bundle.js, six ui-* packages in packages.json.
Diffstat (limited to 'modules/test/test-ui-shell.js')
-rw-r--r--modules/test/test-ui-shell.js214
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);
+})();