generated from Labyricorn/labyricorn-project-template
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.
248 lines
13 KiB
JavaScript
248 lines
13 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import vm from 'node:vm';
|
|
import { ExhibitHost } from '../src/host.js';
|
|
import { postMessageTransport } from '../src/transport/post-message.js';
|
|
|
|
const SHARED = new URL('../test-fixtures/reference-exhibits/shared/', import.meta.url);
|
|
const ROOT = new URL('../test-fixtures/reference-exhibits/', import.meta.url);
|
|
const BASE_ORIGIN = 'http://127.0.0.1:4173';
|
|
|
|
function source(base, file) {
|
|
return readFileSync(new URL(file, base), 'utf8');
|
|
}
|
|
|
|
/**
|
|
* A REAL postMessage transport between two window-like objects -- not a
|
|
* synthetic in-memory peer that hand-crafts contract responses (see
|
|
* tests/host.test.js's peer() fixture, which fakes the wire protocol
|
|
* itself). This harness only supplies the generic browser plumbing --
|
|
* origin-checked delivery, async dispatch, and window.source identity,
|
|
* exactly per the postMessage spec -- and lets the REAL, unmodified
|
|
* production code on both ends (src/host.js + src/transport/post-message.js
|
|
* for NGN, and the exhibit-side code under test-fixtures/reference-exhibits/)
|
|
* do 100% of the actual protocol handling. This is the only way an
|
|
* exact-string envelope-gating bug living inside an exhibit's inbound
|
|
* message filter can ever be caught by an automated test -- a hand-rolled
|
|
* peer() fixture that answers `hello` directly can never exercise that gate
|
|
* at all. An optional `rewriteOutgoing` hook lets a test mutate every
|
|
* message NGN sends before it crosses the wire, without touching src/host.js
|
|
* itself -- used below to prove the advisory `xzbt` tag really is ignored.
|
|
*/
|
|
function makeWindowPair(exhibitPath, { rewriteOutgoing } = {}) {
|
|
const ngn = { location: { origin: BASE_ORIGIN, href: BASE_ORIGIN + '/' }, listeners: new Set() };
|
|
const exhibit = { location: { origin: BASE_ORIGIN, href: BASE_ORIGIN + exhibitPath }, listeners: new Set() };
|
|
ngn.parent = ngn;
|
|
exhibit.parent = ngn;
|
|
for (const w of [ngn, exhibit]) {
|
|
w.addEventListener = (type, fn) => { if (type === 'message') w.listeners.add(fn); };
|
|
w.removeEventListener = (type, fn) => { if (type === 'message') w.listeners.delete(fn); };
|
|
}
|
|
// Real postMessage semantics: targetOrigin is checked at delivery time,
|
|
// delivery is asynchronous, and event.source is the caller's own window
|
|
// object -- never something the message payload can spoof.
|
|
exhibit.postMessage = (data, targetOrigin) => {
|
|
if (targetOrigin !== '*' && targetOrigin !== exhibit.location.origin) return;
|
|
let payload = data;
|
|
if (rewriteOutgoing) payload = rewriteOutgoing(payload) || payload;
|
|
const cloned = JSON.parse(JSON.stringify(payload));
|
|
setTimeout(() => { for (const fn of [...exhibit.listeners]) fn({ origin: ngn.location.origin, source: ngn, data: cloned }); }, 0);
|
|
};
|
|
ngn.postMessage = (data, targetOrigin) => {
|
|
if (targetOrigin !== '*' && targetOrigin !== ngn.location.origin) return;
|
|
const cloned = JSON.parse(JSON.stringify(data));
|
|
setTimeout(() => { for (const fn of [...ngn.listeners]) fn({ origin: exhibit.location.origin, source: exhibit, data: cloned }); }, 0);
|
|
};
|
|
return { ngn, exhibit };
|
|
}
|
|
|
|
/** Boots the real shared-core exhibit-side scripts (unmodified) in their own
|
|
* vm realm, with `window` bound to the fake-but-spec-faithful exhibit window. */
|
|
function bootSharedCoreExhibit(exhibitWindow, files) {
|
|
const ctx = vm.createContext({ window: exhibitWindow, setTimeout, clearTimeout, console, Date, Math });
|
|
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
|
vm.runInContext(source(SHARED, 'host-transport.js'), ctx);
|
|
for (const file of files) vm.runInContext(source(ROOT, file), ctx);
|
|
return ctx;
|
|
}
|
|
|
|
async function connectThroughRealTransport({ exhibitPath, bootFiles, createCore, rewriteOutgoing }) {
|
|
const { ngn, exhibit } = makeWindowPair(exhibitPath, { rewriteOutgoing });
|
|
const ctx = bootSharedCoreExhibit(exhibit, bootFiles);
|
|
const core = createCore(ctx);
|
|
const hostTransport = new ctx.window.XZBTHostTransport({ core });
|
|
|
|
const frame = { src: exhibit.location.href, contentWindow: exhibit };
|
|
const transport = postMessageTransport(frame, ngn);
|
|
const host = new ExhibitHost({ timeoutMs: 2000 });
|
|
await host.connect(transport, exhibit.location.href);
|
|
return { host, core, hostTransport };
|
|
}
|
|
|
|
/** Boots the real, self-contained SciFi-XZBT contract-adapter.js (unmodified)
|
|
* -- this adapter does NOT use the shared contract-core.js/host-transport.js
|
|
* at all; it is its own complete Contract implementation with its own
|
|
* window-message bridge (`_setupWindowBridge`). Minimal, real (not
|
|
* hand-rolled-protocol) bindings are supplied so `state.get` and `set` round
|
|
* trip through actual application-shaped state instead of the adapter's own
|
|
* empty {} default -- the bindings only stand in for SciFi's app.js/audio.js
|
|
* runtime, never for any part of the contract adapter itself. */
|
|
function defaultValueFor(target) {
|
|
if (target.kind === 'range') return target.min;
|
|
if (target.kind === 'selection') return target.options[0].value;
|
|
if (target.kind === 'state') {
|
|
if (target.valueType === 'boolean') return false;
|
|
if (target.valueType === 'string') return '';
|
|
return 0;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function bootSciFiAdapter(exhibitWindow) {
|
|
const ctx = vm.createContext({ window: exhibitWindow, setTimeout, clearTimeout, console, Date, Math });
|
|
vm.runInContext(source(ROOT, 'scifi/js/contract-adapter.js'), ctx);
|
|
const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT' });
|
|
|
|
const state = new Map();
|
|
for (const target of adapter.targets.values()) {
|
|
if (target.readable && target.kind !== 'impulse') state.set(target.id, defaultValueFor(target));
|
|
}
|
|
adapter.bindings.getState = () => Object.fromEntries(state);
|
|
adapter.bindings.setters = new Proxy({}, { get: (_t, id) => (value) => { state.set(id, value); } });
|
|
|
|
return { ctx, adapter };
|
|
}
|
|
|
|
async function connectToSciFiThroughRealTransport({ rewriteOutgoing } = {}) {
|
|
const { ngn, exhibit } = makeWindowPair('/test-fixtures/reference-exhibits/scifi/index.html', { rewriteOutgoing });
|
|
const { adapter } = bootSciFiAdapter(exhibit); // constructor wires the real _setupWindowBridge()
|
|
|
|
const frame = { src: exhibit.location.href, contentWindow: exhibit };
|
|
const transport = postMessageTransport(frame, ngn);
|
|
const host = new ExhibitHost({ timeoutMs: 2000 });
|
|
await host.connect(transport, exhibit.location.href);
|
|
return { host, adapter };
|
|
}
|
|
|
|
test('NGN hello reaches a real Contract 5.3 Museum Gallery iframe over the real postMessage transport, session establishes, describe surfaces surfaces', async (t) => {
|
|
const { host, hostTransport } = await connectThroughRealTransport({
|
|
exhibitPath: '/test-fixtures/reference-exhibits/museum-gallery/control.html',
|
|
bootFiles: ['museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js'],
|
|
createCore: (ctx) => {
|
|
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
|
|
return ctx.window.MuseumGalleryContract.create(gallery);
|
|
}
|
|
});
|
|
t.after(() => host.disconnect());
|
|
|
|
assert.equal(host.status, 'connected');
|
|
assert.equal(hostTransport.connected, true, 'host-transport must have accepted the hello and replied');
|
|
assert.equal(host.contract.major, 5);
|
|
assert.equal(host.contract.minor, 3);
|
|
assert.equal(host.sync, 'synchronized');
|
|
assert.ok(Array.isArray(host.surfaces), 'describe() must surface Contract 5.3 presentation surfaces');
|
|
const ids = host.surfaces.map((s) => s.id).sort();
|
|
assert.deepEqual(ids, ['surface.artifact', 'surface.control', 'surface.info-wall']);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* SciFi-XZBT: the real bridge, over the real transport, forward on
|
|
* Contract 5.3 -- this is the fix for the "hello timed out" regression.
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('NGN hello reaches the real SciFi-XZBT bridge over the real postMessage transport and negotiates Contract 5.3', async (t) => {
|
|
const { host, adapter } = await connectToSciFiThroughRealTransport();
|
|
t.after(() => host.disconnect());
|
|
|
|
assert.equal(host.status, 'connected', 'hello must not time out against the real SciFi bridge');
|
|
assert.equal(adapter.sessionActive, true, 'the real adapter must have accepted the hello and opened a session');
|
|
assert.equal(host.contract.major, 5);
|
|
assert.equal(host.contract.minor, 3, 'SciFi-XZBT is a maintained exhibit and must negotiate the current contract, not stay pinned to 5.2');
|
|
assert.equal(host.sessionId, adapter.sessionId);
|
|
});
|
|
|
|
test('SciFi-XZBT: describe, state.get and synchronization all complete over the real transport', async (t) => {
|
|
const { host } = await connectToSciFiThroughRealTransport();
|
|
t.after(() => host.disconnect());
|
|
|
|
assert.equal(host.sync, 'synchronized', 'connect() must run describe + state.get and reach synchronized');
|
|
assert.ok(host.catalog.length > 0, 'describe.result must report a non-empty target catalog');
|
|
assert.ok(host.capabilities.length > 0, 'describe.result must report capabilities');
|
|
assert.ok(host.values.size > 0, 'state.get must report readable persistent state');
|
|
assert.ok(host.values.has('mix.master'), 'a known SciFi target must be present in the snapshot');
|
|
});
|
|
|
|
test('SciFi-XZBT: a representative command (set on a harmless mixer target) round-trips after synchronization', async (t) => {
|
|
const { host } = await connectToSciFiThroughRealTransport();
|
|
t.after(() => host.disconnect());
|
|
|
|
assert.equal(host.sync, 'synchronized');
|
|
const before = host.stateRevision;
|
|
await host.set('mix.master', 0.42);
|
|
assert.equal(host.values.get('mix.master'), 0.42, 'the set value must be reflected in NGN\'s cache');
|
|
assert.ok(host.stateRevision > before, 'a genuine value change must advance stateRevision, proving more than handshake-only connectivity');
|
|
|
|
await assert.doesNotReject(host.invoke('sfx.comm-badge', {}), 'a zero-argument impulse must invoke cleanly through the real bridge');
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Regression coverage for the exact bug class: `xzbt` must never be an
|
|
* equality gate, anywhere a maintained exhibit or the shared transport
|
|
* decides whether to accept a message.
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('Regression: no maintained bridge source gates on exact equality against the advisory `xzbt` field', () => {
|
|
// Matches `<something>.xzbt <op> '<quoted 5.x>'` (in either operand order)
|
|
// for op in {===, !==, ==, !=}. This is a static guard against exactly the
|
|
// bug class fixed here: the field is metadata (Contract §6.5), never a
|
|
// condition a bridge is allowed to branch on to accept or reject a message.
|
|
const gatePattern = /\.xzbt\s*(===|!==|==|!=)\s*['"]5\.\d['"]|['"]5\.\d['"]\s*(===|!==|==|!=)\s*[\w.]*\.xzbt\b/;
|
|
|
|
const files = [
|
|
[SHARED, 'contract-core.js'],
|
|
[SHARED, 'host-transport.js'],
|
|
[ROOT, 'scifi/js/contract-adapter.js'],
|
|
[ROOT, 'museum-gallery/contract-adapter.js'],
|
|
[ROOT, 'haunted-house/contract-adapter.js'],
|
|
[ROOT, 'aquarium/contract-adapter.js'],
|
|
[ROOT, 'planetarium/contract-adapter.js']
|
|
];
|
|
|
|
for (const [base, file] of files) {
|
|
const text = source(base, file);
|
|
assert.ok(!gatePattern.test(text), `${file} must not gate on exact equality against the advisory xzbt field`);
|
|
}
|
|
});
|
|
|
|
test('Regression: Museum Gallery (shared-core) still negotiates when NGN stamps an unexpected advisory xzbt tag', async (t) => {
|
|
const { host, hostTransport } = await connectThroughRealTransport({
|
|
exhibitPath: '/test-fixtures/reference-exhibits/museum-gallery/control.html',
|
|
bootFiles: ['museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js'],
|
|
createCore: (ctx) => {
|
|
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
|
|
return ctx.window.MuseumGalleryContract.create(gallery);
|
|
},
|
|
// A caller stamping a nonsense advisory tag is still a well-formed,
|
|
// major-5-compatible request. If anything on the exhibit side were
|
|
// still comparing this string for equality, this would time out again.
|
|
rewriteOutgoing: (message) => ({ ...message, xzbt: 'not-a-real-version' })
|
|
});
|
|
t.after(() => host.disconnect());
|
|
|
|
assert.equal(host.status, 'connected', 'an unrecognized advisory xzbt tag must not block negotiation');
|
|
assert.equal(hostTransport.connected, true);
|
|
assert.equal(host.contract.minor, 3);
|
|
});
|
|
|
|
test('Regression: SciFi-XZBT still negotiates when NGN stamps an unexpected advisory xzbt tag', async (t) => {
|
|
const { host, adapter } = await connectToSciFiThroughRealTransport({
|
|
rewriteOutgoing: (message) => ({ ...message, xzbt: 'not-a-real-version' })
|
|
});
|
|
t.after(() => host.disconnect());
|
|
|
|
assert.equal(host.status, 'connected', 'an unrecognized advisory xzbt tag must not block the real SciFi bridge either');
|
|
assert.equal(adapter.sessionActive, true);
|
|
assert.equal(host.contract.minor, 3);
|
|
});
|