/** * Phase 6.3 — Surface catalog validation and lifecycle tests. * * Covers Contract 5.3 §§31.2-31.5 normative validation order, host state * lifecycle, registry refresh behavior, and the host's protocol-level * ability to negotiate with a genuine Contract 5.2 peer (Contract §28.2). * That is a property of NGN's own negotiation logic against whatever a peer * reports, using synthetic peer fixtures below -- distinct from, and not an * argument for, keeping any of this repository's own maintained reference * exhibits pinned to Contract 5.2. No exhibit-specific IDs appear in this * file. */ import test from 'node:test'; import assert from 'node:assert/strict'; import { validateSurfaceCatalog, validateCatalog } from '../src/validation.js'; import { ExhibitHost } from '../src/host.js'; /* ------------------------------------------------------------------ * * Helpers * ------------------------------------------------------------------ */ /** Minimal valid surface descriptor; caller may override fields. */ function validSurface(overrides = {}) { return { id: 'surface.primary-view', label: 'Primary View', kind: 'surface', primary: true, url: 'primary.html', ...overrides, }; } /** Run validateSurfaceCatalog, capturing diagnostics. */ function validate(surfaces) { const diags = []; const result = validateSurfaceCatalog({ surfaces }, msg => diags.push(msg)); return { result, diags }; } /* ------------------------------------------------------------------ * * Absent / empty array forms (Contract 5.3 §31.3 forms 1 & 2) * ------------------------------------------------------------------ */ test('absent surfaces field is accepted and treated as empty', () => { const { result, diags } = validate(undefined); assert.deepEqual(result, []); assert.equal(diags.length, 0); }); test('surfaces: [] is accepted and treated identically to absent', () => { const { result, diags } = validate([]); assert.deepEqual(result, []); assert.equal(diags.length, 0); }); test('non-array surfaces field treated as absent', () => { const { result } = validate('not-an-array'); assert.deepEqual(result, []); }); /* ------------------------------------------------------------------ * * Individual entry validation — required fields (§31.2) * ------------------------------------------------------------------ */ test('valid single-entry catalog (one primary) is accepted', () => { const { result } = validate([validSurface()]); assert.equal(result.length, 1); assert.equal(result[0].id, 'surface.primary-view'); }); test('entry missing id is discarded', () => { const s = validSurface(); delete s.id; const { result, diags } = validate([s]); assert.deepEqual(result, []); assert.ok(diags.some(d => /Discarded/.test(d))); }); test('entry with non-string id is discarded', () => { const { result } = validate([validSurface({ id: 42 })]); assert.deepEqual(result, []); }); test('bare single-segment id (no dot) is discarded', () => { const { result, diags } = validate([validSurface({ id: 'nodot' })]); assert.deepEqual(result, []); assert.ok(diags.some(d => /Discarded/.test(d))); }); test('id with uppercase is discarded', () => { const { result } = validate([validSurface({ id: 'Surface.control' })]); assert.deepEqual(result, []); }); test('id with leading digit is discarded', () => { const { result } = validate([validSurface({ id: '1surface.control' })]); assert.deepEqual(result, []); }); test('id with empty segment is discarded', () => { const { result } = validate([validSurface({ id: 'surface..control' })]); assert.deepEqual(result, []); }); test('id with underscore is discarded', () => { const { result } = validate([validSurface({ id: 'surface_x.control' })]); assert.deepEqual(result, []); }); test('entry missing label is discarded', () => { const s = validSurface(); delete s.label; const { result, diags } = validate([s]); assert.deepEqual(result, []); assert.ok(diags.some(d => /label/.test(d))); }); test('entry with empty string label is discarded', () => { const { result } = validate([validSurface({ label: '' })]); assert.deepEqual(result, []); }); test('entry with non-string label is discarded', () => { const { result } = validate([validSurface({ label: 123 })]); assert.deepEqual(result, []); }); test('entry with wrong kind is discarded', () => { const { result, diags } = validate([validSurface({ kind: 'state' })]); assert.deepEqual(result, []); assert.ok(diags.some(d => /kind/.test(d))); }); test('entry with kind impulse is discarded', () => { const { result } = validate([validSurface({ kind: 'impulse' })]); assert.deepEqual(result, []); }); test('entry missing kind is discarded', () => { const s = validSurface(); delete s.kind; const { result } = validate([s]); assert.deepEqual(result, []); }); test('entry with non-boolean primary (string) is discarded', () => { const { result, diags } = validate([validSurface({ primary: 'true' })]); assert.deepEqual(result, []); assert.ok(diags.some(d => /primary/.test(d))); }); test('entry with primary: 1 (number) is discarded', () => { const { result } = validate([validSurface({ primary: 1 })]); assert.deepEqual(result, []); }); test('entry with primary: null is discarded', () => { const { result } = validate([validSurface({ primary: null })]); assert.deepEqual(result, []); }); test('entry missing url is discarded', () => { const s = validSurface(); delete s.url; const { result, diags } = validate([s]); assert.deepEqual(result, []); assert.ok(diags.some(d => /url/.test(d))); }); test('absolute url is rejected as individually-invalid', () => { const { result, diags } = validate([validSurface({ url: 'https://evil.example/page.html' })]); assert.deepEqual(result, []); assert.ok(diags.some(d => /url/.test(d))); }); test('protocol-relative url is rejected as individually-invalid', () => { const { result, diags } = validate([validSurface({ url: '//evil.example/page.html' })]); assert.deepEqual(result, []); assert.ok(diags.some(d => /url/.test(d))); }); test('url with scheme (http:) is rejected', () => { const { result } = validate([validSurface({ url: 'http:page.html' })]); assert.deepEqual(result, []); }); test('relative path url is accepted', () => { const { result } = validate([validSurface({ url: 'views/primary.html' })]); assert.equal(result.length, 1); }); test('relative path with query string is accepted', () => { const { result } = validate([validSurface({ url: 'primary.html?surface=main' })]); assert.equal(result.length, 1); }); test('bare query string is accepted (single-page exhibit)', () => { const { result } = validate([validSurface({ url: '?surface=main' })]); assert.equal(result.length, 1); }); test('fragment-only url is accepted', () => { const { result } = validate([validSurface({ url: '#surface-main' })]); assert.equal(result.length, 1); }); /* ------------------------------------------------------------------ * * §31.4 same-origin resolution with exhibitBaseUrl * ------------------------------------------------------------------ */ /** validateSurfaceCatalog with base URL supplied. */ function validateWithBase(surfaces, exhibitBaseUrl) { const diags = []; const result = validateSurfaceCatalog({ surfaces }, exhibitBaseUrl, msg => diags.push(msg)); return { result, diags }; } test('§31.4: relative url resolves same-origin — accepted', () => { const { result } = validateWithBase( [validSurface({ url: 'primary.html' })], 'http://127.0.0.1:4173/test-fixtures/reference-exhibits/gallery/control.html' ); assert.equal(result.length, 1); }); test('§31.4: relative url that resolves to same origin with subdirectory — accepted', () => { const { result } = validateWithBase( [validSurface({ url: '../other/view.html' })], 'http://127.0.0.1:4173/test-fixtures/exhibit/index.html' ); assert.equal(result.length, 1); }); test('§31.4: bare query string resolves same-origin — accepted', () => { const { result } = validateWithBase( [validSurface({ url: '?view=artifact' })], 'http://127.0.0.1:4173/test-fixtures/exhibit/index.html' ); assert.equal(result.length, 1); }); test('§31.4: relative url that resolves cross-origin is rejected as individually-invalid', () => { // This cannot happen with a purely relative URL in practice, but a URL like // '//evil.example/x' would already be caught by structural form check. // To test cross-origin resolution we need a contrived exhibitBaseUrl on a different port. // An absolute URL with a scheme is already blocked by isRelativeSurfaceUrl; here we // verify that a structural-form-valid relative path resolving cross-origin is caught. // We pass a data: URL as base, which will fail to resolve and produce a diagnostic. const { result, diags } = validateWithBase( [validSurface({ url: 'primary.html', primary: true })], 'data:text/html,

not-a-real-origin

' ); // data: scheme — resolved URL will not be same-origin with data: base assert.deepEqual(result, []); assert.ok(diags.some(d => /Discarded/.test(d))); }); test('§31.4: no exhibitBaseUrl — structural form only, relative url accepted without resolution', () => { // When no base URL is supplied (e.g. test-only contexts), structural check only. const { result } = validate([validSurface({ url: 'primary.html' })]); assert.equal(result.length, 1); }); test('individually-invalid primary discarded; remaining valid primary => catalog valid', () => { const entries = [ validSurface({ id: 'INVALID_ID', primary: true }), validSurface({ id: 'surface.secondary', label: 'Secondary', primary: true, url: 'secondary.html' }), ]; const { result } = validate(entries); assert.equal(result.length, 1); assert.equal(result[0].id, 'surface.secondary'); }); test('individually-invalid primary discarded; zero valid primaries => whole catalog fallback', () => { const entries = [ validSurface({ id: 'BAD', primary: true }), validSurface({ id: 'surface.secondary', label: 'Secondary', primary: false, url: 'secondary.html' }), ]; const { result, diags } = validate(entries); assert.deepEqual(result, []); assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d))); }); test('zero primaries among valid entries => whole catalog rejected', () => { const entries = [ validSurface({ id: 'surface.a', label: 'A', primary: false, url: 'a.html' }), validSurface({ id: 'surface.b', label: 'B', primary: false, url: 'b.html' }), ]; const { result, diags } = validate(entries); assert.deepEqual(result, []); assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d))); }); test('multiple valid primaries => whole catalog rejected', () => { const entries = [ validSurface({ id: 'surface.a', label: 'A', primary: true, url: 'a.html' }), validSurface({ id: 'surface.b', label: 'B', primary: true, url: 'b.html' }), ]; const { result, diags } = validate(entries); assert.deepEqual(result, []); assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d))); }); test('all entries individually invalid => absent-equivalent, not malformed-primary', () => { const entries = [ { id: 'BAD ID', label: 'x', kind: 'surface', primary: true, url: 'x.html' }, { id: 'surface.y', label: '', kind: 'surface', primary: true, url: 'y.html' }, ]; const { result, diags } = validate(entries); assert.deepEqual(result, []); assert.ok(!diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d)), 'empty valid set is absent-equivalent, not a primary violation'); }); test('non-empty valid catalog with one primary is fully returned', () => { const entries = [ validSurface({ id: 'surface.ctrl', label: 'Control', primary: true, url: 'ctrl.html' }), validSurface({ id: 'surface.display', label: 'Display', primary: false, url: 'display.html' }), validSurface({ id: 'surface.info', label: 'Info', primary: false, url: 'info.html' }), ]; const { result } = validate(entries); assert.equal(result.length, 3); assert.equal(result.filter(s => s.primary).length, 1); }); /* ------------------------------------------------------------------ * * Optional fields preserved generically (§31.2) * ------------------------------------------------------------------ */ test('optional fields are preserved in validated descriptors', () => { const s = validSurface({ description: 'The operator control surface.', role: 'control', aspectRatio: '16:9', category: 'operator', requires: ['render'], }); const { result } = validate([s]); assert.equal(result.length, 1); assert.equal(result[0].description, 'The operator control surface.'); assert.equal(result[0].role, 'control'); assert.equal(result[0].aspectRatio, '16:9'); assert.equal(result[0].category, 'operator'); assert.deepEqual(result[0].requires, ['render']); }); /* ------------------------------------------------------------------ * * Duplicate id handling * ------------------------------------------------------------------ */ test('duplicate id: second entry with same id is discarded', () => { const entries = [ validSurface({ id: 'surface.ctrl', label: 'First', primary: true, url: 'ctrl.html' }), validSurface({ id: 'surface.ctrl', label: 'Duplicate', primary: false, url: 'ctrl2.html' }), ]; const { result, diags } = validate(entries); assert.equal(result.length, 1); assert.equal(result[0].label, 'First'); assert.ok(diags.some(d => /duplicate/.test(d))); }); /* ------------------------------------------------------------------ * * Malformed surfaces do NOT invalidate targets/capabilities * ------------------------------------------------------------------ */ test('malformed surfaces field does not throw and does not affect validateCatalog', () => { const message = { exhibit: {}, contract: { major: 5 }, registryRevision: 0, stateRevision: 0, capabilities: [], targets: [{ id: 'sample.level', kind: 'state', valueType: 'boolean', readable: true, writable: true, requires: [] }], surfaces: [{ id: 'BAD', label: '', kind: 'not-surface', primary: 'yes', url: 'https://evil.example' }], }; assert.doesNotThrow(() => validateCatalog(message)); const { result } = validate(message.surfaces); assert.deepEqual(result, []); }); /* ------------------------------------------------------------------ * * ExhibitHost lifecycle — surfaces cleared on disconnect, refreshed on * registry.changed (Parts 3 & 4) * ------------------------------------------------------------------ */ function peer53() { let receive, session = 0, sequence = 0; let currentSurfaces = [ { id: 'surface.primary', label: 'Primary', kind: 'surface', primary: true, url: 'primary.html' }, { id: 'surface.secondary', label: 'Secondary', kind: 'surface', primary: false, url: 'secondary.html' }, ]; const fixture = { transport: { subscribe(fn) { receive = fn; return () => {}; }, close() {}, send(m) { const out = { xzbt: '5.3', requestId: m.requestId, sessionId: `s${session}` }; if (m.type === 'hello') { session++; sequence = 0; receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 3 }, exhibit: { product: 'Synthetic 5.3 peer' } }); } else if (m.type === 'describe') { receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.3 peer' }, contract: { major: 5, minor: 3 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean', readable: true, writable: true, requires: [] }], capabilities: [], registryRevision: 1, stateRevision: 0, surfaces: currentSurfaces }); } else if (m.type === 'state.get') { receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } }); } } }, event(type, payload = {}) { receive({ xzbt: '5.3', type, sessionId: `s${session}`, sequence: ++sequence, timestamp: Date.now(), ...payload }); }, setSurfaces(list) { currentSurfaces = list; } }; return fixture; } function peer52() { let receive, session = 0; const fixture = { transport: { subscribe(fn) { receive = fn; return () => {}; }, close() {}, send(m) { const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` }; if (m.type === 'hello') { session++; receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Synthetic 5.2 peer' } }); } else if (m.type === 'describe') { receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.2 peer' }, contract: { major: 5, minor: 2 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean', readable: true, writable: true, requires: [] }], capabilities: [], registryRevision: 1, stateRevision: 0 }); } else if (m.type === 'state.get') { receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } }); } } } }; return fixture; } const settle = () => new Promise(resolve => setTimeout(resolve, 25)); test('Contract 5.3 exhibit: surfaces discovered after connect', async t => { const fixture = peer53(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(fixture.transport); assert.equal(host.contractMinor, 3); assert.equal(host.surfaces.length, 2); assert.equal(host.surfaces.filter(s => s.primary).length, 1); assert.equal(host.surfaces[0].id, 'surface.primary'); }); test('disconnect clears surface catalog', async t => { const fixture = peer53(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(fixture.transport); assert.equal(host.surfaces.length, 2); host.disconnect(); assert.equal(host.surfaces.length, 0, 'surfaces must be empty after disconnect'); }); test('reconnect after disconnect rediscovers surface catalog', async t => { const fixture = peer53(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(fixture.transport); assert.equal(host.surfaces.length, 2); host.disconnect(); assert.equal(host.surfaces.length, 0); await host.connect(fixture.transport); assert.equal(host.surfaces.length, 2, 'surfaces restored after reconnect'); }); test('registry.changed refreshes surface catalog when it changes', async t => { const fixture = peer53(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(fixture.transport); assert.equal(host.surfaces.length, 2); fixture.setSurfaces([ { id: 'surface.primary', label: 'Primary Updated', kind: 'surface', primary: true, url: 'primary.html' } ]); fixture.event('registry.changed'); await settle(); assert.equal(host.surfaces.length, 1); assert.equal(host.surfaces[0].label, 'Primary Updated'); }); test('registry.changed can clear surfaces entirely', async t => { const fixture = peer53(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(fixture.transport); assert.equal(host.surfaces.length, 2); fixture.setSurfaces([]); fixture.event('registry.changed'); await settle(); assert.equal(host.surfaces.length, 0); }); /* ------------------------------------------------------------------ * * Contract 5.2 backward compatibility * ------------------------------------------------------------------ */ test('Contract 5.2 exhibit: surfaces stays empty, no error', async t => { const fixture = peer52(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(fixture.transport); assert.equal(host.contractMinor, 2); assert.equal(host.surfaces.length, 0); assert.ok(!host.logs.some(l => l.code === 'SURFACE_CATALOG')); }); test('Contract 5.2 exhibit: controls, state and events still work', async t => { const fixture = peer52(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(fixture.transport); assert.equal(host.status, 'connected'); assert.equal(host.sync, 'synchronized'); assert.equal(host.catalog.length, 1); assert.equal(host.surfaces.length, 0); }); test('NGN emits xzbt 5.3 advisory on hello; 5.2 exhibit negotiates normally', async t => { const sentMessages = []; let receive, session = 0; const transport = { subscribe(fn) { receive = fn; return () => {}; }, close() {}, send(m) { sentMessages.push(m); const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` }; if (m.type === 'hello') { session++; receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Test 5.2' } }); } else if (m.type === 'describe') { receive({ ...out, type: 'describe.result', exhibit: { product: 'Test 5.2' }, contract: { major: 5, minor: 2 }, targets: [], capabilities: [], registryRevision: 0, stateRevision: 0 }); } else if (m.type === 'state.get') { receive({ ...out, type: 'state.result', stateRevision: 0, values: {} }); } } }; const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 }); t.after(() => host.disconnect()); await host.connect(transport); const helloMsg = sentMessages.find(m => m.type === 'hello'); assert.equal(helloMsg.xzbt, '5.3', 'NGN emits xzbt 5.3 advisory'); assert.equal(host.contractMinor, 2, 'negotiated minor reflects what 5.2 exhibit reported'); assert.equal(host.status, 'connected'); });