generated from Labyricorn/labyricorn-project-template
80 lines
5.5 KiB
JavaScript
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);
|
|
});
|