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