Files
2026-09-14 07:57:18 -07:00

189 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { ExhibitHost } from '../src/host.js';
import { validateArgs, argumentSchema } from '../src/validation.js';
import { postMessageTransport } from '../src/transport/post-message.js';
import { createServer } from '../server/serve.js';
const descriptors = [
{ id: 'sample.enabled', kind: 'state', valueType: 'boolean' },
{ id: 'sample.level', kind: 'range', min: 0, max: 10, step: .5 },
{ id: 'sample.mode', kind: 'selection', options: [{ value: 'a' }, { value: 'b' }] },
{ id: 'sample.fire', kind: 'impulse', arguments: [{ name: 'message', type: 'string', required: true, maxLength: 8 }] }
].map(d => ({ readable: d.kind !== 'impulse', writable: d.kind !== 'impulse', requires: [], ...d }));
function peer() {
let receive, session = 0, sequence = 0, revision = 0;
const values = { 'sample.enabled': false, 'sample.level': 1, 'sample.mode': 'a' };
const requests = [];
const fixture = {
fail: null, drop: false, held: null, holdState: false, targets: structuredClone(descriptors),
caps: [{ id: 'service', state: 'ready' }],
transport: { subscribe(fn) { receive = fn; return () => {}; }, close() {}, send(m) {
requests.push(m);
if (fixture.drop) return;
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
if (fixture.fail) { receive({ ...out, type: 'error', error: { code: fixture.fail, message: 'Fixture rejection' } }); return; }
if (m.type === 'hello') { session++; sequence = 0; receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Synthetic test peer' } }); }
else if (m.type === 'describe') receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic test peer' }, contract: { major: 5, minor: 2 }, targets: structuredClone(fixture.targets), capabilities: structuredClone(fixture.caps), registryRevision: 1, stateRevision: revision });
else if (m.type === 'state.get') {
const snapshot = { ...out, type: 'state.result', stateRevision: revision, values: { ...values } };
if (fixture.holdState) fixture.held = () => receive(snapshot); else receive(snapshot);
} else {
if (m.type === 'set' && values[m.target] !== m.value) { values[m.target] = m.value; revision++; fixture.event(m.target === 'sample.mode' ? 'selection.changed' : 'state.changed', { target: m.target, value: m.value, stateRevision: revision }); }
if (m.type === 'invoke') fixture.event('action.executed', { target: m.target, args: m.args });
receive({ ...out, type: `${m.type}.result`, ok: true });
}
} },
event(type, payload = {}) { receive({ xzbt: '5.2', type, sessionId: `s${session}`, sequence: ++sequence, timestamp: Date.now(), source: 'ui', ...payload }); },
requests, values, receive(m) { receive(m); }
};
return fixture;
}
async function connected(t) {
const fixture = peer(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 100 });
t.after(() => host.disconnect()); await host.connect(fixture.transport); return { host, fixture };
}
const settle = () => new Promise(resolve => setTimeout(resolve, 25));
test('negotiates, discovers and reads real session fields', async t => {
const { host, fixture } = await connected(t);
assert.equal(host.sessionId, 's1'); assert.equal(host.catalog.length, 4); assert.equal(host.values.get('sample.level'), 1);
assert.deepEqual(fixture.requests.map(r => r.type), ['hello', 'describe', 'state.get']);
assert.equal(fixture.requests[0].sessionId, undefined); assert.equal(fixture.requests[1].sessionId, 's1');
});
test('sets all persistent kinds, invokes arguments, preserves no-op revisions', async t => {
const { host } = await connected(t);
await host.set('sample.enabled', true); await host.set('sample.level', 2.5); await host.set('sample.mode', 'b');
assert.equal(host.stateRevision, 3); await host.set('sample.mode', 'b'); assert.equal(host.stateRevision, 3);
await host.invoke('sample.fire', { message: 'hello' });
assert.equal(host.sequence, 4); assert.equal(host.eventLog.at(-1).type, 'action.executed');
});
test('invalid values and required/unknown arguments never reach transport', async t => {
const { host, fixture } = await connected(t); const before = fixture.requests.length;
for (const [id, value] of [['sample.level', 11], ['sample.level', 1.1], ['sample.mode', 'c'], ['sample.enabled', 'true']]) {
await assert.rejects(host.set(id, value), { code: 'INVALID_VALUE' });
}
await assert.rejects(host.invoke('sample.fire', {}), { code: 'INVALID_VALUE' });
await assert.rejects(host.invoke('sample.fire', { message: 'hi', extra: true }), { code: 'INVALID_VALUE' });
assert.equal(fixture.requests.length, before); assert.equal(host.logs.at(-1).code, 'INVALID_VALUE');
});
test('argument type/enum/bounds/step/length rules and unsupported future types', () => {
const target = { arguments: [
{ name: 'text', type: 'string', required: true, minLength: 2, maxLength: 3 },
{ name: 'count', type: 'integer', required: true, min: 0, max: 4, step: 2 },
{ name: 'flag', type: 'boolean', required: false },
{ name: 'choice', type: 'number', required: false, enum: [1, 2] }
] };
validateArgs(target, { text: 'ok', count: 2, flag: false, choice: 1 });
for (const args of [{ text: 'x', count: 2 }, { text: 'long', count: 2 }, { text: 'ok', count: 3 }, { text: 'ok', count: 2, flag: 0 }, { text: 'ok', count: 2, choice: 3 }]) assert.throws(() => validateArgs(target, args), { code: 'INVALID_VALUE' });
validateArgs({}, {}); assert.throws(() => validateArgs({}, []), { code: 'INVALID_VALUE' });
assert.throws(() => argumentSchema({ arguments: [{ name: 'future', type: 'matrix', required: true }] }), { code: 'UNSUPPORTED_SCHEMA' });
});
test('surfaces exhibit errors and invalid session requires reconnect', async t => {
const { host, fixture } = await connected(t);
fixture.fail = 'CAPABILITY_UNAVAILABLE'; await assert.rejects(host.invoke('sample.fire', { message: 'go' }), { code: fixture.fail });
fixture.fail = 'INVALID_SESSION'; await assert.rejects(host.set('sample.level', 2), { code: fixture.fail });
assert.equal(host.status, 'invalid session'); fixture.fail = null;
await host.connect(fixture.transport); assert.equal(host.sessionId, 's2'); assert.equal(host.sequence, null);
});
test('unsupported version and timeout are explicit failures', async () => {
const fixture = peer(); const host = new ExhibitHost({ timeoutMs: 10 });
fixture.fail = 'UNSUPPORTED_VERSION'; await assert.rejects(host.connect(fixture.transport), { code: 'UNSUPPORTED_VERSION' });
fixture.fail = null; fixture.drop = true; await assert.rejects(host.connect(fixture.transport), { code: 'TIMEOUT' }); host.disconnect();
});
test('native events update cache; shared revision is not a gap', async t => {
const { host, fixture } = await connected(t);
fixture.event('state.changed', { target: 'sample.level', value: 3, stateRevision: 1 });
fixture.event('selection.changed', { target: 'sample.mode', value: 'b', stateRevision: 1 });
assert.equal(host.values.get('sample.level'), 3); assert.equal(host.values.get('sample.mode'), 'b');
assert.ok(!host.logs.some(l => l.code === 'REVISION_GAP'));
});
test('sequence and revision gaps resync and remain visible', async t => {
const { host, fixture } = await connected(t);
fixture.event('action.executed', { target: 'sample.fire' });
fixture.event('state.changed', { sequence: 4, stateRevision: 3, target: 'sample.level', value: 3 });
await settle(); assert.ok(host.logs.some(l => l.code === 'SEQUENCE_GAP')); assert.ok(host.logs.some(l => l.code === 'REVISION_GAP'));
assert.equal(fixture.requests.filter(r => r.type === 'state.get').length, 2);
});
test('capability and registry changes debounce discovery; normal state does not', async t => {
const { host, fixture } = await connected(t);
fixture.caps[0].state = 'error';
fixture.event('registry.changed'); fixture.event('registry.changed'); fixture.event('capability.changed');
await settle(); assert.equal(fixture.requests.filter(r => r.type === 'describe').length, 2);
assert.equal(host.capabilities[0].state, 'error');
fixture.event('state.changed', { target: 'sample.enabled', value: true, stateRevision: 1 });
await settle(); assert.equal(fixture.requests.filter(r => r.type === 'describe').length, 2);
});
test('events arriving during snapshot are replayed without lost updates', async t => {
const { host, fixture } = await connected(t); fixture.holdState = true;
const refresh = host.refresh();
fixture.event('state.changed', { target: 'sample.level', value: 7, stateRevision: 1 });
fixture.held(); await refresh; assert.equal(host.values.get('sample.level'), 7); assert.equal(host.stateRevision, 1);
});
test('old session traffic does not mutate new cache', async t => {
const { host, fixture } = await connected(t); await host.connect(fixture.transport);
fixture.receive({ xzbt: '5.2', type: 'state.changed', sessionId: 's1', sequence: 2, timestamp: 1, target: 'sample.level', value: 9, stateRevision: 2 });
assert.equal(host.values.get('sample.level'), 1); assert.equal(host.sequence, null);
});
test('malformed persistent events trigger recovery', async t => {
const { host, fixture } = await connected(t); fixture.event('state.changed', { target: 'sample.fire', value: true, stateRevision: 1 });
await settle(); assert.ok(host.logs.some(l => l.code === 'INVALID_MESSAGE')); assert.equal(host.values.has('sample.fire'), false);
});
test('postMessage verifies origin and source and cleans up', () => {
let callback, removed = false; const sent = [];
const peerWindow = { postMessage: (...args) => sent.push(args) };
const own = { location: { origin: 'http://localhost', href: 'http://localhost/' }, addEventListener(type, fn) { callback = fn; }, removeEventListener() { removed = true; } };
const transport = postMessageTransport({ src: 'http://localhost/exhibit', contentWindow: peerWindow }, own);
const received = []; transport.subscribe(m => received.push(m));
callback({ origin: 'http://evil', source: peerWindow, data: 1 }); callback({ origin: 'http://localhost', source: {}, data: 2 });
callback({ origin: 'http://localhost', source: peerWindow, data: 3 }); assert.deepEqual(received, [3]);
transport.send({ hello: true }); assert.equal(sent[0][1], 'http://localhost'); transport.close(); assert.ok(removed);
});
test('local server serves host and denies repository/private paths', async t => {
const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); t.after(() => server.close());
const base = `http://127.0.0.1:${server.address().port}`;
const home = await fetch(base); assert.equal(home.status, 200); assert.match(await home.text(), /XZBT-NGN Exhibit Engine/);
for (const route of ['/AGENTS.md', '/.git/config', '/src/..%5cAGENTS.md']) assert.equal((await fetch(base + route)).status, 404);
});
test('invalid reported types do not contaminate the cache', async t => {
const { host, fixture } = await connected(t);
fixture.event('state.changed', { target: 'sample.level', value: null, stateRevision: 1 });
assert.equal(host.values.get('sample.level'), 1);
assert.ok(host.logs.some(l => l.code === 'INVALID_MESSAGE'));
await settle(); assert.equal(host.sync, 'synchronized');
});
test('invalid snapshot values preserve previous cache and mark uncertainty', async t => {
const { host, fixture } = await connected(t);
fixture.values['sample.level'] = null;
await assert.rejects(host.refresh(), { code: 'INVALID_MESSAGE' });
assert.equal(host.values.get('sample.level'), 1); assert.equal(host.sync, 'uncertain');
});
test('snapshot revision cannot roll back a previously observed transaction', async t => {
const { host, fixture } = await connected(t);
fixture.event('state.changed', { target: 'sample.level', value: 4, stateRevision: 1 });
await assert.rejects(host.refresh(), /revision regressed/);
assert.equal(host.stateRevision, 1); assert.equal(host.values.get('sample.level'), 4);
});
test('registry rediscovery adds descriptors and removes obsolete cached targets', async t => {
const { host, fixture } = await connected(t);
fixture.targets = fixture.targets.filter(d => d.id !== 'sample.mode'); delete fixture.values['sample.mode'];
fixture.targets.push({ id: 'unrelated.reading', kind: 'state', valueType: 'number', readable: true, writable: false, requires: [] });
fixture.values['unrelated.reading'] = 6;
fixture.event('registry.changed'); await settle();
assert.equal(host.catalog.length, 4); assert.equal(host.values.has('sample.mode'), false);
assert.equal(host.values.get('unrelated.reading'), 6);
});
test('unsupported future impulse arguments do not hide the catalog', async t => {
const { host, fixture } = await connected(t);
fixture.targets.at(-1).arguments = [{ name: 'future', type: 'matrix', required: true }];
await host.refresh(true); assert.equal(host.catalog.length, 4);
await assert.rejects(host.invoke('sample.fire', { future: [] }), { code: 'UNSUPPORTED_SCHEMA' });
});
test('capability requirements block unusable services without deleting targets', async t => {
const { host, fixture } = await connected(t);
fixture.targets.at(-1).requires = ['service']; fixture.caps[0].state = 'error';
await host.refresh(true); assert.equal(host.catalog.length, 4);
await assert.rejects(host.invoke('sample.fire', { message: 'hello' }), { code: 'CAPABILITY_UNAVAILABLE' });
});