Files
XZBT-NGN/tests/surface-validation.test.js
T
Labyricorn 745912e451 Steps 6.4-6.7B — Local surfaces, reference-exhibit validation, SciFi Observation surface
One commit for the work accumulated in the working tree since Step 6.3,
which had never been split into per-step commits:

- src/local-surfaces.js + src/surface-url.js (new); src/ui.js,
  src/validation.js, src/connection.js and public/index.html updated for
  local-surface hosting and generic surface rendering
- tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7)
  new; connection/museum-gallery/surface-validation suites updated
- reference exhibits: shared/contract-core.js defaults to Contract 5.3
  (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface
  catalog; aquarium/haunted-house/planetarium adapters updated
- SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js,
  Observation-surface boot branch, local-change hooks, view.pillars /
  view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT
- SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no
  longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on
  its presence/type, matching the host's own envelope validation; the
  adapter now advertises contract minor 3 / version 5.3.0, which it already
  implemented via the 5.3 surfaces field. Root cause of the five failing
  postmessage-interop tests (host hello was silently dropped).
- docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs;
  test-fixtures/PROVENANCE.md resync record

Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7);
SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check
clean for changed files; two pre-existing trailing-whitespace lines remain
in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from
the authoritative SciFi source.

Step 6.7 live verification (browser Observation, packaged standalone) is
still pending and is not claimed here.
2026-09-14 19:45:27 -07:00

556 lines
22 KiB
JavaScript

/**
* 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,<p>not-a-real-origin</p>'
);
// 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');
});