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.
665 lines
27 KiB
JavaScript
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);
|
|
}
|
|
});
|