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:
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user