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); });