'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const test = require('node:test'); const path = require('node:path'); const vm = require('node:vm'); const ROOT = path.resolve(__dirname, '..', '..'); const foundation = require(path.join(ROOT, 'dockerfiles/hermes-webui-hux/foundation.js')); const shellApi = require(path.join(ROOT, 'dockerfiles/hermes-webui-hux/shell.js')); const IDENTITY = Object.freeze({ tenant_ref: 'tnt_0123456789abcdef', user_ref: 'usr_0123456789abcdef', surface: 'chat', }); function capability(flags, identity = IDENTITY) { return {schema: 'hux.capabilities.v1', api_version: 'hux.v1', identity, flags}; } function response(status, body) { return {status, ok: status >= 200 && status < 300, json: async () => body}; } test('capability negotiation is versioned, dependency-aware, and immutable', () => { const result = foundation.normalizeCapabilities(capability([ 'hux.foundation', 'hux.activity_timeline', 'hux.autonomy', 'hux.multimodal', 'hux.bogus', ]), foundation.normalizeIdentity(IDENTITY)); assert.deepEqual(result.flags, ['hux.activity_timeline', 'hux.autonomy', 'hux.foundation']); assert.equal(result.apiVersion, 'hux.v1'); assert(Object.isFrozen(result)); assert(Object.isFrozen(result.identity)); assert.throws(() => foundation.normalizeCapabilities({}, result.identity), /Unsupported/); assert.throws(() => foundation.normalizeCapabilities([], result.identity), /not an object/); assert.throws(() => foundation.normalizeCapabilities(capability('bad'), result.identity), /flags/); assert.throws(() => foundation.normalizeCapabilities(capability([], {...IDENTITY, surface: 'api'}), result.identity), /does not match/); }); test('browser script path exports only the versioned namespace', () => { const context = {globalThis: {}}; vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'dockerfiles/hermes-webui-hux/foundation.js'), 'utf8'), context); assert.equal(context.globalThis.HermesHuxFoundation.API_VERSION, 'hux.v1'); context.globalThis.HermesHuxFoundation = context.globalThis.HermesHuxFoundation; vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'dockerfiles/hermes-webui-hux/shell.js'), 'utf8'), context); assert.equal(typeof context.globalThis.HermesHuxShell.createShell, 'function'); }); test('identity accepts only opaque tenant and user references', () => { assert.deepEqual(foundation.normalizeIdentity(IDENTITY), { tenantRef: IDENTITY.tenant_ref, userRef: IDENTITY.user_ref, surface: 'chat', }); assert.throws(() => foundation.normalizeIdentity(null), /Missing/); assert.throws(() => foundation.normalizeIdentity({...IDENTITY, tenant_ref: 'tenant-0'}), /tenant/); assert.throws(() => foundation.normalizeIdentity({...IDENTITY, user_ref: 'brad@example.com'}), /user/); assert.throws(() => foundation.normalizeIdentity({...IDENTITY, surface: 'browser'}), /surface/); }); test('client stays off for 404 and reports safe states without response bodies', async () => { const calls = []; let result = response(404, {}); const client = foundation.createClient({ expectedIdentity: IDENTITY, fetcher: async (...args) => { calls.push(args); return result; }, }); const states = []; const unsubscribe = client.subscribe((state) => states.push(state.phase)); assert.equal(client.getState().phase, 'disabled'); await client.negotiate(); assert.deepEqual(states, ['disabled', 'loading', 'disabled']); assert.equal(calls[0][0], '/hux/v1/capabilities'); assert.equal(calls[0][1].credentials, 'same-origin'); assert.equal(client.enabled('hux.foundation'), false); result = response(200, capability([])); await client.negotiate(); assert.equal(client.getState().phase, 'disabled'); result = response(500, {secret: 'must not escape'}); await client.negotiate(); assert.equal(client.getState().phase, 'error'); assert.equal(client.getState().error.includes('secret'), false); unsubscribe(); }); test('client enables verified flags and rejects unsafe endpoint construction', async () => { const client = foundation.createClient({ expectedIdentity: IDENTITY, baseUrl: '/hux/v1/', fetcher: async () => response(200, capability(['hux.foundation', 'hux.projects'])), }); await client.negotiate(); assert.equal(client.getState().phase, 'ready'); assert.equal(client.enabled('hux.projects'), true); assert.equal(client.enabled('hux.bogus'), false); assert.equal(client.endpoint('/projects'), '/hux/v1/projects'); assert.throws(() => client.endpoint('https://evil.example/x'), /scoped/); assert.throws(() => client.endpoint('//evil.example/x'), /scoped/); assert.throws(() => client.endpoint('/../other-tenant'), /scoped/); assert.throws(() => foundation.createClient({expectedIdentity: IDENTITY, baseUrl: 'https://evil.example'}), /same-origin/); assert.throws(() => foundation.createClient({expectedIdentity: IDENTITY, fetcher: null}), /fetch/); assert.throws(() => client.subscribe('nope'), /Listener/); }); test('client discards late negotiation results', async () => { const resolvers = []; const client = foundation.createClient({ expectedIdentity: IDENTITY, fetcher: () => new Promise((resolve) => resolvers.push(resolve)), }); const first = client.negotiate(); const second = client.negotiate(); resolvers[1](response(200, capability(['hux.foundation']))); await second; resolvers[0](response(404, {})); await first; assert.equal(client.getState().phase, 'ready'); }); test('event adapter exposes summaries and references, never details or URIs', () => { const adapters = foundation.createAdapters({identity: IDENTITY, conversationId: 'conv_0001abcd'}); const rawEvent = { schema: 'hux.event.v1', id: 'evt_0001aaaa', seq: 2, ts: '2026-08-24T00:00:00Z', conversation_id: 'conv_0001abcd', kind: 'tool.result', summary: 'Checked service health', detail: {raw: 'private output'}, evidence: [{kind: 'tool_result', id: 'call-1', uri: 'https://secret.example', hash: 'sha256:no'}], sensitivity: 'personal', redaction: {level: 'none'}, }; const event = adapters.adaptEvent(rawEvent); assert.equal(event.summary, 'Checked service health'); assert.deepEqual(event.evidence, [{kind: 'tool_result', id: 'call-1'}]); assert.equal('detail' in event, false); assert(Object.isFrozen(event.evidence[0])); const hidden = adapters.adaptEvent({...rawEvent, redaction: {level: 'full'}}); assert.equal(hidden.summary, 'Details hidden by privacy controls.'); assert.equal(hidden.redacted, true); assert.throws(() => adapters.adaptEvent({...rawEvent, conversation_id: 'conv_other'}), /conversation/); assert.throws(() => adapters.adaptEvent({...rawEvent, seq: -1}), /activity/); assert.throws(() => adapters.adaptEvent({...rawEvent, summary: ''}), /summary/); assert.throws(() => adapters.adaptEvent({...rawEvent, ts: 'today'}), /timestamp/); assert.throws(() => adapters.adaptEvent({...rawEvent, kind: 'tool.raw'}), /classification/); assert.throws(() => adapters.adaptEvent({...rawEvent, redaction: {level: 'maybe'}}), /redaction/); const withoutConversation = {...rawEvent}; delete withoutConversation.conversation_id; assert.throws(() => adapters.adaptEvent(withoutConversation), /matching conversation/); assert.throws(() => adapters.adaptEvent(null), /not an object/); }); test('object adapter is display-only and enforces owner and conversation', () => { const adapters = foundation.createAdapters({identity: IDENTITY, conversationId: 'conv_0001abcd'}); const view = adapters.adaptObject({ schema: 'hux.artifact.v1', id: 'art_0001aaaa', owner: IDENTITY.user_ref, conversation_id: 'conv_0001abcd', project_id: 'prj_0001aaaa', title: 'Plan', type: 'markdown', status: 'ready', sensitivity: 'personal', content: 'never expose me', updated_at: '2026-08-24T00:00:00Z', }); assert.deepEqual(view, { schema: 'hux.artifact.v1', id: 'art_0001aaaa', title: 'Plan', kind: 'markdown', status: 'ready', sensitivity: 'personal', conversationId: 'conv_0001abcd', projectId: 'prj_0001aaaa', updatedAt: '2026-08-24T00:00:00Z', }); assert.equal('content' in view, false); assert.throws(() => adapters.adaptObject({...view, owner: 'usr_ffffffffffffffff'}), /user boundary/); assert.throws(() => adapters.adaptObject({...view, schema: 'hux.artifact.v2'}), /version/); assert.throws(() => foundation.createAdapters({identity: IDENTITY, conversationId: 'bad'}), /conversation id/); }); class FakeNode { constructor(tag) { this.tagName = tag.toUpperCase(); this.attributes = {}; this.children = []; this.dataset = {}; this.hidden = false; this.parentNode = null; this.listeners = {}; this.textContent = ''; } setAttribute(name, value) { this.attributes[name] = String(value); if (name.startsWith('data-')) { const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); this.dataset[key] = String(value); } } getAttribute(name) { return this.attributes[name]; } appendChild(node) { node.parentNode = this; this.children.push(node); return node; } removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null; } replaceChildren(...nodes) { this.children.forEach((node) => { node.parentNode = null; }); this.children = []; nodes.forEach((node) => this.appendChild(node)); } addEventListener(name, listener) { this.listeners[name] = listener; } click() { if (this.listeners.click) this.listeners.click({target: this}); } focus() { this.focused = true; } keydown(key) { if (this.listeners.keydown) this.listeners.keydown({key, preventDefault() {}}); } } function fakeClient() { let state = Object.freeze({phase: 'disabled', capabilities: null, error: null}); let listener = null; let retries = 0; return { enabled: (flag) => state.phase === 'ready' && state.capabilities.flags.includes(flag), getState: () => state, negotiate: () => { retries += 1; }, retries: () => retries, subscribe: (next) => { listener = next; next(state); return () => { listener = null; }; }, setState: (next) => { state = Object.freeze(next); if (listener) listener(state); }, }; } function descendants(node) { return node.children.flatMap((child) => [child, ...descendants(child)]); } test('accessible shell stays hidden until an extension capability is verified', () => { const client = fakeClient(); const host = new FakeNode('main'); const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}}); shell.register({id: 'projects', flag: 'hux.projects', label: 'Projects', order: 20, render: ({container}) => { container.textContent = 'Projects view'; }}); shell.register({id: 'activity', flag: 'hux.activity_timeline', label: 'Activity', order: 10, render: ({container}) => { container.textContent = 'Activity view'; }}); const root = shell.mount(host); assert.equal(root.hidden, true); assert.equal(root.getAttribute('data-hux-version'), 'hux.v1'); client.setState({phase: 'loading', capabilities: null, error: null}); assert.equal(root.hidden, false); assert.equal(descendants(root)[0].getAttribute('role'), 'status'); client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation', 'hux.activity_timeline', 'hux.projects']}, error: null}); const tabs = descendants(root).filter((node) => node.getAttribute('role') === 'tab'); const panels = descendants(root).filter((node) => node.getAttribute('role') === 'tabpanel'); assert.deepEqual(tabs.map((tab) => tab.textContent), ['Activity', 'Projects']); assert.equal(tabs[0].getAttribute('aria-selected'), 'true'); assert.equal(panels[0].hidden, false); tabs[1].click(); assert.equal(tabs[1].getAttribute('aria-selected'), 'true'); assert.equal(panels[0].hidden, true); assert.equal(panels[1].hidden, false); tabs[1].keydown('ArrowLeft'); assert.equal(tabs[0].focused, true); assert.equal(tabs[0].getAttribute('aria-selected'), 'true'); client.setState({phase: 'error', capabilities: null, error: 'Safe error'}); const retry = descendants(root).find((node) => node.textContent === 'Try again'); retry.click(); assert.equal(client.retries(), 1); shell.destroy(); assert.equal(host.children.length, 0); }); test('shell rejects malformed and duplicate extensions', () => { const client = fakeClient(); const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}}); assert.throws(() => shell.register({id: 'X', flag: 'hux.projects', label: 'x', render() {}}), /id/); assert.throws(() => shell.register({id: 'base', flag: 'hux.foundation', label: 'x', render() {}}), /feature flag/); assert.throws(() => shell.register({id: 'unknown', flag: 'hux.nope', label: 'x', render() {}}), /feature flag/); assert.throws(() => shell.register({id: 'projects', flag: 'hux.projects', label: '', render() {}}), /label/); const remove = shell.register({id: 'projects', flag: 'hux.projects', label: 'Projects', render() {}}); assert.throws(() => shell.register({id: 'projects', flag: 'hux.projects', label: 'Again', render() {}}), /already/); remove(); assert.throws(() => shellApi.createShell({}), /client and document/); }); test('shell hides empty ready state and contains extension renderer failure', () => { const client = fakeClient(); const host = new FakeNode('main'); const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}}); const root = shell.mount(host); client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation']}, error: null}); assert.equal(root.hidden, true); shell.register({id: 'broken-panel', flag: 'hux.projects', label: 'Broken', render() { throw new Error('raw private error'); }}); client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation', 'hux.projects']}, error: null}); const safe = descendants(root).find((node) => node.textContent.includes('temporarily unavailable')); assert(safe); assert.equal(safe.textContent.includes('private error'), false); });