'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const test = require('node:test'); const vm = require('node:vm'); const api = require('../../dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.js'); const IDENTITY = Object.freeze({tenantSlot: 'slot-3', userRef: 'usr_0123456789abcdef', surface: 'chat', trust: 'router'}); const RAW_IDENTITY = Object.freeze({tenant_slot: IDENTITY.tenantSlot, subject: IDENTITY.userRef, surface: IDENTITY.surface, trust: IDENTITY.trust}); const PROJECT = 'prj_alpha1234'; const CONVERSATION = 'conv_alpha1234'; const SESSION = 'ses_alpha1234'; const STAMP = '2026-08-24T10:00:00Z'; const DIGEST = `sha256:${'a'.repeat(64)}`; const ROUTES = Object.freeze({ multimodal: ['/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections', '/hux/v1/projects/{project_id}/conversations/{id}/capture-intents'], onboarding: ['/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states'], release: ['/hux/v1/projects/{project_id}/conversations/{id}/releases', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions'], }); function capability(routes = ROUTES, overrides = {}) { const cards = [ ['HUX-11', 'hux.foundation'], ['HUX-01', 'hux.activity_timeline'], ['HUX-03', 'hux.projects'], ['HUX-04', 'hux.artifacts'], ['HUX-05', 'hux.autonomy'], ['HUX-06', 'hux.friendly_modes'], ['HUX-07', 'hux.multimodal'], ['HUX-09', 'hux.onboarding'], ['HUX-10', 'hux.privacy'], ['HUX-12', 'hux.release_followthrough'], ].map(([card, flag]) => ({card, flag, enabled: true, routes: card === 'HUX-07' ? routes.multimodal : card === 'HUX-09' ? routes.onboarding : card === 'HUX-12' ? routes.release : [`/hux/v1/${card.toLowerCase()}`]})); return {schema: 'hux.capabilities.v1', contract_version: '1.1.0', identity: RAW_IDENTITY, cards, server: {}, ...overrides}; } function mediaPage(overrides = {}) { return {items: [{schema: 'hux.multimodal_item.v1', id: 'mmi_alpha1234', owner: IDENTITY.userRef, project_id: PROJECT, conversation_id: CONVERSATION, kind: 'image', source: 'upload', filename: 'photo.png', mime: 'image/png', bytes: 2048, hash: DIGEST, approval_id: 'apr_alpha1234', status: 'metadata_only', created_at: STAMP, revision: 1}], next: null, ...overrides}; } function evaluation(overrides = {}) { const trigger = {surface: 'chat', context: 'after_artifact'}; return {suggestion: {schema: 'hux.suggestion.v1', id: 'sug_alpha1234', kind: 'workflow', trigger, title: 'Run this every week?', body: 'Save this task as a weekly workflow.', action: {type: 'start_workflow', payload: {intent: 'schedule_weekly'}}, priority: 20, suppression: {dismissable: true, max_shows: 3, cooldown_seconds: 3600, never_again_supported: true}}, state: {schema: 'hux.suggestion_state.v1', owner: IDENTITY.userRef, suggestion_id: 'sug_alpha1234', shows: 1, last_shown_at: STAMP, never_again: false}, revision: 1, stored: true, ...overrides}; } function decision(overrides = {}) { return {state: {schema: 'hux.suggestion_state.v1', owner: IDENTITY.userRef, suggestion_id: 'sug_alpha1234', shows: 1, never_again: false}, revision: 2, decision: 'acted', ...overrides}; } function release(state = 'live_verified', overrides = {}) { return {schema: 'hux.release.v1', id: 'rel_alpha1234', workload: 'hermes-webui', commit: 'b'.repeat(40), state, evidence: {image_digest: DIGEST, harbor_digest: DIGEST, pod_digest: DIGEST, flux_revision: `main@sha1:${'c'.repeat(40)}`, health_check: {url: 'https://chat.bstein.dev/healthz', status: 'pass', at: STAMP}}, transitions: [{from: 'converged', to: 'live_verified', at: STAMP, by: {type: 'system', id: 'probe'}}], ...overrides}; } function releasePage(items = [release()], overrides = {}) { const views = items && items.map((item, index) => ({release: item, scope: {project_id: PROJECT, conversation_id: CONVERSATION}, revision: index + 1, ledger_hash: `sha256:${String(index + 1).repeat(64).slice(0, 64)}`})); return {items: views, next: null, ...overrides}; } function response(status, body, headers = {}) { const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])); return {status, ok: status >= 200 && status < 300, json: async () => body, headers: {get: (name) => normalized[name.toLowerCase()] || null}}; } class FakeNode { constructor(tag) { this.tagName = tag.toUpperCase(); this.attributes = {}; this.children = []; this.dataset = {}; this.listeners = {}; this.parentNode = null; this.textContent = ''; this.hidden = false; this.value = ''; } setAttribute(name, value) { this.attributes[name] = String(value); if (name.startsWith('data-')) this.dataset[name.slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = 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 = []; nodes.forEach((node) => this.appendChild(node)); } addEventListener(name, listener) { this.listeners[name] = listener; } trigger(name, extra = {}) { return this.listeners[name] && this.listeners[name]({target: this, preventDefault() {}, ...extra}); } click() { return this.trigger('click'); } focus() { this.focused = true; } } const document = {createElement: (tag) => new FakeNode(tag)}; function descendants(node) { return node.children.flatMap((child) => [child, ...descendants(child)]); } function allText(node) { return [node, ...descendants(node)].map((item) => item.textContent).join(' '); } function byText(node, text) { return descendants(node).find((item) => item.textContent === text); } async function flush() { await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); } function makeClient(flags = []) { const enabled = new Set(flags); return {identity: IDENTITY, enabled: (flag) => enabled.has(flag), request: async () => ({body: {}})}; } test('capability routes are canonical, dependency-aware, and fail closed', () => { const cards = api.normalizeCapabilityCards(capability(), IDENTITY); assert.deepEqual(cards.multimodal.routes, ROUTES.multimodal); assert(Object.isFrozen(cards)); const flags = ['hux.foundation', 'hux.projects', 'hux.artifacts', 'hux.autonomy', 'hux.friendly_modes', 'hux.multimodal', 'hux.onboarding', 'hux.release_followthrough']; const client = makeClient(flags); assert.equal(api.capabilityGap(client, cards, 'multimodal'), null); assert.equal(api.capabilityGap(client, cards, 'onboarding'), null); assert.equal(api.capabilityGap(client, cards, 'release'), null); assert.equal(api.capabilityGap(makeClient(flags.filter((flag) => flag !== 'hux.artifacts')), cards, 'multimodal'), 'feature flag or dependency is off'); assert.equal(api.capabilityGap(client, {...cards, onboarding: {...cards.onboarding, routes: []}}, 'onboarding'), 'server route is not available'); assert.equal(api.capabilityGap(client, {}, 'release'), 'feature flag or dependency is off'); assert.throws(() => api.normalizeCapabilityCards(capability(ROUTES, {cards: capability().cards.filter((card) => card.card !== 'HUX-07')}), IDENTITY), /HUX-07/); assert.throws(() => api.normalizeCapabilityCards(capability(ROUTES, {cards: capability().cards.map((card) => card.card === 'HUX-09' ? {...card, flag: 'hux.projects'} : card)}), IDENTITY), /HUX-09|Canonical/); assert.throws(() => api.normalizeCapabilityCards(capability(ROUTES, {cards: capability().cards.map((card) => card.card === 'HUX-12' ? {...card, enabled: 'yes'} : card)}), IDENTITY), /capability/); }); test('multimodal adapter binds the exact backend page and scoped metadata records', () => { const page = api.normalizeMediaPage(mediaPage(), IDENTITY, PROJECT, CONVERSATION); assert.deepEqual(page, {media: [{id: 'mmi_alpha1234', kind: 'image', fileName: 'photo.png', bytes: 2048}]}); const item = mediaPage().items[0]; const invalid = [null, {items: [], next: null, extra: true}, mediaPage({next: 'cursor'}), mediaPage({items: null}), mediaPage({items: Array(201).fill(item)}), mediaPage({items: [null]}), mediaPage({items: [{...item, schema: 'old'}]}), mediaPage({items: [{...item, id: 'bad'}]}), mediaPage({items: [{...item, owner: 'usr_ffffffffffffffff'}]}), mediaPage({items: [{...item, project_id: 'prj_other1234'}]}), mediaPage({items: [{...item, conversation_id: 'conv_other1234'}]}), mediaPage({items: [{...item, kind: 'binary'}]}), mediaPage({items: [{...item, bytes: 0}]}), mediaPage({items: [{...item, filename: ''}]}), mediaPage({items: [{...item, filename: '../secret'}]}), mediaPage({items: [{...item, status: 'uploaded'}]}), mediaPage({items: [{...item, revision: 0}]}), mediaPage({items: [{...item, created_at: 'today'}]}), mediaPage({items: [{...item, extra: true}]})]; invalid.forEach((raw) => assert.throws(() => api.normalizeMediaPage(raw, IDENTITY, PROJECT, CONVERSATION), /Multimodal|Media/)); }); test('onboarding adapter accepts only backend suggestion and suppression-state shapes', () => { const trigger = api.normalizeTrigger({surface: 'chat', context: 'after_artifact'}, 'chat'); assert.deepEqual(trigger, {surface: 'chat', context: 'after_artifact'}); for (const raw of [null, {surface: 'chat'}, {surface: 'chat', context: 'idle', extra: true}, {surface: 'worker', context: 'idle'}, {surface: 'chat', context: 'surprise'}]) { assert.throws(() => api.normalizeTrigger(raw, 'chat'), /trigger/); } const item = api.normalizeEvaluation(evaluation(), IDENTITY, SESSION, CONVERSATION, trigger, true); assert.equal(item.action, 'start_workflow'); assert(Object.isFrozen(item)); assert.equal(api.normalizeEvaluation({suggestion: null, reason: 'private_mode', stored: false}, IDENTITY, SESSION, CONVERSATION, trigger, true), null); const invalid = [null, {suggestion: null, reason: 'private_mode', stored: false, extra: true}, evaluation({stored: 'yes'}), evaluation({revision: 0}), evaluation({extra: true}), evaluation({suggestion: null}), evaluation({suggestion: {...evaluation().suggestion, schema: 'old'}}), evaluation({suggestion: {...evaluation().suggestion, id: 'bad'}}), evaluation({suggestion: {...evaluation().suggestion, trigger: {surface: 'chat', context: 'idle'}}}), evaluation({suggestion: {...evaluation().suggestion, action: {type: 'surprise'}}}), evaluation({suggestion: {...evaluation().suggestion, suppression: null}}), evaluation({suggestion: {...evaluation().suggestion, suppression: {...evaluation().suggestion.suppression, dismissable: false}}}), evaluation({suggestion: {...evaluation().suggestion, suppression: {...evaluation().suggestion.suppression, never_again_supported: false}}}), evaluation({suggestion: {...evaluation().suggestion, suppression: {...evaluation().suggestion.suppression, max_shows: 0}}}), evaluation({state: null}), evaluation({state: {...evaluation().state, schema: 'old'}}), evaluation({state: {...evaluation().state, owner: 'usr_ffffffffffffffff'}}), evaluation({state: {...evaluation().state, suggestion_id: 'sug_other1234'}}), evaluation({state: {...evaluation().state, shows: 0}}), evaluation({state: {...evaluation().state, shows: 4}}), evaluation({state: {...evaluation().state, never_again: true}}), evaluation({suggestion: {...evaluation().suggestion, title: ''}}), evaluation({suggestion: {...evaluation().suggestion, body: ''}})]; invalid.forEach((raw) => assert.throws(() => api.normalizeEvaluation(raw, IDENTITY, SESSION, CONVERSATION, trigger, true), /Suggestion/)); const memory = evaluation({suggestion: {...evaluation().suggestion, action: {type: 'open_memory'}}}); assert.throws(() => api.normalizeEvaluation(memory, IDENTITY, SESSION, CONVERSATION, trigger, false), /eligible/); assert.equal(api.normalizeEvaluation(memory, IDENTITY, SESSION, CONVERSATION, trigger, true).action, 'open_memory'); assert.equal(api.actionLabel('open_mode'), 'Choose a mode'); assert.equal(api.actionLabel('create_project'), 'Add to a project'); assert.equal(api.actionLabel('open_memory'), 'Review memory'); assert.equal(api.actionLabel('open_artifacts'), 'View artifacts'); assert.equal(api.actionLabel('start_workflow'), 'Use suggestion'); assert.equal(api.actionLabel('none'), null); }); test('release adapter makes a live claim only from exact end-to-end evidence', () => { const live = api.releaseEvidence(release()); assert.equal(live.live, true); assert.equal(live.digest, DIGEST); assert.deepEqual(api.releaseEvidence(release('merged')), {id: 'rel_alpha1234', workload: 'hermes-webui', commit: 'b'.repeat(40), live: false}); const bad = [null, release('live_verified', {schema: 'old'}), release('live_verified', {id: 'bad'}), release('live_verified', {workload: 'unknown'}), release('live_verified', {commit: 'short'}), release('live_verified', {evidence: null}), release('live_verified', {transitions: null}), release('live_verified', {evidence: {...release().evidence, image_digest: 'bad'}}), release('live_verified', {evidence: {...release().evidence, harbor_digest: `sha256:${'d'.repeat(64)}`}}), release('live_verified', {evidence: {...release().evidence, flux_revision: 'queued'}}), release('live_verified', {evidence: {...release().evidence, health_check: null}}), release('live_verified', {evidence: {...release().evidence, health_check: {...release().evidence.health_check, status: 'fail'}}}), release('live_verified', {evidence: {...release().evidence, health_check: {...release().evidence.health_check, at: 'today'}}}), release('live_verified', {transitions: []}), release('live_verified', {transitions: [{from: 'deployed', to: 'live_verified', at: STAMP}]}), release('live_verified', {transitions: [{from: 'converged', to: 'deployed', at: STAMP}]}), release('live_verified', {transitions: [{from: 'converged', to: 'live_verified', at: 'today'}]})]; bad.forEach((raw) => assert.equal(api.releaseEvidence(raw), null)); const page = api.normalizeReleasePage(releasePage([release(), bad[1], release('merged')]), IDENTITY, PROJECT, CONVERSATION); assert.equal(page.items.length, 2); assert.equal(page.withheld, 1); const invalidPages = [null, {items: [], next: null, extra: true}, releasePage([], {next: 'cursor'}), releasePage(null), releasePage(Array(101).fill(release()))]; invalidPages.forEach((raw) => assert.throws(() => api.normalizeReleasePage(raw, IDENTITY, PROJECT, CONVERSATION), /Release/)); }); test('all three extensions expose inert accessible capability gaps without calls', () => { const cards = api.normalizeCapabilityCards(capability({multimodal: [], onboarding: [], release: []}), IDENTITY); const client = makeClient(['hux.foundation', 'hux.projects', 'hux.artifacts', 'hux.autonomy', 'hux.friendly_modes', 'hux.multimodal', 'hux.onboarding', 'hux.release_followthrough']); let calls = 0; client.request = async () => { calls += 1; return {body: {}}; }; const options = {document, projectId: PROJECT, conversationId: CONVERSATION, sessionId: SESSION, cards: () => cards, idempotencyKey: () => 'suggest:key:0001'}; const panels = [new FakeNode('div'), new FakeNode('div'), new FakeNode('div')]; api.multimodalExtension(options).render({client, container: panels[0]}); const onboarding = api.onboardingExtension(options); onboarding.extension.render({client, container: panels[1]}); api.releaseExtension(options).render({client, container: panels[2]}); assert.equal(calls, 0); panels.forEach((panel) => { assert.match(allText(panel), /Not available/); assert.equal(descendants(panel).some((node) => node.tagName === 'BUTTON'), false); }); return onboarding.present({surface: 'chat', context: 'idle'}).then((shown) => assert.equal(shown, false)); }); test('complete runtime renders read-only media and release evidence and saves explicit suggestion choices', async () => { const calls = []; const actions = []; const fetcher = async (url, init) => { calls.push({url, init}); if (url.endsWith('/capabilities')) return response(200, capability()); if (url.endsWith('/multimodal/items')) return response(200, mediaPage()); if (url.endsWith('/releases')) return response(200, releasePage([release(), release('merged')])); if (url.endsWith('/suggestions/evaluate')) return response(200, evaluation()); if (url.endsWith('/decisions')) return response(200, decision()); throw new Error(`unexpected ${url}`); }; const runtime = api.createWaveCRuntime({document, fetcher, expectedIdentity: IDENTITY, projectId: PROJECT, conversationId: CONVERSATION, sessionId: SESSION, idempotencyKey: () => 'suggest:key:0001', onSuggestionAction: async (intent) => actions.push(intent)}); const root = new FakeNode('main'); assert.equal(await runtime.presentSuggestion({surface: 'chat', context: 'idle'}), false); await runtime.mount(root); await flush(); assert.match(allText(root), /photo.png/); assert.match(allText(root), /Live verified/); assert.match(allText(root), /not live verified/); assert.equal(allText(root).includes('merged'), false); assert.equal(calls.filter((call) => call.url.endsWith('/suggestions/evaluate')).length, 0); assert.equal(await runtime.presentSuggestion({surface: 'chat', context: 'after_artifact'}), true); assert.match(allText(root), /Run this every week/); byText(root, 'Use suggestion').click(); await flush(); assert.deepEqual(actions, [{type: 'start_workflow', conversationId: CONVERSATION}]); const mutation = calls.find((call) => call.url.endsWith('/decisions')); assert.equal(mutation.init.headers['Idempotency-Key'], 'suggest:key:0001'); assert.equal(mutation.init.credentials, 'same-origin'); assert.equal(mutation.init.cache, 'no-store'); assert.deepEqual(JSON.parse(mutation.init.body), {decision: 'acted', clicked: true}); assert.equal(mutation.init.headers['If-Match'], '1'); assert.match(allText(root), /Suggestion completed/); assert.equal(await runtime.presentSuggestion({surface: 'chat', context: 'after_artifact'}), false); await assert.rejects(() => runtime.mount(root), /already mounted/); runtime.destroy(); }); test('suggestions never auto-act and failures never claim success', async () => { const calls = []; const client = makeClient(['hux.foundation', 'hux.projects', 'hux.friendly_modes', 'hux.onboarding', 'hux.privacy']); client.request = async (path, init) => { calls.push({path, init}); if (path.endsWith('/suggestions/evaluate')) return {body: evaluation()}; if (path.endsWith('/decisions')) return {body: decision({state: {...decision().state, owner: 'usr_ffffffffffffffff'}})}; throw new Error('unexpected'); }; const cards = api.normalizeCapabilityCards(capability(), IDENTITY); const panel = new FakeNode('div'); const onboarding = api.onboardingExtension({document, projectId: PROJECT, conversationId: CONVERSATION, sessionId: SESSION, cards: () => cards, idempotencyKey: () => 'suggest:key:0002'}); onboarding.extension.render({client, container: panel}); assert.equal(calls.length, 0); await onboarding.present({surface: 'chat', context: 'after_artifact'}); byText(panel, 'Use suggestion').click(); await flush(); assert.equal(calls.length, 1); assert.match(allText(panel), /not connected/); await onboarding.present({surface: 'chat', context: 'after_artifact'}); byText(panel, 'Not now').click(); await flush(); assert.match(allText(panel), /could not be saved/); }); test('renderers withhold invalid network records and use alert states', async () => { const client = makeClient(['hux.foundation', 'hux.projects', 'hux.artifacts', 'hux.autonomy', 'hux.multimodal', 'hux.release_followthrough']); const cards = api.normalizeCapabilityCards(capability(), IDENTITY); const panels = [new FakeNode('div'), new FakeNode('div')]; client.request = async (path) => ({body: path.endsWith('/releases') ? releasePage([], {next: 'forged'}) : mediaPage({identity: null})}); const options = {document, projectId: PROJECT, conversationId: CONVERSATION, cards: () => cards}; api.multimodalExtension(options).render({client, container: panels[0]}); api.releaseExtension(options).render({client, container: panels[1]}); await flush(); panels.forEach((panel) => assert.equal(descendants(panel)[0].getAttribute('role'), 'alert')); }); test('runtime validates scope, capability responses, and browser export', async () => { for (const options of [{}, {document, fetcher: async () => response(404, {}), expectedIdentity: IDENTITY, projectId: 'bad', conversationId: CONVERSATION, sessionId: SESSION}, {document, fetcher: async () => response(404, {}), expectedIdentity: IDENTITY, projectId: PROJECT, conversationId: 'bad', sessionId: SESSION}, {document, fetcher: async () => response(404, {}), expectedIdentity: IDENTITY, projectId: PROJECT, conversationId: CONVERSATION, sessionId: 'bad'}]) { assert.throws(() => api.createWaveCRuntime(options), /requires/); } const runtime = api.createWaveCRuntime({document, fetcher: async () => response(404, {}), expectedIdentity: IDENTITY, projectId: PROJECT, conversationId: CONVERSATION, sessionId: SESSION}); const target = new FakeNode('main'); await runtime.mount(target); assert.equal(runtime.client.getState().phase, 'disabled'); runtime.destroy(); const source = fs.readFileSync('dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.js', 'utf8'); const context = {globalThis: {}, HermesHuxFoundation: {}, HermesHuxShell: {}, HermesHuxWaveAContract: {}}; context.globalThis = context; vm.runInNewContext(source, context); assert.equal(typeof context.HermesHuxWaveC.createWaveCRuntime, 'function'); });