Files
XZBT-NGN/tests/museum-gallery.test.js
T
Labyricorn 745912e451 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.
2026-09-14 19:45:27 -07:00

665 lines
27 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const ROOT = new URL('../test-fixtures/reference-exhibits/museum-gallery/', import.meta.url);
const SHARED = new URL('../test-fixtures/reference-exhibits/shared/', import.meta.url);
function source(base, file) {
return readFileSync(new URL(file, base), 'utf8');
}
/** Strips vm-realm object/array identity so assert.deepEqual compares plain
* host-realm structures instead of failing on cross-realm prototypes. */
function plain(value) {
return JSON.parse(JSON.stringify(value));
}
/**
* A fresh vm context standing in for one browser document. `BroadcastChannel`
* is Node's real global implementation (same spec surface as the DOM one),
* shared process-wide by channel name -- so two contexts with the same
* channel name genuinely talk to each other, the same cross-document shape
* Contract 5.3 Section 31.7 and the Step 6.1 architecture document describe,
* minus only the literal separate-window/separate-origin browser plumbing.
*
* Every test that opens channels is responsible for closing them (see
* closeAll below) -- Node's BroadcastChannel is a process-wide bus by name,
* so a leftover open channel from one test would otherwise go on answering
* a later test's attach requests.
*/
function makeContext() {
return vm.createContext({
window: {},
BroadcastChannel,
setTimeout,
clearTimeout,
Math,
Date,
console
});
}
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']) {
vm.runInContext(source(ROOT, file), ctx);
}
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
const core = ctx.window.MuseumGalleryContract.create(gallery);
const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core, options);
return { ctx, gallery, core, bus };
}
/** A non-primary-surface context. Deliberately loads ONLY surface-bus.js --
* not contract-core.js, not exhibit.js, not contract-adapter.js -- so it is
* structurally incapable of constructing a second Core. */
function makeSurfaceContext() {
const ctx = makeContext();
vm.runInContext(source(ROOT, 'surface-bus.js'), ctx);
return ctx;
}
function attachSurface(ctx, timeoutMs) {
const events = [];
return new Promise((resolve, reject) => {
const link = ctx.window.MuseumGallerySurfaceBus.attach({
timeoutMs: timeoutMs || 500,
onSnapshot: (snapshot, registryRevision) => resolve({ link, events, snapshot: plain(snapshot), registryRevision }),
onEvent: (event) => events.push(plain(event)),
onTimeout: () => reject(new Error('attach timed out waiting for an owner'))
});
});
}
function expectTimeout(ctx, timeoutMs) {
return new Promise((resolve, reject) => {
const link = ctx.window.MuseumGallerySurfaceBus.attach({
timeoutMs: timeoutMs || 150,
onSnapshot: () => reject(new Error('unexpectedly attached')),
onTimeout: () => resolve(link)
});
});
}
function waitForEvent(events, predicate, timeoutMs) {
const deadline = Date.now() + (timeoutMs || 500);
return new Promise((resolve, reject) => {
(function poll() {
const found = events.find(predicate);
if (found) return resolve(found);
if (Date.now() > deadline) return reject(new Error('timed out waiting for event'));
setTimeout(poll, 5);
})();
});
}
/** Closes every channel/link handed to it, tolerating already-closed ones. */
function closeAll(...handles) {
for (const h of handles) {
try {
if (!h) continue;
if (typeof h.close === 'function') h.close();
else if (h.link && typeof h.link.detach === 'function') h.link.detach();
else if (h.bus && typeof h.bus.close === 'function') h.bus.close();
} catch {
/* best-effort cleanup */
}
}
}
/* ------------------------------------------------------------------ *
* 1-4: Contract 5.3 describe / surface descriptor conformance
* ------------------------------------------------------------------ */
test('Museum Gallery describe reports Contract 5.3 with a valid, single-primary surfaces array', () => {
const { core, bus } = makeOwner();
try {
const description = plain(core.describe());
assert.equal(description.contract.major, 5);
assert.equal(description.contract.minor, 3);
assert.ok(Array.isArray(description.surfaces), 'surfaces must be an array');
assert.equal(description.surfaces.length, 3);
const ids = description.surfaces.map((s) => s.id).sort();
assert.deepEqual(ids, ['surface.artifact', 'surface.control', 'surface.info-wall']);
const primaries = description.surfaces.filter((s) => s.primary === true);
assert.equal(primaries.length, 1, 'exactly one primary surface');
assert.equal(primaries[0].id, 'surface.control');
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 (Contract 8.1)');
assert.equal(typeof s.label, 'string');
assert.ok(s.label.length > 0);
assert.equal(typeof s.url, 'string');
assert.ok(!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s.url), 'url must not carry a scheme');
assert.ok(s.url.indexOf('//') !== 0, 'url must not be protocol-relative');
}
} finally {
closeAll(bus);
}
});
test('Non-surface-aware exhibits are unaffected: describe() omits `surfaces` entirely with no options.surfaces', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const Core = ctx.window.XZBTContractCore;
const catalog = new Core.Catalog([
{ id: 'mix.master', kind: 'range', min: 0, max: 1, step: 0.01, readable: true, writable: true, restorable: true, category: 'mix', requires: [] }
]);
const caps = new Core.CapabilityRegistry();
const core = new Core.ContractCore({
identity: { product: 'plain', version: '1', build: 'x' },
catalog, capabilities: caps,
setters: { 'mix.master': () => ({ changed: false }) },
readers: { 'mix.master': () => 0.5 }
});
const description = plain(core.describe());
assert.equal('surfaces' in description, false);
assert.equal(description.contract.major, 5);
assert.equal(description.contract.minor, 3, 'shared contract-core.js now defaults every exhibit forward to Contract 5.3; surfaces stays opt-in regardless');
});
/* ------------------------------------------------------------------ *
* SurfaceCatalog validation order (Contract 5.3 Section 31.3, Part A2)
* ------------------------------------------------------------------ */
test('SurfaceCatalog: an individually-invalid entry is discarded before the primary invariant is evaluated', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'not_dotted', label: 'Bad', kind: 'surface', primary: true, url: 'bad.html' },
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'control.html' },
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: 'artifact.html' }
]);
assert.equal(catalog.isEmpty(), false);
assert.equal(catalog.wasRejectedAsMalformed(), false);
assert.equal(catalog.descriptors().length, 2);
assert.equal(catalog.primaryId(), 'surface.control');
});
test('SurfaceCatalog: zero primary among valid entries rejects the whole catalog and falls back to absent', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: 'artifact.html' },
{ id: 'surface.info-wall', label: 'Info', kind: 'surface', primary: false, url: 'info-wall.html' }
]);
assert.equal(catalog.isEmpty(), true);
assert.equal(catalog.wasRejectedAsMalformed(), true);
assert.equal(catalog.descriptors().length, 0);
assert.ok(catalog.diagnostics().some((d) => /exactly one/.test(d)));
});
test('SurfaceCatalog: multiple primary among valid entries rejects the whole catalog', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'control.html' },
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: true, url: 'artifact.html' }
]);
assert.equal(catalog.isEmpty(), true);
assert.equal(catalog.wasRejectedAsMalformed(), true);
});
test('SurfaceCatalog: discarding every entry (all individually invalid) behaves as absent, not malformed', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'bad id', label: 'x', kind: 'surface', primary: true, url: 'x.html' },
{ id: 'surface.y', label: '', kind: 'surface', primary: true, url: 'y.html' }
]);
assert.equal(catalog.isEmpty(), true);
assert.equal(catalog.wasRejectedAsMalformed(), false, 'empty valid set is absent-equivalent, not a primary violation');
});
test('SurfaceCatalog: an absolute or scheme-carrying url is rejected as an individually invalid entry', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'https://evil.example/control.html' },
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: '//evil.example/artifact.html' },
{ id: 'surface.info-wall', label: 'Info', kind: 'surface', primary: false, url: 'info-wall.html' }
]);
// Only the third entry is individually valid; the valid set then has zero
// primary entries and is rejected as a whole, falling back to absent.
assert.equal(catalog.isEmpty(), true);
});
/* ------------------------------------------------------------------ *
* 5, 12-13: one authoritative Core; primary works fully standalone
* ------------------------------------------------------------------ */
test('The primary surface is fully usable standalone: the Core alone supports full read/set/invoke', () => {
const { core, bus } = makeOwner();
try {
assert.equal(core.stateSnapshot().values['artifact.selected'], 'the-orrery');
const setResult = core.applyMutation('lighting.level', 0.2, 'ui');
assert.equal(setResult.ok, true);
assert.equal(setResult.revisionChanged, true);
assert.equal(core.stateSnapshot().values['lighting.level'], 0.2);
const invokeResult = core.invokeAction('action.spotlight-flash', {}, 'ui');
assert.equal(invokeResult.ok, true);
assert.ok(core.eventLog().some((e) => e.type === 'action.executed' && e.target === 'action.spotlight-flash'));
// None of this touched any surface or bus -- this is a plain Core call,
// exactly what a person opening control.html directly, with no NGN and
// no other surface open, gets.
} finally {
closeAll(bus);
}
});
test('Exactly one Exhibit State Core exists: non-primary surface contexts cannot construct one', () => {
const ctx = makeSurfaceContext();
assert.equal(ctx.window.MuseumGalleryContract, undefined, 'contract-adapter.js was never loaded here');
assert.equal(ctx.window.XZBTContractCore, undefined, 'contract-core.js was never loaded here');
assert.equal(typeof ctx.window.MuseumGallerySurfaceBus.attach, 'function');
});
/* ------------------------------------------------------------------ *
* 6, 7, 8, 9, 11: cross-document synchronization via the real attachment bus
* ------------------------------------------------------------------ */
test('Attach sequence: a surface opened after state already changed receives the CURRENT snapshot', async () => {
const { core, bus } = makeOwner();
let surface;
try {
core.applyMutation('lighting.level', 0.9, 'ui');
assert.equal(core.stateRevision, 1);
const artifactCtx = makeSurfaceContext();
surface = await attachSurface(artifactCtx);
assert.equal(surface.snapshot.stateRevision, 1);
assert.equal(surface.snapshot.values['lighting.level'], 0.9, 'reopen/reattach gets current state, not stale or default state');
assert.equal(surface.registryRevision, core.registryRevision);
} finally {
closeAll(bus, surface);
}
});
test('State changes from the primary propagate to open non-primary surfaces', async () => {
const { core, bus } = makeOwner();
let artifact, info;
try {
artifact = await attachSurface(makeSurfaceContext());
info = await attachSurface(makeSurfaceContext());
core.applyMutation('rotation.speed', 1.2, 'ui'); // simulates the primary UI's own slider
const artifactEvent = await waitForEvent(artifact.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
const infoEvent = await waitForEvent(info.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
assert.equal(artifactEvent.value, 1.2);
assert.equal(infoEvent.value, 1.2);
assert.equal(artifactEvent.stateRevision, core.stateRevision);
assert.equal(infoEvent.stateRevision, core.stateRevision);
} finally {
closeAll(bus, artifact, info);
}
});
test('A native interaction on a non-primary surface updates the primary AND the other non-primary surface, through the canonical mutation path', async () => {
const { core, bus } = makeOwner();
let artifact, info;
try {
artifact = await attachSurface(makeSurfaceContext());
info = await attachSurface(makeSurfaceContext());
const before = core.stateRevision;
const ok = artifact.link.mutate('set', 'artifact.selected', 'star-map');
assert.equal(ok, true);
const infoEvent = await waitForEvent(info.events, (e) => e.type === 'selection.changed' && e.target === 'artifact.selected');
assert.equal(infoEvent.value, 'star-map');
assert.equal(infoEvent.source, 'ui', 'surface interactions use the same source vocabulary as primary UI (Contract 5.3 15)');
// "The primary" here IS the Core itself (control.html owns it) -- its
// authoritative state reflects the change made from the artifact surface.
assert.equal(core.readValue('artifact.selected'), 'star-map');
assert.equal(core.stateRevision, before + 1, 'stateRevision increments exactly once for the one mutation transaction');
} finally {
closeAll(bus, artifact, info);
}
});
test('Event sequence remains one stream regardless of which surface originated the interaction', async () => {
const { core, bus } = makeOwner();
let artifact;
try {
artifact = await attachSurface(makeSurfaceContext());
core.applyMutation('lighting.level', 0.3, 'ui'); // "primary UI" origin
artifact.link.mutate('set', 'rotation.speed', 0.8); // non-primary surface origin
await waitForEvent(artifact.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
const sequences = core.eventLog().map((e) => e.sequence);
const sorted = sequences.slice().sort((a, b) => a - b);
assert.deepEqual(sequences, sorted, 'sequence is monotonic across both origins');
assert.equal(new Set(sequences).size, sequences.length, 'no duplicate sequence numbers -- one stream, not one per surface');
} finally {
closeAll(bus, artifact);
}
});
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;
try {
first = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 1);
const revisionBeforeDetach = core.stateRevision;
const eventCountBeforeDetach = core.eventLog().length;
first.link.detach();
await new Promise((r) => setTimeout(r, 20));
assert.equal(core.stateRevision, revisionBeforeDetach, 'closing a surface must not change stateRevision');
assert.equal(core.eventLog().length, eventCountBeforeDetach, 'closing a surface must not itself emit a contract event');
core.applyMutation('labels.enabled', false, 'ui');
second = await attachSurface(makeSurfaceContext());
assert.equal(second.snapshot.values['labels.enabled'], false, 'reopened surface reflects current state automatically');
} finally {
closeAll(bus, first, second);
}
});
test('No independent per-surface state exists: two surfaces attached at once never disagree', async () => {
const { core, bus } = makeOwner();
let surfaceA, surfaceB;
try {
surfaceA = await attachSurface(makeSurfaceContext());
surfaceB = await attachSurface(makeSurfaceContext());
for (const value of [0.1, 0.5, 0.95]) {
core.applyMutation('lighting.level', value, 'ui');
}
await waitForEvent(surfaceB.events, (e) => e.target === 'lighting.level' && e.value === 0.95, 500);
await new Promise((r) => setTimeout(r, 20));
const aValues = surfaceA.events.filter((e) => e.target === 'lighting.level').map((e) => e.value);
const bValues = surfaceB.events.filter((e) => e.target === 'lighting.level').map((e) => e.value);
assert.deepEqual(aValues, [0.1, 0.5, 0.95]);
assert.deepEqual(bValues, [0.1, 0.5, 0.95]);
assert.equal(core.readValue('lighting.level'), 0.95, 'the Core remains the single source of truth both surfaces converged on');
} finally {
closeAll(bus, surfaceA, surfaceB);
}
});
/* ------------------------------------------------------------------ *
* Standalone degrade behavior: a non-primary surface opened with no owner
* ------------------------------------------------------------------ */
test('A non-primary surface opened without the Control Room present times out and does not invent a second Core', async () => {
// No makeOwner() call in this test at all -- nothing is listening on the
// channel, matching "artifact.html opened with no control.html open".
const ctx = makeSurfaceContext();
const link = await expectTimeout(ctx, 150);
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);
}
});