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.
297 lines
17 KiB
JavaScript
297 lines
17 KiB
JavaScript
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/);
|
|
});
|