Standalone per-card browser model/security/view modules for HUX-01..10 plus node+pytest suites that read the hux.v1 contract schemas directly. Reconciled drift found on integration: the activity model now accepts all 32 hux.event.v1 kinds (delegation.*, memory.suppressed, memory.retrieval_removed, budget.exhausted, side_effect.*), the autonomy model carries the external_side_effect capability, and the foundation boundary test now asserts the shipped static HUX surface exists on disk and that images never bake activated HUX_FLAGS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
277 lines
14 KiB
JavaScript
277 lines
14 KiB
JavaScript
'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);
|
|
});
|