generated from Labyricorn/labyricorn-project-template
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.
This commit is contained in:
@@ -50,6 +50,19 @@ test('stalled document load ends with recoverable error', async t => {
|
||||
assert.equal(errors[0].code, 'LOAD_TIMEOUT');
|
||||
connection.reconnect(); assert.equal(frames.length, 2);
|
||||
});
|
||||
|
||||
test('redirected control document supplies the final same-origin base; foreign navigation is refused', t => {
|
||||
const { connection, frames, connections, errors } = setup(t);
|
||||
connection.load('/public/entry.html');
|
||||
frames[0].contentWindow = { location: { href: base + 'public/gallery/control.html' } };
|
||||
frames[0].handlers.load();
|
||||
assert.equal(connection.url, base + 'public/gallery/control.html');
|
||||
assert.equal(connections.length, 1);
|
||||
frames[0].contentWindow.location.href = 'https://other.example/';
|
||||
frames[0].handlers.load();
|
||||
assert.equal(connection.frame, null); assert.equal(errors[0].code, 'INVALID_LOCATION');
|
||||
assert.equal(connections.length, 1);
|
||||
});
|
||||
test('optional descriptor metadata is permissive when omitted and typed when supplied', () => {
|
||||
const message = { exhibit: {}, contract: { major: 5 }, registryRevision: 0, stateRevision: 0, capabilities: [],
|
||||
targets: [{ id: 'sample.enabled', kind: 'state', readable: true, writable: true, valueType: 'boolean', requires: [] }] };
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { ExhibitHost } from '../src/host.js';
|
||||
import { LocalSurfaces, checkSurfaceResource } from '../src/local-surfaces.js';
|
||||
import { resolveSurfaceURL } from '../src/surface-url.js';
|
||||
import { postMessageTransport } from '../src/transport/post-message.js';
|
||||
import { createServer } from '../server/serve.js';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
|
||||
const base = 'http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html';
|
||||
const directory = new URL('.', base).href;
|
||||
for (const [reference, expected] of [
|
||||
['artifact.html', directory + 'artifact.html'], ['./artifact.html', directory + 'artifact.html'],
|
||||
['../foo/bar.html', new URL('../foo/bar.html', base).href],
|
||||
['?mode=wall', base + '?mode=wall'], ['#section', base + '#section'],
|
||||
['room/../artifact.html', directory + 'artifact.html']
|
||||
]) test(`surface URL resolves ${reference} against supplying exhibit`, () => assert.equal(resolveSurfaceURL(reference, base), expected));
|
||||
|
||||
test('surface URL rejects invalid, absolute, protocol-relative and cross-origin forms', () => {
|
||||
for (const value of [null, {}, 1, '', ' ', 'https://evil.example/', base, '//evil.example/',
|
||||
'\\\\evil.example/x', '/\\evil.example/x', ' javascript:alert(1)', 'java\nscript:alert(1)']) {
|
||||
assert.throws(() => resolveSurfaceURL(value, base), undefined, String(value));
|
||||
}
|
||||
assert.throws(() => resolveSurfaceURL('view.html', 'data:text/html,test'));
|
||||
});
|
||||
|
||||
function galleryCatalog() {
|
||||
const context = vm.createContext({ window: {}, console, Date, Math, setTimeout, clearTimeout });
|
||||
for (const file of ['shared/contract-core.js', 'museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js']) {
|
||||
vm.runInContext(readFileSync(new URL('../test-fixtures/reference-exhibits/' + file, import.meta.url), 'utf8'), context);
|
||||
}
|
||||
const core = context.window.MuseumGalleryContract.create(new context.window.MuseumGalleryExhibit.Gallery());
|
||||
return JSON.parse(JSON.stringify(core.describe().surfaces));
|
||||
}
|
||||
|
||||
/** SciFi-XZBT's contract-adapter.js is self-contained (no shared
|
||||
* contract-core.js dependency, unlike Museum Gallery); its own
|
||||
* surface-mode.js supplies the real, production two-entry catalog. */
|
||||
function sciFiCatalog() {
|
||||
const context = vm.createContext({ window: {}, console, Date, Math, setTimeout, clearTimeout, URLSearchParams });
|
||||
for (const file of ['scifi/js/surface-mode.js', 'scifi/js/contract-adapter.js']) {
|
||||
vm.runInContext(readFileSync(new URL('../test-fixtures/reference-exhibits/' + file, import.meta.url), 'utf8'), context);
|
||||
}
|
||||
const surfaces = context.window.XZBTSurfaceMode.SURFACES('xi-test-instance');
|
||||
const adapter = new context.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', surfaces, instanceId: 'xi-test-instance', bindings: {} });
|
||||
return JSON.parse(JSON.stringify(adapter.describe().surfaces));
|
||||
}
|
||||
function setup(t, options = {}) {
|
||||
const host = new ExhibitHost();
|
||||
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() });
|
||||
const frames = [];
|
||||
const manager = new LocalSurfaces({ host, checkResource: async () => {}, ...options,
|
||||
createFrame(entry) {
|
||||
const frame = new EventTarget();
|
||||
frame.contentWindow = { location: { href: entry.url } };
|
||||
frame.isConnected = true;
|
||||
frame.remove = () => { frame.isConnected = false; };
|
||||
frames.push(frame); return frame;
|
||||
}
|
||||
});
|
||||
t.after(() => manager.dispose());
|
||||
const secondary = host.surfaces.find(s => !s.primary).id;
|
||||
return { host, manager, frames, secondary };
|
||||
}
|
||||
test('real Museum descriptors: primary reuses control; opening, duplicate open, reload, close and reopen', async t => {
|
||||
const { host, manager, frames, secondary } = setup(t);
|
||||
const primary = host.surfaces.find(s => s.primary).id;
|
||||
assert.equal(manager.entries.get(primary).state, 'control');
|
||||
await manager.open(primary); manager.close(primary); await manager.reload(primary);
|
||||
assert.equal(frames.length, 0);
|
||||
const opening = manager.open(secondary);
|
||||
assert.equal(manager.entries.get(secondary).state, 'loading');
|
||||
await manager.open(secondary); await opening;
|
||||
assert.equal(frames.length, 1);
|
||||
assert.equal(frames[0].src, resolveSurfaceURL(host.surfaces.find(s => s.id === secondary).url, base));
|
||||
frames[0].dispatchEvent(new Event('load'));
|
||||
assert.equal(manager.entries.get(secondary).state, 'open');
|
||||
await manager.open(secondary); assert.equal(frames.length, 1);
|
||||
await manager.reload(secondary); assert.equal(frames[0].isConnected, false);
|
||||
frames[0].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'loading');
|
||||
frames[1].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'open');
|
||||
manager.close(secondary); assert.equal(manager.entries.get(secondary).state, 'closed');
|
||||
assert.equal(frames[1].isConnected, false);
|
||||
await manager.open(secondary); assert.equal(frames.length, 3);
|
||||
assert.equal(host.sessionId, 'control-session'); assert.equal(host.sync, 'synchronized');
|
||||
});
|
||||
test('release() invokes a frame\'s __xzbtSurfaceDispose hook synchronously before removing it, on reload, close and disconnect', async t => {
|
||||
// Deterministic lifecycle fix (Step 6.5B): local-surfaces.js does not know
|
||||
// or care what an exhibit's own attach/detach protocol looks like -- it
|
||||
// just gives a same-origin frame one last synchronous chance to clean
|
||||
// itself up, via a well-known optional global, before the frame is
|
||||
// removed. This proves the call happens, happens before removal, and
|
||||
// that a frame with no such hook (or one that throws) is unaffected.
|
||||
const host = new ExhibitHost();
|
||||
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() });
|
||||
const frames = [];
|
||||
const disposeCalls = [];
|
||||
const manager = new LocalSurfaces({
|
||||
host, checkResource: async () => {},
|
||||
createFrame(entry) {
|
||||
const frame = new EventTarget();
|
||||
const order = [];
|
||||
frame.contentWindow = {
|
||||
location: { href: entry.url },
|
||||
__xzbtSurfaceDispose: () => { order.push('dispose'); disposeCalls.push(entry.url); }
|
||||
};
|
||||
frame.isConnected = true;
|
||||
frame.remove = () => { order.push('remove'); frame.isConnected = false; frame._order = order; };
|
||||
frames.push(frame); return frame;
|
||||
}
|
||||
});
|
||||
t.after(() => manager.dispose());
|
||||
const secondary = host.surfaces.find(s => !s.primary).id;
|
||||
|
||||
await manager.open(secondary);
|
||||
frames[0].dispatchEvent(new Event('load'));
|
||||
await manager.reload(secondary);
|
||||
assert.deepEqual(frames[0]._order, ['dispose', 'remove'], 'dispose runs before remove on reload');
|
||||
assert.equal(disposeCalls.length, 1);
|
||||
|
||||
frames[1].dispatchEvent(new Event('load'));
|
||||
manager.close(secondary);
|
||||
assert.deepEqual(frames[1]._order, ['dispose', 'remove'], 'dispose runs before remove on close');
|
||||
assert.equal(disposeCalls.length, 2);
|
||||
|
||||
await manager.open(secondary);
|
||||
frames[2].dispatchEvent(new Event('load'));
|
||||
host.disconnect();
|
||||
assert.deepEqual(frames[2]._order, ['dispose', 'remove'], 'dispose runs before remove on disconnect/exhibit-switch teardown');
|
||||
assert.equal(disposeCalls.length, 3);
|
||||
});
|
||||
test('release() tolerates frames with no dispose hook, and a throwing hook does not block or corrupt teardown', async t => {
|
||||
const host = new ExhibitHost();
|
||||
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() });
|
||||
const frames = [];
|
||||
let throwingHookCalls = 0;
|
||||
const manager = new LocalSurfaces({
|
||||
host, checkResource: async () => {},
|
||||
createFrame(entry) {
|
||||
const frame = new EventTarget();
|
||||
// No __xzbtSurfaceDispose at all -- matches every non-Museum-Gallery
|
||||
// reference exhibit local-surfaces.js also serves.
|
||||
frame.contentWindow = { location: { href: entry.url } };
|
||||
frame.isConnected = true;
|
||||
frame.remove = () => { frame.isConnected = false; };
|
||||
frames.push(frame); return frame;
|
||||
}
|
||||
});
|
||||
t.after(() => manager.dispose());
|
||||
const secondary = host.surfaces.find(s => !s.primary).id;
|
||||
await manager.open(secondary);
|
||||
frames[0].dispatchEvent(new Event('load'));
|
||||
await manager.reload(secondary); // must not throw despite no hook present
|
||||
assert.equal(frames[0].isConnected, false);
|
||||
assert.equal(manager.entries.get(secondary).state, 'loading');
|
||||
|
||||
frames[1].contentWindow.__xzbtSurfaceDispose = () => { throwingHookCalls += 1; throw new Error('boom'); };
|
||||
frames[1].dispatchEvent(new Event('load'));
|
||||
manager.close(secondary); // a throwing hook must not prevent removal or the state transition
|
||||
assert.equal(throwingHookCalls, 1);
|
||||
assert.equal(frames[1].isConnected, false);
|
||||
assert.equal(manager.entries.get(secondary).state, 'closed');
|
||||
});
|
||||
test('a separate primary page and query/fragment views are rendered from generic descriptors', async t => {
|
||||
const { host, manager, frames } = setup(t);
|
||||
for (const url of ['primary.html', '?mode=wall', '#primary']) {
|
||||
host.surfaces = [{ id: 'other.main', label: 'Another primary', primary: true, kind: 'surface', url }]; host.changed();
|
||||
assert.equal(manager.entries.get('other.main').state, 'closed');
|
||||
await manager.open('other.main'); assert.equal(frames.at(-1).src, resolveSurfaceURL(url, base));
|
||||
}
|
||||
});
|
||||
test('real SciFi-XZBT descriptors: the primary resolves to the already-open control pane and opens no second frame', async t => {
|
||||
const sciFiBase = 'http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html';
|
||||
const host = new ExhibitHost();
|
||||
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: sciFiBase, surfaces: sciFiCatalog() });
|
||||
const frames = [];
|
||||
const manager = new LocalSurfaces({
|
||||
host, checkResource: async () => {},
|
||||
createFrame(entry) {
|
||||
const frame = new EventTarget();
|
||||
frame.contentWindow = { location: { href: entry.url } };
|
||||
frame.isConnected = true;
|
||||
frame.remove = () => { frame.isConnected = false; };
|
||||
frames.push(frame); return frame;
|
||||
}
|
||||
});
|
||||
t.after(() => manager.dispose());
|
||||
const primary = host.surfaces.find(s => s.primary).id;
|
||||
const observation = host.surfaces.find(s => !s.primary).id;
|
||||
assert.equal(primary, 'surface.console');
|
||||
assert.equal(observation, 'surface.observation');
|
||||
// The primary's url ('index.html') resolves to exactly exhibitBaseUrl, so
|
||||
// it is recognized as the already-open control pane -- opening it must
|
||||
// not create a second frame (report §4 "Why url: 'index.html' for the primary").
|
||||
assert.equal(manager.entries.get(primary).state, 'control');
|
||||
await manager.open(primary);
|
||||
assert.equal(frames.length, 0, 'the primary must never be opened as a second frame');
|
||||
|
||||
// The observation surface is a bare query string against the same base
|
||||
// and opens normally as any other secondary surface would.
|
||||
await manager.open(observation);
|
||||
assert.equal(frames.length, 1);
|
||||
assert.equal(frames[0].src, resolveSurfaceURL(host.surfaces.find(s => s.id === observation).url, sciFiBase));
|
||||
assert.ok(frames[0].src.includes('?surface=observation&xi='));
|
||||
});
|
||||
test('disconnect and exhibit switch release all frames and pending opens', async t => {
|
||||
const { host, manager, frames, secondary } = setup(t);
|
||||
await manager.open(secondary);
|
||||
host.disconnect(); assert.equal(manager.entries.size, 0); assert.equal(frames[0].isConnected, false);
|
||||
Object.assign(host, { status: 'connected', exhibitBaseUrl: base, surfaces: galleryCatalog() }); host.changed();
|
||||
await manager.open(secondary);
|
||||
host.exhibitBaseUrl = new URL('../another/control.html', base).href; host.changed();
|
||||
assert.equal(frames[1].isConnected, false);
|
||||
assert.equal(manager.entries.get(secondary).state, 'closed');
|
||||
let finish;
|
||||
manager.checkResource = () => new Promise(resolve => { finish = resolve; });
|
||||
const pending = manager.open(secondary);
|
||||
host.disconnect(); finish(); await pending;
|
||||
assert.equal(frames.length, 2); assert.equal(manager.entries.size, 0);
|
||||
});
|
||||
test('rediscovery preserves unchanged frames and closes removed/changed descriptors', async t => {
|
||||
const { host, manager, frames, secondary } = setup(t);
|
||||
await manager.open(secondary);
|
||||
host.surfaces = structuredClone(host.surfaces); host.changed();
|
||||
assert.equal(manager.entries.get(secondary).frame, frames[0]);
|
||||
host.surfaces = host.surfaces.map(s => s.id === secondary ? { ...s, url: 'other.html' } : s); host.changed();
|
||||
assert.equal(frames[0].isConnected, false);
|
||||
await manager.open(secondary);
|
||||
host.surfaces = host.surfaces.filter(s => s.id !== secondary); host.changed();
|
||||
assert.equal(frames[1].isConnected, false);
|
||||
});
|
||||
test('HTTP, frame and reload failures are isolated; external removal is detected', async t => {
|
||||
const { host, manager, frames, secondary } = setup(t);
|
||||
manager.checkResource = async () => { throw new Error('HTTP 404'); };
|
||||
await manager.open(secondary); assert.equal(manager.entries.get(secondary).state, 'error');
|
||||
assert.match(manager.entries.get(secondary).error, /404/);
|
||||
manager.checkResource = async () => {};
|
||||
await manager.reload(secondary); frames[0].dispatchEvent(new Event('error'));
|
||||
assert.equal(manager.entries.get(secondary).state, 'error');
|
||||
await manager.reload(secondary); frames[1].contentWindow.location.href = 'https://evil.example/';
|
||||
frames[1].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'error');
|
||||
await manager.reload(secondary); frames[2].remove(); manager.sweep();
|
||||
assert.equal(manager.entries.get(secondary).state, 'closed');
|
||||
assert.equal(host.status, 'connected'); assert.equal(host.sync, 'synchronized');
|
||||
});
|
||||
test('stalled surface resource fails without harming the session', async t => {
|
||||
const { host, manager, secondary } = setup(t, { loadTimeoutMs: 5, checkResource: () => new Promise(() => {}) });
|
||||
void manager.open(secondary);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.equal(manager.entries.get(secondary).state, 'error');
|
||||
assert.match(manager.entries.get(secondary).error, /timed out/);
|
||||
assert.equal(host.status, 'connected');
|
||||
});
|
||||
test('malformed surfaces and invalid primary catalogs do not change authoritative session', t => {
|
||||
const { host, manager } = setup(t);
|
||||
for (const metadata of [{ requires: 'bad' }, { requires: [{}] }, { role: {} }, { description: [] }]) {
|
||||
host.surfaces = [...galleryCatalog(), { id: 'bad.optional', label: 'Bad', kind: 'surface', primary: false, url: 'bad.html', ...metadata }]; host.changed();
|
||||
assert.equal(manager.entries.size, 3);
|
||||
}
|
||||
host.surfaces = [...host.surfaces, { id: 'bad.view', url: '//evil.example/' }]; host.changed();
|
||||
assert.equal(manager.entries.size, 3);
|
||||
host.surfaces = host.surfaces.map(s => ({ ...s, primary: false })); host.changed();
|
||||
assert.equal(manager.entries.size, 0); assert.equal(host.sessionId, 'control-session');
|
||||
});
|
||||
test('resource check refuses redirects before frame navigation', async t => {
|
||||
let foreignRequests = 0;
|
||||
const foreign = createHttpServer((_req, res) => { foreignRequests++; res.end('external'); });
|
||||
await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve));
|
||||
const server = createHttpServer((_req, res) => {
|
||||
res.writeHead(302, { Location: `http://127.0.0.1:${foreign.address().port}/` }); res.end();
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => Promise.all([server, foreign].map(s => new Promise(resolve => s.close(resolve)))));
|
||||
await assert.rejects(checkSurfaceResource(`http://127.0.0.1:${server.address().port}/`));
|
||||
assert.equal(foreignRequests, 0);
|
||||
});
|
||||
test('presentation window cannot impersonate authoritative peer over production transport', () => {
|
||||
const parent = new EventTarget(); parent.location = { href: base, origin: new URL(base).origin };
|
||||
const control = {}, surface = {}, received = [];
|
||||
const transport = postMessageTransport({ src: base, contentWindow: control }, parent);
|
||||
transport.subscribe(message => received.push(message));
|
||||
for (const source of [surface, control]) {
|
||||
const event = new Event('message'); Object.assign(event, { source, origin: parent.location.origin, data: { type: 'state.changed' } }); parent.dispatchEvent(event);
|
||||
}
|
||||
assert.equal(received.length, 1); transport.close();
|
||||
});
|
||||
test('real static server resource check accepts Museum pages and rejects missing/outside mounts', async t => {
|
||||
const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
for (const descriptor of galleryCatalog()) await checkSurfaceResource(resolveSurfaceURL(descriptor.url, origin + new URL(base).pathname));
|
||||
await assert.rejects(checkSurfaceResource(origin + '/test-fixtures/missing-surface.html'), /404/);
|
||||
await assert.rejects(checkSurfaceResource(origin + '/README.md'), /404/);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ function makeContext() {
|
||||
});
|
||||
}
|
||||
|
||||
function makeOwner() {
|
||||
function makeOwner(options) {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
for (const file of ['exhibit.js', 'contract-adapter.js', 'surface-bus.js']) {
|
||||
@@ -49,7 +49,7 @@ function makeOwner() {
|
||||
}
|
||||
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
|
||||
const core = ctx.window.MuseumGalleryContract.create(gallery);
|
||||
const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core);
|
||||
const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core, options);
|
||||
return { ctx, gallery, core, bus };
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ test('Non-surface-aware exhibits are unaffected: describe() omits `surfaces` ent
|
||||
const description = plain(core.describe());
|
||||
assert.equal('surfaces' in description, false);
|
||||
assert.equal(description.contract.major, 5);
|
||||
assert.equal(description.contract.minor, 2, 'defaults are byte-identical to pre-5.3 behavior');
|
||||
assert.equal(description.contract.minor, 3, 'shared contract-core.js now defaults every exhibit forward to Contract 5.3; surfaces stays opt-in regardless');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -362,6 +362,59 @@ test('Event sequence remains one stream regardless of which surface originated t
|
||||
}
|
||||
});
|
||||
|
||||
test('Attachment triggers connection callback without state mutation', async () => {
|
||||
const counts = [];
|
||||
let detached;
|
||||
const detachNotification = new Promise((resolve) => { detached = resolve; });
|
||||
const { core, bus } = makeOwner({
|
||||
onConnectionChange(count) {
|
||||
assert.equal(count, bus.attachedCount(), 'callback reports the current owner count');
|
||||
counts.push(count);
|
||||
if (count === 0) detached();
|
||||
}
|
||||
});
|
||||
let surface;
|
||||
try {
|
||||
const before = plain(core.stateSnapshot());
|
||||
const eventsBefore = plain(core.eventLog());
|
||||
assert.equal(bus.attachedCount(), 0);
|
||||
assert.deepEqual(counts, []);
|
||||
|
||||
surface = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 1);
|
||||
assert.deepEqual(counts, [1]);
|
||||
assert.deepEqual(plain(core.stateSnapshot()), before);
|
||||
assert.deepEqual(plain(core.eventLog()), eventsBefore);
|
||||
|
||||
surface.link.detach();
|
||||
surface = undefined;
|
||||
let timer;
|
||||
try {
|
||||
await Promise.race([
|
||||
detachNotification,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('detach callback timed out')), 500);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
assert.equal(bus.attachedCount(), 0);
|
||||
assert.deepEqual(counts, [1, 0]);
|
||||
|
||||
// Irrelevant messages and an extra detach at zero are not count changes.
|
||||
for (const data of [null, { type: 'unrelated' }, { type: 'detach' }]) {
|
||||
bus.channel.onmessage({ data });
|
||||
}
|
||||
assert.equal(bus.attachedCount(), 0);
|
||||
assert.deepEqual(counts, [1, 0]);
|
||||
assert.deepEqual(plain(core.stateSnapshot()), before);
|
||||
assert.deepEqual(plain(core.eventLog()), eventsBefore, 'no fake core events');
|
||||
} finally {
|
||||
closeAll(bus, surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('Detach does not mutate state, and a later reattach still observes it correctly', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let first, second;
|
||||
@@ -421,3 +474,191 @@ test('A non-primary surface opened without the Control Room present times out an
|
||||
assert.equal(ctx.window.MuseumGalleryContract, undefined);
|
||||
closeAll({ link });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Participant lifecycle bookkeeping (Step 6.5B live defect regression)
|
||||
*
|
||||
* These simulate, at the surface-bus level, exactly what NGN's
|
||||
* local-surfaces.js now does around a frame reload/close/reopen: it calls
|
||||
* the frame's `__xzbtSurfaceDispose` hook (== the surface's own
|
||||
* `link.detach()`) synchronously before removing the old iframe, and a
|
||||
* reload/reopen produces a brand-new attach() call with its own fresh
|
||||
* participant identity, exactly as a freshly-loaded document would.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** Simulates NGN reloading/replacing one secondary's iframe: dispose the
|
||||
* old attachment (as local-surfaces.js's release() does before removing
|
||||
* the frame), then attach a fresh one (as the reloaded document does). */
|
||||
async function simulateReload(oldSurface) {
|
||||
oldSurface.link.detach();
|
||||
return attachSurface(makeSurfaceContext());
|
||||
}
|
||||
|
||||
test('Participant lifecycle: two secondaries attach to a count of exactly 2', async () => {
|
||||
const { bus } = makeOwner();
|
||||
let a, b;
|
||||
try {
|
||||
assert.equal(bus.attachedCount(), 0);
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
} finally {
|
||||
closeAll(bus, a, b);
|
||||
}
|
||||
});
|
||||
|
||||
test('Participant lifecycle: reloading one secondary does not ratchet the count up', async () => {
|
||||
const { bus } = makeOwner();
|
||||
let a, b;
|
||||
try {
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
|
||||
a = await simulateReload(a);
|
||||
assert.equal(bus.attachedCount(), 2, 'reload retires the old participant and adopts the new one -- net zero');
|
||||
} finally {
|
||||
closeAll(bus, a, b);
|
||||
}
|
||||
});
|
||||
|
||||
test('Participant lifecycle: repeated reloads leave the count stable', async () => {
|
||||
const { bus } = makeOwner();
|
||||
let a, b;
|
||||
try {
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
a = await simulateReload(a);
|
||||
assert.equal(bus.attachedCount(), 2, `count must remain 2 after reload #${i + 1}`);
|
||||
}
|
||||
} finally {
|
||||
closeAll(bus, a, b);
|
||||
}
|
||||
});
|
||||
|
||||
test('Participant lifecycle: closing a secondary decrements deterministically without waiting for a Core mutation', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let a, b;
|
||||
try {
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
|
||||
const revisionBeforeClose = core.stateRevision;
|
||||
b.link.detach();
|
||||
b = undefined;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
assert.equal(bus.attachedCount(), 1, 'close must decrement immediately, with no Core mutation involved');
|
||||
assert.equal(core.stateRevision, revisionBeforeClose, 'closing a secondary never mutates exhibit state');
|
||||
} finally {
|
||||
closeAll(bus, a, b);
|
||||
}
|
||||
});
|
||||
|
||||
test('Participant lifecycle: reopening a closed secondary increments exactly once', async () => {
|
||||
const { bus } = makeOwner();
|
||||
let a, b;
|
||||
try {
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
|
||||
b.link.detach();
|
||||
b = undefined;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(bus.attachedCount(), 1);
|
||||
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2, 'reopen brings the count back to 2, not higher');
|
||||
} finally {
|
||||
closeAll(bus, a, b);
|
||||
}
|
||||
});
|
||||
|
||||
test('Participant lifecycle: repeated close/reopen never ratchets in either direction', async () => {
|
||||
const { bus } = makeOwner();
|
||||
let a, b;
|
||||
try {
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
b.link.detach();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(bus.attachedCount(), 1, `count must be 1 after close #${i + 1}`);
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2, `count must be 2 after reopen #${i + 1}`);
|
||||
}
|
||||
} finally {
|
||||
closeAll(bus, a, b);
|
||||
}
|
||||
});
|
||||
|
||||
test('Participant lifecycle: duplicate/late attach and detach messages cannot inflate or underflow the count', async () => {
|
||||
const { bus } = makeOwner();
|
||||
let a;
|
||||
try {
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 1);
|
||||
|
||||
// A replayed/duplicated attach for a participant id that is already
|
||||
// live (e.g. a retried message) must not inflate the count -- the
|
||||
// owner tracks identities in a Set, so re-adding a live id is a no-op.
|
||||
bus.channel.onmessage({ data: { type: 'attach', requestId: 'dup-req', participantId: 'known-participant' } });
|
||||
bus.channel.onmessage({ data: { type: 'attach', requestId: 'dup-req', participantId: 'known-participant' } });
|
||||
assert.equal(bus.attachedCount(), 2, 'two attach messages for the SAME id count as exactly one participant');
|
||||
|
||||
bus.channel.onmessage({ data: { type: 'detach', participantId: 'known-participant' } });
|
||||
assert.equal(bus.attachedCount(), 1, 'detaching that id removes exactly the one participant it represents');
|
||||
|
||||
// A late/duplicate detach for an id that is no longer (or never was)
|
||||
// present must not underflow the count.
|
||||
bus.channel.onmessage({ data: { type: 'detach', participantId: 'known-participant' } });
|
||||
bus.channel.onmessage({ data: { type: 'detach', participantId: 'not-a-real-participant' } });
|
||||
assert.equal(bus.attachedCount(), 1, 'stale/unknown detach ids must not drive the count below the real count');
|
||||
|
||||
a.link.detach();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(bus.attachedCount(), 0);
|
||||
|
||||
bus.channel.onmessage({ data: { type: 'detach', participantId: 'not-a-real-participant' } });
|
||||
assert.equal(bus.attachedCount(), 0, 'never goes negative or otherwise corrupts at zero');
|
||||
} finally {
|
||||
closeAll(bus, a);
|
||||
}
|
||||
});
|
||||
|
||||
test('Participant lifecycle: synchronization is unaffected by the lifecycle bookkeeping change', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let a, b;
|
||||
try {
|
||||
a = await attachSurface(makeSurfaceContext());
|
||||
b = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
|
||||
// Control mutation still reaches both mirrors.
|
||||
core.applyMutation('lighting.level', 0.42, 'ui');
|
||||
await waitForEvent(a.events, (e) => e.target === 'lighting.level' && e.value === 0.42, 500);
|
||||
await waitForEvent(b.events, (e) => e.target === 'lighting.level' && e.value === 0.42, 500);
|
||||
|
||||
// A secondary mutation request still reaches the primary and the other
|
||||
// mirror through the canonical mutation path.
|
||||
a.link.mutate('set', 'labels.enabled', false); // default is true, so false is an actual change
|
||||
await waitForEvent(b.events, (e) => e.target === 'labels.enabled' && e.value === false, 500);
|
||||
assert.equal(core.readValue('labels.enabled'), false);
|
||||
|
||||
// A reload (dispose + fresh attach) still gets a correct, current
|
||||
// snapshot -- late-join/attach.snapshot behavior survives the change.
|
||||
a = await simulateReload(a);
|
||||
assert.equal(a.snapshot.values['lighting.level'], 0.42);
|
||||
assert.equal(a.snapshot.values['labels.enabled'], false);
|
||||
assert.equal(bus.attachedCount(), 2);
|
||||
} finally {
|
||||
closeAll(bus, a, b);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,535 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
/*
|
||||
* Step 6.7B -- SciFi Observation Surface Integration.
|
||||
*
|
||||
* SciFi-XZBT's contract-adapter.js is self-contained (no shared
|
||||
* test-fixtures/reference-exhibits/shared/contract-core.js dependency,
|
||||
* unlike Museum Gallery) -- it is one IIFE publishing window.XZBTContractAdapter
|
||||
* directly. surface-mode.js and surface-bus.js are likewise standalone, pure
|
||||
* files with no imports, which is what makes them separately loadable and
|
||||
* testable under vm here even though js/app.js (DOM-bound, ~4900 lines) is
|
||||
* not. See docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md
|
||||
* SS14 for the full test plan this file implements a representative subset of.
|
||||
*/
|
||||
|
||||
const ROOT = new URL('../test-fixtures/reference-exhibits/scifi/', import.meta.url);
|
||||
|
||||
function source(file) {
|
||||
return readFileSync(new URL(file, ROOT), 'utf8');
|
||||
}
|
||||
|
||||
/** Strips vm-realm object identity so assert.deepEqual compares plain
|
||||
* host-realm structures (same technique as museum-gallery.test.js). */
|
||||
function plain(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function makeContext(extra = {}) {
|
||||
return vm.createContext({
|
||||
window: {},
|
||||
BroadcastChannel,
|
||||
URLSearchParams,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
Math,
|
||||
Date,
|
||||
console,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads surface-mode.js + contract-adapter.js only -- structurally
|
||||
* incapable of constructing app.js's audio/AI/visualizer subsystems,
|
||||
* exactly as makeSurfaceContext() does for Museum Gallery. */
|
||||
function makeAdapterContext() {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source('js/surface-mode.js'), ctx);
|
||||
vm.runInContext(source('js/contract-adapter.js'), ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function sciFiCatalog(instanceId = 'xi-test-instance') {
|
||||
const ctx = makeAdapterContext();
|
||||
const surfaces = ctx.window.XZBTSurfaceMode.SURFACES(instanceId);
|
||||
const adapter = new ctx.window.XZBTContractAdapter({
|
||||
product: 'SciFi-XZBT', version: '5.3.0', surfaces, instanceId, bindings: {}
|
||||
});
|
||||
return { ctx, adapter, surfaces: plain(surfaces), description: plain(adapter.describe()) };
|
||||
}
|
||||
|
||||
function makeBusContext() {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source('js/surface-bus.js'), ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function closeAll(...handles) {
|
||||
for (const h of handles) {
|
||||
try {
|
||||
if (!h) continue;
|
||||
if (typeof h.detach === 'function') h.detach();
|
||||
else if (h.owner && typeof h.owner.detach === 'function') h.owner.detach();
|
||||
else if (h.link && typeof h.link.detach === 'function') h.link.detach();
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitFor(predicate, timeoutMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
(function poll() {
|
||||
if (predicate()) return resolve();
|
||||
if (Date.now() > deadline) return reject(new Error('timed out waiting for condition'));
|
||||
setTimeout(poll, 5);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 1-7: Catalog and contract conformance
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('describe() includes a surfaces array with exactly one primary and only documented §31.2 fields', () => {
|
||||
const { description } = sciFiCatalog();
|
||||
assert.ok(Array.isArray(description.surfaces));
|
||||
assert.equal(description.surfaces.length, 2);
|
||||
|
||||
const ids = description.surfaces.map((s) => s.id).sort();
|
||||
assert.deepEqual(ids, ['surface.console', 'surface.observation']);
|
||||
|
||||
const primaries = description.surfaces.filter((s) => s.primary === true);
|
||||
assert.equal(primaries.length, 1, 'exactly one primary surface');
|
||||
assert.equal(primaries[0].id, 'surface.console');
|
||||
assert.equal(primaries[0].url, 'index.html');
|
||||
|
||||
const ALLOWED = new Set(['id', 'label', 'kind', 'primary', 'url', 'role', 'category',
|
||||
'aspectRatio', 'requires', 'description']);
|
||||
for (const s of description.surfaces) {
|
||||
assert.equal(s.kind, 'surface');
|
||||
assert.match(s.id, /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/, 'canonical dotted id grammar');
|
||||
for (const key of Object.keys(s)) {
|
||||
assert.ok(ALLOWED.has(key), `unexpected field '${key}' on ${s.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the observation surface url is a bare query string against the same document, carrying the instance id', () => {
|
||||
const { description } = sciFiCatalog('xi-abc123');
|
||||
const observation = description.surfaces.find((s) => s.id === 'surface.observation');
|
||||
assert.equal(observation.primary, false);
|
||||
assert.equal(observation.url, '?surface=observation&xi=xi-abc123');
|
||||
assert.ok(!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(observation.url), 'url must not carry a scheme');
|
||||
assert.ok(observation.url.indexOf('//') !== 0, 'url must not be protocol-relative');
|
||||
assert.deepEqual(observation.requires, ['observation']);
|
||||
});
|
||||
|
||||
test('constructing the adapter with no surfaces option omits `surfaces` entirely (§31.3 form 1 preserved)', () => {
|
||||
const ctx = makeAdapterContext();
|
||||
const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', bindings: {} });
|
||||
const description = plain(adapter.describe());
|
||||
assert.equal('surfaces' in description, false);
|
||||
assert.equal(description.contract.major, 5);
|
||||
assert.equal(description.exhibit.version, '5.3.0');
|
||||
});
|
||||
|
||||
test('view.pillars and view.warp-flight are readable/writable boolean state targets and appear in the snapshot key set', () => {
|
||||
const { ctx, adapter } = sciFiCatalog();
|
||||
const pillars = adapter.targets.get('view.pillars');
|
||||
const warp = adapter.targets.get('view.warp-flight');
|
||||
for (const target of [pillars, warp]) {
|
||||
assert.ok(target, 'target must be registered');
|
||||
assert.equal(target.kind, 'state');
|
||||
assert.equal(target.valueType, 'boolean');
|
||||
assert.equal(target.readable, true);
|
||||
assert.equal(target.writable, true);
|
||||
assert.equal(target.restorable, true);
|
||||
assert.equal(target.category, 'observation');
|
||||
assert.deepEqual(plain(target.requires), ['observation']);
|
||||
}
|
||||
const description = plain(adapter.describe());
|
||||
const ids = description.targets.map((t) => t.id);
|
||||
assert.ok(ids.includes('view.pillars'));
|
||||
assert.ok(ids.includes('view.warp-flight'));
|
||||
void ctx;
|
||||
});
|
||||
|
||||
test('registryRevision stays 1 -- the new targets are a build-time addition, not a runtime registry change', () => {
|
||||
const { adapter } = sciFiCatalog();
|
||||
assert.equal(adapter.registryRevision, 1);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 8-9: Mode resolution (pure, no DOM)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('mode resolution: no query -> console; ?surface=observation -> observation; unknown values -> console', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source('js/surface-mode.js'), ctx);
|
||||
const resolve = ctx.window.XZBTSurfaceMode.resolve;
|
||||
|
||||
assert.equal(resolve({ search: '' }).mode, 'console');
|
||||
assert.equal(resolve({ search: '?foo=bar' }).mode, 'console');
|
||||
assert.equal(resolve({ search: '?surface=bogus' }).mode, 'console');
|
||||
assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).mode, 'observation');
|
||||
assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).instanceId, 'xi-1');
|
||||
// Order and extra params are tolerated.
|
||||
assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).mode, 'observation');
|
||||
assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).instanceId, 'xi-2');
|
||||
});
|
||||
|
||||
test('channelName is instance-scoped: two instance ids yield two different channel names', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source('js/surface-mode.js'), ctx);
|
||||
const { channelName, newInstanceId } = ctx.window.XZBTSurfaceMode;
|
||||
const a = newInstanceId();
|
||||
const b = newInstanceId();
|
||||
assert.notEqual(a, b);
|
||||
assert.notEqual(channelName(a), channelName(b));
|
||||
assert.ok(channelName(a).startsWith('xzbt-scifi-surface-v1:'));
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 10-17: Bus / synchronization (surface-bus.js against a minimal stub
|
||||
* adapter -- exercises the bus mechanism itself, independent of app.js)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** A minimal stand-in for XZBTContractAdapter: just enough surface for
|
||||
* surface-bus.js's owner side (getContractState/applyMutation/invokeAction/
|
||||
* targets/stateRevision/registryRevision), with the same session-independent
|
||||
* onLocalChange/onLocalAction contract the real adapter now provides. */
|
||||
function makeStubAdapter() {
|
||||
const targets = new Map([
|
||||
['view.pillars', { id: 'view.pillars', kind: 'state', readable: true, writable: true }],
|
||||
['preset.selected', { id: 'preset.selected', kind: 'selection', readable: true, writable: true }]
|
||||
]);
|
||||
const state = { 'view.pillars': false, 'preset.selected': 'a' };
|
||||
const adapter = {
|
||||
targets,
|
||||
stateRevision: 0,
|
||||
registryRevision: 1,
|
||||
onLocalChange: null,
|
||||
onLocalAction: null,
|
||||
getContractState: () => ({ ...state }),
|
||||
applyMutation: (id, value) => {
|
||||
if (!targets.has(id)) return { ok: false };
|
||||
state[id] = value;
|
||||
adapter.stateRevision += 1;
|
||||
if (adapter.onLocalChange) adapter.onLocalChange(id, value, adapter.stateRevision);
|
||||
return { ok: true };
|
||||
},
|
||||
invokeAction: (id, args) => {
|
||||
if (adapter.onLocalAction) adapter.onLocalAction(id, args || {});
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function makeOwner(channelName) {
|
||||
const ctx = makeBusContext();
|
||||
const adapter = makeStubAdapter();
|
||||
const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName });
|
||||
adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev);
|
||||
adapter.onLocalAction = (id, args) => owner.broadcastAction(id, args);
|
||||
return { ctx, adapter, owner };
|
||||
}
|
||||
|
||||
function attachSurface(channelName, timeoutMs) {
|
||||
const ctx = makeBusContext();
|
||||
const changes = [];
|
||||
const actions = [];
|
||||
const presentations = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = ctx.window.XZBTSurfaceBus.attach({
|
||||
channelName,
|
||||
timeoutMs: timeoutMs || 500,
|
||||
onSnapshot: (values, stateRevision, registryRevision, presentation) =>
|
||||
resolve({ ctx, link, changes, actions, presentations, values: plain(values), stateRevision, registryRevision, presentation: plain(presentation) }),
|
||||
onChange: (target, value, stateRevision) => changes.push({ target, value, stateRevision }),
|
||||
onAction: (target, args) => actions.push({ target, args: plain(args) }),
|
||||
onPresentation: (kind, payload) => presentations.push({ kind, payload: plain(payload) }),
|
||||
onTimeout: () => reject(new Error('attach timed out waiting for an owner'))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('late join: attach after a mutation carries the current value and stateRevision in the snapshot', async () => {
|
||||
const channel = 'xzbt-scifi-surface-v1:test-latejoin-' + Math.random();
|
||||
const { adapter, owner } = makeOwner(channel);
|
||||
let surface;
|
||||
try {
|
||||
adapter.applyMutation('view.pillars', true);
|
||||
surface = await attachSurface(channel);
|
||||
assert.equal(surface.values['view.pillars'], true);
|
||||
assert.equal(surface.stateRevision, adapter.stateRevision);
|
||||
} finally {
|
||||
closeAll(owner, surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('propagation: an authority mutation reaches an attached surface as one state message with the post-mutation value', async () => {
|
||||
const channel = 'xzbt-scifi-surface-v1:test-propagate-' + Math.random();
|
||||
const { adapter, owner } = makeOwner(channel);
|
||||
let surface;
|
||||
try {
|
||||
surface = await attachSurface(channel);
|
||||
adapter.applyMutation('preset.selected', 'b');
|
||||
await waitFor(() => surface.changes.some((c) => c.target === 'preset.selected' && c.value === 'b'));
|
||||
const matches = surface.changes.filter((c) => c.target === 'preset.selected');
|
||||
assert.equal(matches.length, 1);
|
||||
assert.equal(matches[0].stateRevision, adapter.stateRevision);
|
||||
} finally {
|
||||
closeAll(owner, surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('mutation routing: a surface-side mutate results in exactly one applyMutation call and converges on a second attached surface', async () => {
|
||||
const channel = 'xzbt-scifi-surface-v1:test-routing-' + Math.random();
|
||||
const { adapter, owner } = makeOwner(channel);
|
||||
let applyCount = 0;
|
||||
const origApply = adapter.applyMutation;
|
||||
adapter.applyMutation = (...args) => { applyCount += 1; return origApply(...args); };
|
||||
let surfaceA, surfaceB;
|
||||
try {
|
||||
surfaceA = await attachSurface(channel);
|
||||
surfaceB = await attachSurface(channel);
|
||||
surfaceA.link.mutate('set', 'view.pillars', true);
|
||||
await waitFor(() => surfaceB.changes.some((c) => c.target === 'view.pillars' && c.value === true));
|
||||
assert.equal(applyCount, 1);
|
||||
assert.equal(adapter.getContractState()['view.pillars'], true, 'authority converged');
|
||||
assert.ok(surfaceA.changes.some((c) => c.target === 'view.pillars' && c.value === true), 'originating surface also converges (no local write)');
|
||||
} finally {
|
||||
closeAll(owner, surfaceA, surfaceB);
|
||||
}
|
||||
});
|
||||
|
||||
test('no second authority: a surface-side mutate with no owner present is a no-op and the surface stays in the waiting state', async () => {
|
||||
const channel = 'xzbt-scifi-surface-v1:test-noowner-' + Math.random();
|
||||
const ctx = makeBusContext();
|
||||
let timedOut = false;
|
||||
const link = await new Promise((resolve) => {
|
||||
const l = ctx.window.XZBTSurfaceBus.attach({
|
||||
channelName: channel,
|
||||
timeoutMs: 60,
|
||||
onSnapshot: () => resolve(l),
|
||||
onTimeout: () => { timedOut = true; resolve(l); }
|
||||
});
|
||||
});
|
||||
try {
|
||||
assert.equal(timedOut, true);
|
||||
assert.equal(link.isAttached(), false);
|
||||
assert.equal(link.mutate('set', 'view.pillars', true), false, 'mutate before/without attachment is a no-op');
|
||||
} finally {
|
||||
closeAll(link);
|
||||
}
|
||||
});
|
||||
|
||||
test('reload: re-attaching with a fresh participantId yields the current snapshot and does not ratchet the participant count', async () => {
|
||||
const channel = 'xzbt-scifi-surface-v1:test-reload-' + Math.random();
|
||||
const { adapter, owner } = makeOwner(channel);
|
||||
let first, second;
|
||||
try {
|
||||
first = await attachSurface(channel);
|
||||
assert.equal(owner.attachedCount(), 1);
|
||||
first.link.detach();
|
||||
await waitFor(() => owner.attachedCount() === 0);
|
||||
adapter.applyMutation('view.pillars', true);
|
||||
second = await attachSurface(channel);
|
||||
assert.equal(owner.attachedCount(), 1, 'reload does not ratchet the count');
|
||||
assert.equal(second.values['view.pillars'], true, 'reattach observes current state');
|
||||
} finally {
|
||||
closeAll(owner, first, second);
|
||||
}
|
||||
});
|
||||
|
||||
test('detach is idempotent, never mutates state, and cannot underflow the participant count', async () => {
|
||||
const channel = 'xzbt-scifi-surface-v1:test-detach-' + Math.random();
|
||||
const { adapter, owner } = makeOwner(channel);
|
||||
let surface;
|
||||
try {
|
||||
surface = await attachSurface(channel);
|
||||
const revBefore = adapter.stateRevision;
|
||||
surface.link.detach();
|
||||
surface.link.detach(); // duplicate detach must not underflow
|
||||
await waitFor(() => owner.attachedCount() === 0);
|
||||
assert.equal(owner.attachedCount(), 0);
|
||||
assert.equal(adapter.stateRevision, revBefore, 'detaching must not itself mutate state');
|
||||
} finally {
|
||||
closeAll(owner, surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('session independence: onLocalChange fires regardless of any session concept -- the bus has no idea NGN exists', async () => {
|
||||
// The stub adapter here has no sessionActive/eventSequence at all, which is
|
||||
// the point: onLocalChange is a plain method call, not gated on a session,
|
||||
// unlike the real adapter's _emitEvent (contract-adapter.js §5.4).
|
||||
const channel = 'xzbt-scifi-surface-v1:test-sessionindep-' + Math.random();
|
||||
const { adapter, owner } = makeOwner(channel);
|
||||
let surface;
|
||||
try {
|
||||
surface = await attachSurface(channel);
|
||||
adapter.applyMutation('view.pillars', true);
|
||||
await waitFor(() => surface.changes.length > 0);
|
||||
assert.equal(surface.changes[0].target, 'view.pillars');
|
||||
} finally {
|
||||
closeAll(owner, surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('presentation messages (ticker, obs-activity) are relayed to attached surfaces and are not contract state', async () => {
|
||||
const channel = 'xzbt-scifi-surface-v1:test-presentation-' + Math.random();
|
||||
const { owner } = makeOwner(channel);
|
||||
let surface;
|
||||
try {
|
||||
surface = await attachSurface(channel);
|
||||
owner.broadcastPresentation('ticker', { tickerText: 'HELLO WORLD' });
|
||||
owner.broadcastPresentation('obs-activity', { source: 'ambient' });
|
||||
await waitFor(() => surface.presentations.length >= 2);
|
||||
assert.deepEqual(surface.presentations[0], { kind: 'ticker', payload: { type: 'presentation', kind: 'ticker', tickerText: 'HELLO WORLD' } });
|
||||
assert.equal(surface.presentations[1].kind, 'obs-activity');
|
||||
} finally {
|
||||
closeAll(owner, surface);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 18: Duplicate-subsystem prevention -- construction spy on surface-bus.js
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('surface-bus.js never touches AudioContext, fetch or dynamic import, in either owner or surface role', async () => {
|
||||
let audioContextConstructions = 0;
|
||||
const ctx = vm.createContext({
|
||||
window: {},
|
||||
BroadcastChannel,
|
||||
setTimeout, clearTimeout, Math, Date, console,
|
||||
AudioContext: class { constructor() { audioContextConstructions += 1; } },
|
||||
fetch: () => { throw new Error('surface-bus.js must never fetch'); }
|
||||
});
|
||||
vm.runInContext(source('js/surface-bus.js'), ctx);
|
||||
const channel = 'xzbt-scifi-surface-v1:test-spy-' + Math.random();
|
||||
const adapter = makeStubAdapter();
|
||||
const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName: channel });
|
||||
adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev);
|
||||
let link;
|
||||
try {
|
||||
link = await new Promise((resolve, reject) => {
|
||||
const l = ctx.window.XZBTSurfaceBus.attach({
|
||||
channelName: channel, timeoutMs: 500,
|
||||
onSnapshot: () => resolve(l),
|
||||
onTimeout: () => reject(new Error('attach timed out'))
|
||||
});
|
||||
});
|
||||
link.mutate('set', 'view.pillars', true);
|
||||
await waitFor(() => adapter.getContractState()['view.pillars'] === true);
|
||||
assert.equal(audioContextConstructions, 0);
|
||||
} finally {
|
||||
closeAll(owner, link);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 19-21: app.js is too DOM-bound for vm (per the architecture doc's own
|
||||
* assessment) -- these are asserted as source-structure tests, the same
|
||||
* technique museum-gallery.test.js uses for "exactly one Exhibit State
|
||||
* Core exists".
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function appJsSource() {
|
||||
return source('js/app.js');
|
||||
}
|
||||
|
||||
test('source structure: enterObservation() only calls prepareExperience() inside an isConsoleMode guard', () => {
|
||||
const src = appJsSource();
|
||||
const fnStart = src.indexOf('function enterObservation()');
|
||||
assert.ok(fnStart >= 0, 'enterObservation() must exist');
|
||||
const fnEnd = src.indexOf('\n function exitObservation()', fnStart);
|
||||
assert.ok(fnEnd > fnStart);
|
||||
const body = src.slice(fnStart, fnEnd);
|
||||
// Search for the real call site, not the word appearing in a comment.
|
||||
const callIdx = body.indexOf('generativeExperience.prepareExperience()');
|
||||
assert.ok(callIdx >= 0, 'enterObservation() must still prepare the generative experience somewhere');
|
||||
const guardIdx = body.lastIndexOf('if (isConsoleMode) {', callIdx);
|
||||
assert.ok(guardIdx >= 0 && guardIdx < callIdx, 'prepareExperience() must be reached only through an isConsoleMode guard');
|
||||
// The guard's closing brace must come after the call (i.e. the call is
|
||||
// actually nested inside the guard, not merely preceded by one elsewhere).
|
||||
const closeIdx = body.indexOf('\n }', callIdx);
|
||||
assert.ok(closeIdx > callIdx, 'the isConsoleMode guard must close after the prepareExperience() call');
|
||||
});
|
||||
|
||||
test('source structure: XZBTGenerativeExperience, XZBTContractAdapter, XZBTControlBus and StarshipVisualizer construction sites are each reached only through an isConsoleMode check', () => {
|
||||
const src = appJsSource();
|
||||
for (const ctor of ['new XZBTGenerativeExperience(', 'new XZBTControlBus(', 'new XZBTContractAdapter(', 'new StarshipVisualizer(']) {
|
||||
const idx = src.indexOf(ctor);
|
||||
assert.ok(idx >= 0, `${ctor} construction site must exist`);
|
||||
// The isConsoleMode identifier (either as an if-guard or a ternary
|
||||
// condition) must appear on a line at or before the construction site,
|
||||
// within a reasonably tight window -- resilient to exact formatting,
|
||||
// unlike a brittle single-line regex.
|
||||
const window_ = src.slice(Math.max(0, idx - 400), idx);
|
||||
assert.ok(window_.includes('isConsoleMode'), `${ctor} must be structurally gated on isConsoleMode`);
|
||||
}
|
||||
});
|
||||
|
||||
test('source structure: the keydown hotkey listener is installed only inside an isConsoleMode guard', () => {
|
||||
const src = appJsSource();
|
||||
const idx = src.indexOf("window.addEventListener('keydown'");
|
||||
assert.ok(idx >= 0);
|
||||
const before = src.slice(Math.max(0, idx - 200), idx);
|
||||
assert.ok(before.includes('if (isConsoleMode)'), 'keydown listener must be console-mode only');
|
||||
});
|
||||
|
||||
test('source structure: scheduleObservationAmbientActivity() refuses to run at all outside console mode (no independent surface-side timer)', () => {
|
||||
const src = appJsSource();
|
||||
const fnStart = src.indexOf('function scheduleObservationAmbientActivity()');
|
||||
assert.ok(fnStart >= 0);
|
||||
const fnBody = src.slice(fnStart, fnStart + 600);
|
||||
assert.match(fnBody, /if\s*\(!isConsoleMode\)\s*return;/, 'the scheduler must early-return outside console mode');
|
||||
});
|
||||
|
||||
test('source structure: the observation-audience predicate considers attached surfaces, not just the local overlay flag', () => {
|
||||
const src = appJsSource();
|
||||
assert.match(src, /observationAudienceActive\s*=\s*\(\)\s*=>\s*observationActive\s*\|\|/, 'audience-active must OR in attached-surface state');
|
||||
assert.match(src, /surfaceOwner\s*&&\s*surfaceOwner\.attachedCount\(\)\s*>\s*0/, 'must consult surfaceOwner.attachedCount()');
|
||||
});
|
||||
|
||||
test('source structure: opening the surface never sets view.observation (surface lifecycle and the console overlay flag stay distinct)', () => {
|
||||
const src = appJsSource();
|
||||
// The presentation attach path's onSnapshot handler must call
|
||||
// enterObservation() (unconditional local render) and must never call
|
||||
// applyMutation('view.observation', ...) or mutate('set', 'view.observation', ...).
|
||||
const snapshotIdx = src.indexOf('onSnapshot: (values, stateRevision, registryRevision, presentation)');
|
||||
assert.ok(snapshotIdx >= 0);
|
||||
const handlerEnd = src.indexOf('onChange:', snapshotIdx);
|
||||
const handlerBody = src.slice(snapshotIdx, handlerEnd);
|
||||
assert.ok(handlerBody.includes('enterObservation()'));
|
||||
assert.ok(!handlerBody.includes("'view.observation'"), 'the snapshot handler must never touch view.observation');
|
||||
});
|
||||
|
||||
test('source structure: presentation-side dock routing excludes view.observation from the routed target set', () => {
|
||||
const src = appJsSource();
|
||||
const idx = src.indexOf('XZBT_PRESENTATION_ROUTED_TARGETS');
|
||||
assert.ok(idx >= 0);
|
||||
const block = src.slice(idx, idx + 300);
|
||||
assert.ok(!block.includes("'view.observation'"), 'view.observation must never be routed from the surface to the owner');
|
||||
for (const target of ['view.warp-flight', 'view.viewport-frame', 'view.pillars', 'alert.active', 'preset.selected']) {
|
||||
assert.ok(block.includes(`'${target}'`), `${target} must be routed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('source structure: contract-adapter.js exposes onLocalChange/onLocalAction independent of the sessionActive gate on _emitEvent', () => {
|
||||
const src = source('js/contract-adapter.js');
|
||||
assert.match(src, /if\s*\(!this\.sessionActive\)\s*return;/, '_emitEvent\'s session gate must remain');
|
||||
assert.match(src, /this\.onLocalChange\s*\(/, 'onLocalChange must be invoked');
|
||||
assert.match(src, /this\.onLocalAction\s*\(/, 'onLocalAction must be invoked');
|
||||
// The onLocalChange call site must not itself be behind a sessionActive check.
|
||||
const idx = src.indexOf('if (this.onLocalChange) {');
|
||||
assert.ok(idx >= 0);
|
||||
const nearby = src.slice(Math.max(0, idx - 150), idx);
|
||||
assert.ok(!nearby.includes('sessionActive'), 'onLocalChange must fire regardless of sessionActive');
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* 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 5.2 backward
|
||||
* compatibility. No exhibit-specific IDs appear in this file.
|
||||
* 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';
|
||||
|
||||
Reference in New Issue
Block a user