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