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.
556 lines
22 KiB
JavaScript
556 lines
22 KiB
JavaScript
/**
|
|
* Phase 6.3 — Surface catalog validation and lifecycle tests.
|
|
*
|
|
* Covers Contract 5.3 §§31.2-31.5 normative validation order, host state
|
|
* lifecycle, registry refresh behavior, and the host's protocol-level
|
|
* ability to negotiate with a genuine Contract 5.2 peer (Contract §28.2).
|
|
* That is a property of NGN's own negotiation logic against whatever a peer
|
|
* reports, using synthetic peer fixtures below -- distinct from, and not an
|
|
* argument for, keeping any of this repository's own maintained reference
|
|
* exhibits pinned to Contract 5.2. No exhibit-specific IDs appear in this
|
|
* file.
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { validateSurfaceCatalog, validateCatalog } from '../src/validation.js';
|
|
import { ExhibitHost } from '../src/host.js';
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Helpers
|
|
* ------------------------------------------------------------------ */
|
|
|
|
/** Minimal valid surface descriptor; caller may override fields. */
|
|
function validSurface(overrides = {}) {
|
|
return {
|
|
id: 'surface.primary-view',
|
|
label: 'Primary View',
|
|
kind: 'surface',
|
|
primary: true,
|
|
url: 'primary.html',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Run validateSurfaceCatalog, capturing diagnostics. */
|
|
function validate(surfaces) {
|
|
const diags = [];
|
|
const result = validateSurfaceCatalog({ surfaces }, msg => diags.push(msg));
|
|
return { result, diags };
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Absent / empty array forms (Contract 5.3 §31.3 forms 1 & 2)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('absent surfaces field is accepted and treated as empty', () => {
|
|
const { result, diags } = validate(undefined);
|
|
assert.deepEqual(result, []);
|
|
assert.equal(diags.length, 0);
|
|
});
|
|
|
|
test('surfaces: [] is accepted and treated identically to absent', () => {
|
|
const { result, diags } = validate([]);
|
|
assert.deepEqual(result, []);
|
|
assert.equal(diags.length, 0);
|
|
});
|
|
|
|
test('non-array surfaces field treated as absent', () => {
|
|
const { result } = validate('not-an-array');
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Individual entry validation — required fields (§31.2)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('valid single-entry catalog (one primary) is accepted', () => {
|
|
const { result } = validate([validSurface()]);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].id, 'surface.primary-view');
|
|
});
|
|
|
|
test('entry missing id is discarded', () => {
|
|
const s = validSurface(); delete s.id;
|
|
const { result, diags } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /Discarded/.test(d)));
|
|
});
|
|
|
|
test('entry with non-string id is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 42 })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('bare single-segment id (no dot) is discarded', () => {
|
|
const { result, diags } = validate([validSurface({ id: 'nodot' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /Discarded/.test(d)));
|
|
});
|
|
|
|
test('id with uppercase is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 'Surface.control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('id with leading digit is discarded', () => {
|
|
const { result } = validate([validSurface({ id: '1surface.control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('id with empty segment is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 'surface..control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('id with underscore is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 'surface_x.control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry missing label is discarded', () => {
|
|
const s = validSurface(); delete s.label;
|
|
const { result, diags } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /label/.test(d)));
|
|
});
|
|
|
|
test('entry with empty string label is discarded', () => {
|
|
const { result } = validate([validSurface({ label: '' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with non-string label is discarded', () => {
|
|
const { result } = validate([validSurface({ label: 123 })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with wrong kind is discarded', () => {
|
|
const { result, diags } = validate([validSurface({ kind: 'state' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /kind/.test(d)));
|
|
});
|
|
|
|
test('entry with kind impulse is discarded', () => {
|
|
const { result } = validate([validSurface({ kind: 'impulse' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry missing kind is discarded', () => {
|
|
const s = validSurface(); delete s.kind;
|
|
const { result } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with non-boolean primary (string) is discarded', () => {
|
|
const { result, diags } = validate([validSurface({ primary: 'true' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /primary/.test(d)));
|
|
});
|
|
|
|
test('entry with primary: 1 (number) is discarded', () => {
|
|
const { result } = validate([validSurface({ primary: 1 })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with primary: null is discarded', () => {
|
|
const { result } = validate([validSurface({ primary: null })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry missing url is discarded', () => {
|
|
const s = validSurface(); delete s.url;
|
|
const { result, diags } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /url/.test(d)));
|
|
});
|
|
|
|
test('absolute url is rejected as individually-invalid', () => {
|
|
const { result, diags } = validate([validSurface({ url: 'https://evil.example/page.html' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /url/.test(d)));
|
|
});
|
|
|
|
test('protocol-relative url is rejected as individually-invalid', () => {
|
|
const { result, diags } = validate([validSurface({ url: '//evil.example/page.html' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /url/.test(d)));
|
|
});
|
|
|
|
test('url with scheme (http:) is rejected', () => {
|
|
const { result } = validate([validSurface({ url: 'http:page.html' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('relative path url is accepted', () => {
|
|
const { result } = validate([validSurface({ url: 'views/primary.html' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('relative path with query string is accepted', () => {
|
|
const { result } = validate([validSurface({ url: 'primary.html?surface=main' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('bare query string is accepted (single-page exhibit)', () => {
|
|
const { result } = validate([validSurface({ url: '?surface=main' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('fragment-only url is accepted', () => {
|
|
const { result } = validate([validSurface({ url: '#surface-main' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* §31.4 same-origin resolution with exhibitBaseUrl
|
|
* ------------------------------------------------------------------ */
|
|
|
|
/** validateSurfaceCatalog with base URL supplied. */
|
|
function validateWithBase(surfaces, exhibitBaseUrl) {
|
|
const diags = [];
|
|
const result = validateSurfaceCatalog({ surfaces }, exhibitBaseUrl, msg => diags.push(msg));
|
|
return { result, diags };
|
|
}
|
|
|
|
test('§31.4: relative url resolves same-origin — accepted', () => {
|
|
const { result } = validateWithBase(
|
|
[validSurface({ url: 'primary.html' })],
|
|
'http://127.0.0.1:4173/test-fixtures/reference-exhibits/gallery/control.html'
|
|
);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('§31.4: relative url that resolves to same origin with subdirectory — accepted', () => {
|
|
const { result } = validateWithBase(
|
|
[validSurface({ url: '../other/view.html' })],
|
|
'http://127.0.0.1:4173/test-fixtures/exhibit/index.html'
|
|
);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('§31.4: bare query string resolves same-origin — accepted', () => {
|
|
const { result } = validateWithBase(
|
|
[validSurface({ url: '?view=artifact' })],
|
|
'http://127.0.0.1:4173/test-fixtures/exhibit/index.html'
|
|
);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('§31.4: relative url that resolves cross-origin is rejected as individually-invalid', () => {
|
|
// This cannot happen with a purely relative URL in practice, but a URL like
|
|
// '//evil.example/x' would already be caught by structural form check.
|
|
// To test cross-origin resolution we need a contrived exhibitBaseUrl on a different port.
|
|
// An absolute URL with a scheme is already blocked by isRelativeSurfaceUrl; here we
|
|
// verify that a structural-form-valid relative path resolving cross-origin is caught.
|
|
// We pass a data: URL as base, which will fail to resolve and produce a diagnostic.
|
|
const { result, diags } = validateWithBase(
|
|
[validSurface({ url: 'primary.html', primary: true })],
|
|
'data:text/html,<p>not-a-real-origin</p>'
|
|
);
|
|
// data: scheme — resolved URL will not be same-origin with data: base
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /Discarded/.test(d)));
|
|
});
|
|
|
|
test('§31.4: no exhibitBaseUrl — structural form only, relative url accepted without resolution', () => {
|
|
// When no base URL is supplied (e.g. test-only contexts), structural check only.
|
|
const { result } = validate([validSurface({ url: 'primary.html' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
|
|
test('individually-invalid primary discarded; remaining valid primary => catalog valid', () => {
|
|
const entries = [
|
|
validSurface({ id: 'INVALID_ID', primary: true }),
|
|
validSurface({ id: 'surface.secondary', label: 'Secondary', primary: true, url: 'secondary.html' }),
|
|
];
|
|
const { result } = validate(entries);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].id, 'surface.secondary');
|
|
});
|
|
|
|
test('individually-invalid primary discarded; zero valid primaries => whole catalog fallback', () => {
|
|
const entries = [
|
|
validSurface({ id: 'BAD', primary: true }),
|
|
validSurface({ id: 'surface.secondary', label: 'Secondary', primary: false, url: 'secondary.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
|
|
});
|
|
|
|
test('zero primaries among valid entries => whole catalog rejected', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.a', label: 'A', primary: false, url: 'a.html' }),
|
|
validSurface({ id: 'surface.b', label: 'B', primary: false, url: 'b.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
|
|
});
|
|
|
|
test('multiple valid primaries => whole catalog rejected', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.a', label: 'A', primary: true, url: 'a.html' }),
|
|
validSurface({ id: 'surface.b', label: 'B', primary: true, url: 'b.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
|
|
});
|
|
|
|
test('all entries individually invalid => absent-equivalent, not malformed-primary', () => {
|
|
const entries = [
|
|
{ id: 'BAD ID', label: 'x', kind: 'surface', primary: true, url: 'x.html' },
|
|
{ id: 'surface.y', label: '', kind: 'surface', primary: true, url: 'y.html' },
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(!diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d)), 'empty valid set is absent-equivalent, not a primary violation');
|
|
});
|
|
|
|
test('non-empty valid catalog with one primary is fully returned', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.ctrl', label: 'Control', primary: true, url: 'ctrl.html' }),
|
|
validSurface({ id: 'surface.display', label: 'Display', primary: false, url: 'display.html' }),
|
|
validSurface({ id: 'surface.info', label: 'Info', primary: false, url: 'info.html' }),
|
|
];
|
|
const { result } = validate(entries);
|
|
assert.equal(result.length, 3);
|
|
assert.equal(result.filter(s => s.primary).length, 1);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Optional fields preserved generically (§31.2)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('optional fields are preserved in validated descriptors', () => {
|
|
const s = validSurface({
|
|
description: 'The operator control surface.',
|
|
role: 'control',
|
|
aspectRatio: '16:9',
|
|
category: 'operator',
|
|
requires: ['render'],
|
|
});
|
|
const { result } = validate([s]);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].description, 'The operator control surface.');
|
|
assert.equal(result[0].role, 'control');
|
|
assert.equal(result[0].aspectRatio, '16:9');
|
|
assert.equal(result[0].category, 'operator');
|
|
assert.deepEqual(result[0].requires, ['render']);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Duplicate id handling
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('duplicate id: second entry with same id is discarded', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.ctrl', label: 'First', primary: true, url: 'ctrl.html' }),
|
|
validSurface({ id: 'surface.ctrl', label: 'Duplicate', primary: false, url: 'ctrl2.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].label, 'First');
|
|
assert.ok(diags.some(d => /duplicate/.test(d)));
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Malformed surfaces do NOT invalidate targets/capabilities
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('malformed surfaces field does not throw and does not affect validateCatalog', () => {
|
|
const message = {
|
|
exhibit: {}, contract: { major: 5 },
|
|
registryRevision: 0, stateRevision: 0,
|
|
capabilities: [],
|
|
targets: [{ id: 'sample.level', kind: 'state', valueType: 'boolean', readable: true, writable: true, requires: [] }],
|
|
surfaces: [{ id: 'BAD', label: '', kind: 'not-surface', primary: 'yes', url: 'https://evil.example' }],
|
|
};
|
|
assert.doesNotThrow(() => validateCatalog(message));
|
|
const { result } = validate(message.surfaces);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* ExhibitHost lifecycle — surfaces cleared on disconnect, refreshed on
|
|
* registry.changed (Parts 3 & 4)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
function peer53() {
|
|
let receive, session = 0, sequence = 0;
|
|
let currentSurfaces = [
|
|
{ id: 'surface.primary', label: 'Primary', kind: 'surface', primary: true, url: 'primary.html' },
|
|
{ id: 'surface.secondary', label: 'Secondary', kind: 'surface', primary: false, url: 'secondary.html' },
|
|
];
|
|
const fixture = {
|
|
transport: {
|
|
subscribe(fn) { receive = fn; return () => {}; },
|
|
close() {},
|
|
send(m) {
|
|
const out = { xzbt: '5.3', requestId: m.requestId, sessionId: `s${session}` };
|
|
if (m.type === 'hello') {
|
|
session++; sequence = 0;
|
|
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 3 }, exhibit: { product: 'Synthetic 5.3 peer' } });
|
|
} else if (m.type === 'describe') {
|
|
receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.3 peer' },
|
|
contract: { major: 5, minor: 3 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean',
|
|
readable: true, writable: true, requires: [] }], capabilities: [],
|
|
registryRevision: 1, stateRevision: 0, surfaces: currentSurfaces });
|
|
} else if (m.type === 'state.get') {
|
|
receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } });
|
|
}
|
|
}
|
|
},
|
|
event(type, payload = {}) {
|
|
receive({ xzbt: '5.3', type, sessionId: `s${session}`, sequence: ++sequence, timestamp: Date.now(), ...payload });
|
|
},
|
|
setSurfaces(list) { currentSurfaces = list; }
|
|
};
|
|
return fixture;
|
|
}
|
|
|
|
function peer52() {
|
|
let receive, session = 0;
|
|
const fixture = {
|
|
transport: {
|
|
subscribe(fn) { receive = fn; return () => {}; },
|
|
close() {},
|
|
send(m) {
|
|
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
|
|
if (m.type === 'hello') {
|
|
session++;
|
|
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Synthetic 5.2 peer' } });
|
|
} else if (m.type === 'describe') {
|
|
receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.2 peer' },
|
|
contract: { major: 5, minor: 2 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean',
|
|
readable: true, writable: true, requires: [] }], capabilities: [],
|
|
registryRevision: 1, stateRevision: 0 });
|
|
} else if (m.type === 'state.get') {
|
|
receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
return fixture;
|
|
}
|
|
|
|
const settle = () => new Promise(resolve => setTimeout(resolve, 25));
|
|
|
|
test('Contract 5.3 exhibit: surfaces discovered after connect', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.contractMinor, 3);
|
|
assert.equal(host.surfaces.length, 2);
|
|
assert.equal(host.surfaces.filter(s => s.primary).length, 1);
|
|
assert.equal(host.surfaces[0].id, 'surface.primary');
|
|
});
|
|
|
|
test('disconnect clears surface catalog', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
host.disconnect();
|
|
assert.equal(host.surfaces.length, 0, 'surfaces must be empty after disconnect');
|
|
});
|
|
|
|
test('reconnect after disconnect rediscovers surface catalog', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
host.disconnect();
|
|
assert.equal(host.surfaces.length, 0);
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2, 'surfaces restored after reconnect');
|
|
});
|
|
|
|
test('registry.changed refreshes surface catalog when it changes', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
|
|
fixture.setSurfaces([
|
|
{ id: 'surface.primary', label: 'Primary Updated', kind: 'surface', primary: true, url: 'primary.html' }
|
|
]);
|
|
fixture.event('registry.changed');
|
|
await settle();
|
|
|
|
assert.equal(host.surfaces.length, 1);
|
|
assert.equal(host.surfaces[0].label, 'Primary Updated');
|
|
});
|
|
|
|
test('registry.changed can clear surfaces entirely', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
fixture.setSurfaces([]);
|
|
fixture.event('registry.changed');
|
|
await settle();
|
|
assert.equal(host.surfaces.length, 0);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Contract 5.2 backward compatibility
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('Contract 5.2 exhibit: surfaces stays empty, no error', async t => {
|
|
const fixture = peer52();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.contractMinor, 2);
|
|
assert.equal(host.surfaces.length, 0);
|
|
assert.ok(!host.logs.some(l => l.code === 'SURFACE_CATALOG'));
|
|
});
|
|
|
|
test('Contract 5.2 exhibit: controls, state and events still work', async t => {
|
|
const fixture = peer52();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.status, 'connected');
|
|
assert.equal(host.sync, 'synchronized');
|
|
assert.equal(host.catalog.length, 1);
|
|
assert.equal(host.surfaces.length, 0);
|
|
});
|
|
|
|
test('NGN emits xzbt 5.3 advisory on hello; 5.2 exhibit negotiates normally', async t => {
|
|
const sentMessages = [];
|
|
let receive, session = 0;
|
|
const transport = {
|
|
subscribe(fn) { receive = fn; return () => {}; },
|
|
close() {},
|
|
send(m) {
|
|
sentMessages.push(m);
|
|
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
|
|
if (m.type === 'hello') {
|
|
session++;
|
|
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Test 5.2' } });
|
|
} else if (m.type === 'describe') {
|
|
receive({ ...out, type: 'describe.result', exhibit: { product: 'Test 5.2' }, contract: { major: 5, minor: 2 },
|
|
targets: [], capabilities: [], registryRevision: 0, stateRevision: 0 });
|
|
} else if (m.type === 'state.get') {
|
|
receive({ ...out, type: 'state.result', stateRevision: 0, values: {} });
|
|
}
|
|
}
|
|
};
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(transport);
|
|
const helloMsg = sentMessages.find(m => m.type === 'hello');
|
|
assert.equal(helloMsg.xzbt, '5.3', 'NGN emits xzbt 5.3 advisory');
|
|
assert.equal(host.contractMinor, 2, 'negotiated minor reflects what 5.2 exhibit reported');
|
|
assert.equal(host.status, 'connected');
|
|
});
|