Files
XZBT-NGN/test-fixtures/host-verification.js
T
2026-09-14 13:01:45 -07:00

80 lines
5.5 KiB
JavaScript

import { ExhibitHost } from '/src/host.js';
import { postMessageTransport } from '/src/transport/post-message.js';
const results = document.querySelector('#results');
const run = document.querySelector('#run');
function assert(condition, message) { if (!condition) throw new Error(message); }
async function rejected(operation, code) {
try { await operation(); } catch (error) { assert(error.code === code, `Expected ${code}, got ${error.code}`); return; }
throw new Error(`Expected rejection ${code}`);
}
async function raw(transport, message) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { off(); reject(new Error('Raw request timeout')); }, 3000);
const off = transport.subscribe(response => {
if (response.requestId !== message.requestId) return;
clearTimeout(timeout); off(); resolve(response);
});
transport.send(message);
});
}
run.addEventListener('click', async () => {
run.disabled = true; results.textContent = `Started: ${new Date().toISOString()}\n`; document.querySelector('#fixtures').replaceChildren();
let passed = 0;
for (const name of ['aquarium', 'planetarium', 'haunted-house']) {
const host = new ExhibitHost();
const frame = document.createElement('iframe'); frame.title = `${name} test exhibit`;
try {
const loaded = new Promise(resolve => frame.addEventListener('load', resolve, { once: true }));
frame.src = `/test-fixtures/reference-exhibits/${name}/index.html`;
document.querySelector('#fixtures').append(frame); await loaded;
const versionTransport = postMessageTransport(frame);
const unsupported = await raw(versionTransport, { xzbt: '5.2', type: 'hello', requestId: 'bad-version', supportedContractMajors: [999] });
assert(unsupported.error?.code === 'UNSUPPORTED_VERSION', 'Version rejection'); versionTransport.close();
await host.connect(postMessageTransport(frame));
assert(host.status === 'connected' && host.sync === 'synchronized', 'Session and snapshot');
const session = host.sessionId;
assert(host.catalog.length > 0, 'Dynamic catalog');
const choose = kind => host.catalog.find(t => t.kind === kind && (kind === 'impulse' || t.writable) && t.requires.length === 0);
const state = choose('state'), range = choose('range'), selection = choose('selection'), impulse = choose('impulse');
assert(state && range && selection && impulse, 'Representative kinds are present');
await host.set(state.id, !host.values.get(state.id));
const rangeValue = host.values.get(range.id) === range.min ? range.max : range.min;
await host.set(range.id, rangeValue); assert(host.values.get(range.id) === rangeValue, 'Range readback');
const option = selection.options.find(o => o.value !== host.values.get(selection.id));
await host.set(selection.id, option.value); assert(host.values.get(selection.id) === option.value, 'Selection readback');
const revision = host.stateRevision; await host.set(selection.id, option.value);
assert(host.stateRevision === revision, 'No-op revision');
await host.invoke(impulse.id, {});
assert(host.eventLog.some(e => e.type === 'action.executed' && e.target === impulse.id), 'Action event delivered');
assert(host.eventLog.some(e => e.type === 'selection.changed'), 'Selection event delivered');
await rejected(() => host.set(range.id, range.max + range.step), 'INVALID_VALUE');
// Bypass the host's local value checks to prove exhibit errors survive the transport.
await rejected(() => host.request('set', { target: range.id, value: range.max + range.step }), 'INVALID_VALUE');
await rejected(() => host.request('invoke', { target: impulse.id, args: { undeclared: true } }), 'INVALID_VALUE');
const withArguments = host.catalog.find(t => t.kind === 'impulse' && t.arguments?.length);
if (withArguments) {
const args = Object.fromEntries(withArguments.arguments.map(a => [a.name, a.min ?? 1]));
await host.invoke(withArguments.id, args);
assert(host.eventLog.some(e => e.target === withArguments.id && Object.keys(e.args ?? {}).length), 'Arguments delivered');
}
const revisionBeforeReconnect = host.stateRevision;
await host.connect(postMessageTransport(frame));
assert(host.sessionId !== session && host.sequence === null, 'Reconnect creates new session and resets sequence');
assert(host.stateRevision >= revisionBeforeReconnect, 'Reconnect preserves exhibit revision');
// A second handshake invalidates the host's session at the real exhibit.
const sideTransport = postMessageTransport(frame);
await raw(sideTransport, { xzbt: '5.2', type: 'hello', requestId: 'replace-session', supportedContractMajors: [5] });
sideTransport.close();
await rejected(() => host.request('state.get'), 'INVALID_SESSION');
assert(host.status === 'invalid session', 'Invalid session is explicit');
await host.connect(postMessageTransport(frame)); assert(host.sync === 'synchronized', 'Recovery after session replacement');
results.textContent += `PASS ${name}: ${host.catalog.length} targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery.\n`;
passed++;
} catch (error) { results.textContent += `FAIL ${name}: ${error.message}\n`; }
finally { host.disconnect(); frame.remove(); }
}
results.textContent += `${passed}/3 real exhibits passed.\nFinished: ${new Date().toISOString()}\n`; run.disabled = false;
console.info(results.textContent);
});