aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-ui-shell.js
blob: ea4dc8445bb288c12814602e91f8b0938e73a578 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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);
})();