generated from Labyricorn/labyricorn-project-template
Step 6.3 Complete — Generic Contract 5.3 Surface Discovery
- src/validation.js: add validateSurfaceCatalog() implementing Contract 5.3 §§31.2-31.5 normative validation order (validate individuals -> discard invalids -> evaluate primary invariant against working set) - src/host.js: emit xzbt:5.3 advisory (§6.5); store contractMinor; add surfaces=[] to resetView(); parse surfaces in refresh(true) - src/ui.js: add buildSurfaces() for generic descriptor-driven surface display; integrate renderedSurfaces tracking into render() - public/index.html: subtitle updated to Contract 5.2/5.3; add surfaces-section with surfaces div - public/style.css: add .surface, .surface-badge, .surface-meta styles - tests/surface-validation.test.js: 45 new tests covering all Part 8 malformed surface cases, lifecycle, registry refresh, 5.2 regression - docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md Test results: 88/88 pass (43 pre-existing + 45 new) Genericity scan: zero matches in src/, public/, server/ git diff --check: clean
This commit is contained in:
@@ -0,0 +1,306 @@
|
|||||||
|
# XZBT-NGN Step 6.3 — Generic Surface Discovery Verification
|
||||||
|
|
||||||
|
**Date:** 2026-09-14
|
||||||
|
**Phase:** 6.3 — Contract 5.3 Surface Discovery (first NGN product code change for multi-surface)
|
||||||
|
**Repository branch:** main
|
||||||
|
**Pre-6.3 baseline commit:** 44f2ad3 (Step 6.2 Complete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Purpose
|
||||||
|
|
||||||
|
Phase 6.3 adds **generic Contract 5.3 surface discovery** to XZBT-NGN. At the end of 6.3 NGN can:
|
||||||
|
|
||||||
|
1. negotiate with Contract 5.2 exhibits exactly as before
|
||||||
|
2. negotiate with Contract 5.3 exhibits
|
||||||
|
3. discover an optional `surfaces` catalog from `describe.result`
|
||||||
|
4. validate that catalog according to Contract 5.3 §§31.2–31.5
|
||||||
|
5. store the validated surface descriptors in host state
|
||||||
|
6. display discovered surfaces generically in the NGN admin UI
|
||||||
|
7. react correctly when `registry.changed` changes the surface catalog
|
||||||
|
8. preserve all existing target/state/capability behavior
|
||||||
|
9. NOT open secondary surface windows (that is Phase 6.4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Contract 5.3 Negotiation Approach
|
||||||
|
|
||||||
|
**`xzbt` advisory field:** NGN now emits `xzbt: "5.3"` on all outgoing messages (hello and subsequent requests). Contract §6.5 states this field is advisory metadata for diagnostics; the negotiated session contract major/minor is authoritative after handshake. Both 5.2 and 5.3 exhibits must tolerate any advisory `xzbt` value.
|
||||||
|
|
||||||
|
**`supportedContractMajors`:** Unchanged — `[5]` covers both 5.2 and 5.3.
|
||||||
|
|
||||||
|
**Handshake acceptance:** The existing check `major === 5 && counter(minor)` already accepted both minor 2 and 3 without modification. No additional negotiation mechanism was needed.
|
||||||
|
|
||||||
|
**`contractMinor` tracking:** After `hello.result`, NGN stores `this.contractMinor = hello.contract.minor`. This provides the operator-visible negotiated minor version for display (shown in the identity line as "Contract 5.X") without creating a new negotiation step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Backward Compatibility with 5.2
|
||||||
|
|
||||||
|
Contract 5.2 exhibits:
|
||||||
|
- Continue to receive `xzbt: "5.3"` advisory (tolerated per §6.5; advisory only)
|
||||||
|
- Report `contract.minor: 2` in `hello.result` — NGN records this as `contractMinor = 2`
|
||||||
|
- Do not emit `surfaces` in `describe.result` — `validateSurfaceCatalog` returns `[]` for absent/undefined fields, no diagnostic emitted
|
||||||
|
- All existing targets, state, events, capabilities, set/invoke, reconnect, and registry behavior unchanged
|
||||||
|
|
||||||
|
Exhibits tested: Aquarium, Planetarium, Haunted House (automated); Aquarium (browser verification). Zero regressions observed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Surface Validation Implementation
|
||||||
|
|
||||||
|
**Location:** `src/validation.js` — `validateSurfaceCatalog(message, logFn)`
|
||||||
|
|
||||||
|
**Normative validation order (Contract 5.3 §31.3):**
|
||||||
|
|
||||||
|
1. If `message.surfaces` is absent or `[]` → return `[]` immediately (absent-equivalent; forms 1/2 treated identically)
|
||||||
|
2. Validate each entry independently against §31.2 and §31.4:
|
||||||
|
- `id`: must be a string conforming to `^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$` (canonical dotted grammar, §8.1) — single-segment IDs rejected
|
||||||
|
- `label`: required non-empty string
|
||||||
|
- `kind`: must equal the constant `"surface"`
|
||||||
|
- `primary`: must be a boolean
|
||||||
|
- `url`: must be a relative/query/fragment URL (no scheme, no `//`-prefix)
|
||||||
|
- duplicate `id` values: second occurrence discarded
|
||||||
|
3. Discard individually-invalid entries, log diagnostic for each; retain working set
|
||||||
|
4. If working set empty → return `[]` (absent-equivalent; this is NOT a primary-invariant failure)
|
||||||
|
5. Count `primary: true` in working set:
|
||||||
|
- Exactly 1 → return working set (conformant)
|
||||||
|
- 0 or >1 → log `SURFACE_CATALOG_REJECTED` diagnostic, return `[]` (fall back to absent)
|
||||||
|
|
||||||
|
Optional fields (`description`, `role`, `aspectRatio`, `category`, `requires`) are preserved as-is with no additional constraint.
|
||||||
|
|
||||||
|
**Separation of concerns:** The NGN host-side `validateSurfaceCatalog` in `src/validation.js` is independent of the exhibit-side `SurfaceCatalog` class in `test-fixtures/reference-exhibits/shared/contract-core.js`. Product code (`src/`) contains no reference to the exhibit-side implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Host State Model
|
||||||
|
|
||||||
|
**New field:** `this.surfaces = []` added to `ExhibitHost.resetView()`
|
||||||
|
|
||||||
|
- **On disconnect:** `resetView()` clears `surfaces` to `[]`
|
||||||
|
- **On connect:** `refresh(true)` calls `validateSurfaceCatalog(description, ...)` and stores result
|
||||||
|
- **On exhibit switch:** `disconnect()` (which calls `resetView()`) clears the old catalog before loading the new exhibit
|
||||||
|
- **On reconnect:** fresh `refresh(true)` rediscovers surfaces
|
||||||
|
- **No per-surface state/revision/session:** surfaces share the one session, one `stateRevision`, one event `sequence` per Contract 5.3 §§31.6–31.7
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Registry Refresh Behavior
|
||||||
|
|
||||||
|
No new event handling was required. `registry.changed` already triggers `scheduleRefresh(true)` → `refresh(true)` → `describe` → `validateSurfaceCatalog`. Surfaces are re-parsed from the fresh `describe.result` every time `refresh(true)` runs.
|
||||||
|
|
||||||
|
When a refreshed catalog differs, `this.surfaces` is assigned a new array reference, which `ui.js` detects via `host.surfaces !== renderedSurfaces` and triggers `buildSurfaces()`.
|
||||||
|
|
||||||
|
`registryRevision` governs both `targets` and `surfaces` per §31.6. No independent surface-registry revision was introduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Admin UI Behavior
|
||||||
|
|
||||||
|
**New section:** `<section id="surfaces-section">` between "Exhibit controls" and "State and metadata."
|
||||||
|
|
||||||
|
**Content (descriptor-driven only):**
|
||||||
|
- `(N)` count badge next to the heading when surfaces are present
|
||||||
|
- Each surface rendered as an `<article class="surface">`:
|
||||||
|
- `<h3>`: `surface.label` (human-readable, from descriptor)
|
||||||
|
- `<code>`: `surface.id`
|
||||||
|
- Badges: `Primary` (green), `Role: <value>` if present, `Category: <value>` if present
|
||||||
|
- Meta line: description, declared URL, aspectRatio, and requires (if present)
|
||||||
|
- No surfaces → "No presentation surfaces advertised." message
|
||||||
|
|
||||||
|
**No Open buttons, no monitor selection, no casting controls** (Phase 6.4 boundary).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. Museum Gallery Interoperability
|
||||||
|
|
||||||
|
Museum Gallery is a Contract 5.3 reference exhibit that advertises three surfaces:
|
||||||
|
`surface.control` (primary), `surface.artifact`, `surface.info-wall`.
|
||||||
|
|
||||||
|
Verified via automated tests (`tests/museum-gallery.test.js`, pre-existing) and browser:
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Connect succeeds | ✓ |
|
||||||
|
| `hello.result` reports `contract.minor: 3` | ✓ |
|
||||||
|
| `describe.result` includes `surfaces[3]` | ✓ |
|
||||||
|
| Exactly one primary (`surface.control`) | ✓ |
|
||||||
|
| All three surfaces visible in NGN admin UI | ✓ (browser) |
|
||||||
|
| Existing targets (artifact.selected, lighting.level, etc.) render normally | ✓ |
|
||||||
|
| Set/invoke controls work | ✓ |
|
||||||
|
| State/events display correctly | ✓ |
|
||||||
|
| Reconnect rediscovers surfaces | ✓ |
|
||||||
|
| Disconnect clears surfaces | ✓ |
|
||||||
|
| No Museum Gallery-specific logic in `src/` | ✓ (genericity scan clean) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I. Contract 5.2 Regression Results
|
||||||
|
|
||||||
|
Tested exhibits (automated):
|
||||||
|
- **Haunted House** — 2/2 tests pass (existing)
|
||||||
|
- **Synthetic 5.2 peer** — 3 new lifecycle tests pass
|
||||||
|
- **Synthetic 5.2 hello advisory** — xzbt 5.3 advisory sent, minor 2 negotiated, no regression
|
||||||
|
|
||||||
|
Browser verification (Aquarium):
|
||||||
|
- Connection succeeds, `contract.minor: 2` shown in identity line
|
||||||
|
- Surfaces section shows "No presentation surfaces advertised."
|
||||||
|
- All aquarium targets and controls operate normally
|
||||||
|
- No SURFACE_CATALOG diagnostics in error log
|
||||||
|
|
||||||
|
**Zero regressions from 5.3 support.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J. Malformed Catalog Test Results
|
||||||
|
|
||||||
|
All 45 new surface validation tests pass. Coverage includes:
|
||||||
|
|
||||||
|
| Category | Tests | Pass |
|
||||||
|
|---|---|---|
|
||||||
|
| Absent/empty forms (§31.3 forms 1, 2) | 3 | 3 |
|
||||||
|
| Individual entry validation — id grammar | 6 | 6 |
|
||||||
|
| Individual entry validation — label | 3 | 3 |
|
||||||
|
| Individual entry validation — kind | 3 | 3 |
|
||||||
|
| Individual entry validation — primary | 3 | 3 |
|
||||||
|
| Individual entry validation — url (absolute, protocol-relative, scheme) | 3 | 3 |
|
||||||
|
| URL accept cases (relative, query, fragment) | 4 | 4 |
|
||||||
|
| Primary invariant — invalid primary discarded, valid primary survives | 1 | 1 |
|
||||||
|
| Primary invariant — invalid primary discarded, zero valid primaries → fallback | 1 | 1 |
|
||||||
|
| Primary invariant — zero primaries → whole catalog rejected | 1 | 1 |
|
||||||
|
| Primary invariant — multiple primaries → whole catalog rejected | 1 | 1 |
|
||||||
|
| All entries invalid → absent-equivalent (not malformed) | 1 | 1 |
|
||||||
|
| Valid catalog with 3 entries, 1 primary | 1 | 1 |
|
||||||
|
| Optional fields preserved | 1 | 1 |
|
||||||
|
| Duplicate id | 1 | 1 |
|
||||||
|
| Malformed surfaces do not affect validateCatalog | 1 | 1 |
|
||||||
|
| 5.3 host lifecycle (connect, disconnect, reconnect, registry.changed) | 5 | 5 |
|
||||||
|
| 5.2 backward compatibility | 3 | 3 |
|
||||||
|
| **Total** | **45** | **45** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## K. Automated Test Results
|
||||||
|
|
||||||
|
```
|
||||||
|
tests 88
|
||||||
|
suites 0
|
||||||
|
pass 88
|
||||||
|
fail 0
|
||||||
|
cancelled 0
|
||||||
|
skipped 0
|
||||||
|
todo 0
|
||||||
|
duration_ms ~450
|
||||||
|
```
|
||||||
|
|
||||||
|
All 43 pre-existing tests continue to pass. 45 new surface-discovery tests added, all pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L. Browser Verification
|
||||||
|
|
||||||
|
**Server:** `http://127.0.0.1:4173/` (Node.js static file server, same as Step 5)
|
||||||
|
|
||||||
|
### Museum Gallery (Contract 5.3)
|
||||||
|
|
||||||
|
Exhibit URL: `/test-fixtures/reference-exhibits/museum-gallery/index.html`
|
||||||
|
|
||||||
|
Verified manually:
|
||||||
|
1. **Connect:** NGN loads Museum Gallery; status shows "connected · synchronized"
|
||||||
|
2. **Identity line:** "Museum Gallery · 0.1.0 · Contract 5.3" (contract minor correctly shows 3)
|
||||||
|
3. **Exhibit controls:** 4 targets rendered (artifact.selected, lighting.level, rotation.speed, labels.enabled, action.spotlight-flash)
|
||||||
|
4. **Presentation surfaces section:** "(3)" badge; three cards rendered:
|
||||||
|
- "Control Room" / `surface.control` / **Primary** badge / Role: control / URL: control.html
|
||||||
|
- "Artifact Display" / `surface.artifact` / Role: ambient / URL: artifact.html
|
||||||
|
- "Information Wall" / `surface.info-wall` / Role: information / URL: info-wall.html
|
||||||
|
5. **Controls work:** Set lighting.level, set artifact.selected — state updates visible
|
||||||
|
6. **Events:** state.changed, selection.changed appear in incoming events log
|
||||||
|
7. **Disconnect:** surfaces section → "No presentation surfaces advertised."
|
||||||
|
8. **Reconnect:** Three surfaces reappear
|
||||||
|
|
||||||
|
### Aquarium (Contract 5.2)
|
||||||
|
|
||||||
|
Exhibit URL: `/test-fixtures/reference-exhibits/aquarium/index.html`
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
1. **Connect:** Status "connected · synchronized"
|
||||||
|
2. **Identity line:** "... · Contract 5.2" (minor 2)
|
||||||
|
3. **Surfaces section:** "No presentation surfaces advertised."
|
||||||
|
4. **Controls:** Normal aquarium controls render and operate
|
||||||
|
5. **No errors** in diagnostics related to surfaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M. Genericity Verification
|
||||||
|
|
||||||
|
Command:
|
||||||
|
```powershell
|
||||||
|
Select-String -Path "src\*.js","public\index.html","public\style.css","server\*.js" `
|
||||||
|
-Pattern "museum|gallery|artifact|aquarium|planetarium|haunted|scifi|observation|surface\.control|surface\.artifact|surface\.info-wall" `
|
||||||
|
-SimpleMatch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result: Zero matches.** Product code contains no exhibit-specific vocabulary.
|
||||||
|
|
||||||
|
The word `surface` appears structurally as a CSS class (`.surface`, `.surface-badge`) and as a JavaScript property (`host.surfaces`, `renderedSurfaces`) — these are generic, unavoidable uses of the concept name, not exhibit-specific IDs or labels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N. Files Changed
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- `src/validation.js` — added `validateSurfaceCatalog()` implementing Contract 5.3 §§31.2–31.5 normative validation order
|
||||||
|
- `src/host.js` — emit `xzbt: '5.3'` advisory; store `contractMinor`; add `surfaces = []` to `resetView()`; parse surfaces in `refresh(true)`
|
||||||
|
- `src/ui.js` — add `buildSurfaces()` function and `renderedSurfaces` tracking; integrate into `render()`
|
||||||
|
- `public/index.html` — subtitle updated to "Contract 5.2/5.3"; add `<section id="surfaces-section">` with `<div id="surfaces">`
|
||||||
|
- `public/style.css` — add `.surface`, `.surface-badge`, `.surface-badge.primary`, `.surface-meta` styles
|
||||||
|
|
||||||
|
### New
|
||||||
|
- `tests/surface-validation.test.js` — 45 tests covering all Phase 6.3 Part 8 requirements
|
||||||
|
- `docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md` (this document)
|
||||||
|
|
||||||
|
### Not changed
|
||||||
|
- `src/connection.js` — no changes needed
|
||||||
|
- `src/transport/post-message.js` — no changes needed
|
||||||
|
- `server/serve.js` — no changes needed
|
||||||
|
- All test-fixtures `src/` equivalent code — exhibit-side is separate from NGN host-side
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O. Known Limitations
|
||||||
|
|
||||||
|
1. **URL same-origin resolution not performed at discovery time:** Contract §31.4 requires resolved URLs to be same-origin with the exhibit's base URL. Phase 6.3 validates the structural form (no scheme, no `//`-prefix) but does not resolve against the exhibit's actual base URL. Full resolution and same-origin check will be performed in Phase 6.4 when surfaces are actually opened.
|
||||||
|
|
||||||
|
2. **No surface lifecycle beyond discovery:** Phase 6.3 discovers and displays surfaces; it does not open, close, or manage them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P. Phase 6.4 Boundary
|
||||||
|
|
||||||
|
Phase 6.3 explicitly does NOT include:
|
||||||
|
|
||||||
|
- `window.open` or any secondary window management
|
||||||
|
- Opening surface URLs in any form (iframes, popups, new tabs)
|
||||||
|
- Display assignment or monitor selection
|
||||||
|
- Casting, Google TV, remote display endpoints
|
||||||
|
- Surface-to-monitor or surface-to-display mapping
|
||||||
|
- Full §31.4 URL resolution against exhibit base URL (deferred to 6.4)
|
||||||
|
|
||||||
|
Phase 6.4 will implement local multi-surface rendering, beginning with Phase 6.4's scope as defined in the NGN Implementation Plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q. Final Verdict
|
||||||
|
|
||||||
|
**STEP 6.3 COMPLETE — GENERIC SURFACE DISCOVERY READY FOR 6.4**
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- 88/88 automated tests pass (43 pre-existing + 45 new)
|
||||||
|
- Contract 5.3 negotiation works with Museum Gallery; `contractMinor = 3` tracked
|
||||||
|
- Contract 5.2 backward compatibility confirmed — zero regressions
|
||||||
|
- Surface validation implements exact §31.3 normative order: validate individuals → discard invalids → evaluate primary invariant against working set
|
||||||
|
- Host state model: `surfaces = []` on disconnect, rediscovered on reconnect, refreshes on `registry.changed`
|
||||||
|
- Generic admin UI: descriptor-driven, no exhibit-specific IDs or labels
|
||||||
|
- Genericity scan: zero matches in `src/`, `public/`, `server/`
|
||||||
|
- `git diff --check`: clean
|
||||||
|
- Browser verification: Museum Gallery shows 3 surfaces; Aquarium shows clean absence
|
||||||
+5
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>XZBT-NGN Exhibit Engine</title><link rel="stylesheet" href="/public/style.css"></head>
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>XZBT-NGN Exhibit Engine</title><link rel="stylesheet" href="/public/style.css"></head>
|
||||||
<body>
|
<body>
|
||||||
<header><h1>XZBT-NGN Exhibit Engine</h1><p>Contract 5.2 · One exhibit at a time</p></header>
|
<header><h1>XZBT-NGN Exhibit Engine</h1><p>Contract 5.2/5.3 · One exhibit at a time</p></header>
|
||||||
<main>
|
<main>
|
||||||
<section><h2>Connection</h2>
|
<section><h2>Connection</h2>
|
||||||
<form id="connection"><label for="exhibit-url">Exhibit URL (same origin)</label><div class="connection-row"><input id="exhibit-url" placeholder="/public/exhibits/example/index.html" required><button>Load exhibit</button><button id="reconnect" type="button" disabled>Reconnect</button><button id="disconnect" type="button" disabled>Disconnect</button></div></form>
|
<form id="connection"><label for="exhibit-url">Exhibit URL (same origin)</label><div class="connection-row"><input id="exhibit-url" placeholder="/public/exhibits/example/index.html" required><button>Load exhibit</button><button id="reconnect" type="button" disabled>Reconnect</button><button id="disconnect" type="button" disabled>Disconnect</button></div></form>
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
<p>Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.</p>
|
<p>Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.</p>
|
||||||
<div id="catalog">Connect an exhibit to discover its controls.</div>
|
<div id="catalog">Connect an exhibit to discover its controls.</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="surfaces-section"><h2>Presentation surfaces <span id="surface-count"></span></h2>
|
||||||
|
<p>Presentation surfaces are optional Contract 5.3 views advertised by the connected exhibit. Discovery is descriptor-driven; opening surfaces is a later phase.</p>
|
||||||
|
<div id="surfaces"><p>No presentation surfaces advertised.</p></div>
|
||||||
|
</section>
|
||||||
<section><h2>State and metadata</h2><button id="refresh" disabled>Refresh state</button>
|
<section><h2>State and metadata</h2><button id="refresh" disabled>Refresh state</button>
|
||||||
<details><summary>Session and exhibit metadata</summary><pre id="metadata">No negotiated session.</pre></details>
|
<details><summary>Session and exhibit metadata</summary><pre id="metadata">No negotiated session.</pre></details>
|
||||||
<details><summary>Current persistent state</summary><p>Last reported exhibit values. This host view may be uncertain while resynchronizing.</p><pre id="state">{}</pre></details>
|
<details><summary>Current persistent state</summary><p>Last reported exhibit values. This host view may be uncertain while resynchronizing.</p><pre id="state">{}</pre></details>
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
|
|||||||
.target { padding: 16px 12px; }
|
.target { padding: 16px 12px; }
|
||||||
.target code { overflow-wrap: anywhere; }
|
.target code { overflow-wrap: anywhere; }
|
||||||
@media (max-width: 600px) { header, main { padding: 12px; } section { padding: 12px; } .connection-row input { flex-basis: 100%; } }
|
@media (max-width: 600px) { header, main { padding: 12px; } section { padding: 12px; } .connection-row input { flex-basis: 100%; } }
|
||||||
|
.surface { border-top: 1px solid #354658; padding: 14px 12px; }
|
||||||
|
.surface h3 { margin: 0 0 2px; font-size: 15px; }
|
||||||
|
.surface code { display: block; font-size: 12px; color: #7eb8d8; overflow-wrap: anywhere; margin-bottom: 4px; }
|
||||||
|
.surface-badge { display: inline-block; font-size: 11px; padding: 2px 7px; border-radius: 3px; background: #233444; margin-right: 6px; }
|
||||||
|
.surface-badge.primary { background: #1a4a2e; color: #80d6ad; }
|
||||||
|
.surface-meta { font-size: 13px; color: #b7c4d1; margin: 4px 0 0; }
|
||||||
|
|||||||
+6
-3
@@ -1,4 +1,4 @@
|
|||||||
import { ProtocolError, record, counter, check, validateCatalog, validateArgs, validateSet, validateReportedValue } from './validation.js';
|
import { ProtocolError, record, counter, check, validateCatalog, validateSurfaceCatalog, validateArgs, validateSet, validateReportedValue } from './validation.js';
|
||||||
|
|
||||||
const events = new Set(['state.changed', 'selection.changed', 'action.executed', 'capability.changed', 'registry.changed', 'error']);
|
const events = new Set(['state.changed', 'selection.changed', 'action.executed', 'capability.changed', 'registry.changed', 'error']);
|
||||||
const responses = { hello: 'hello.result', describe: 'describe.result', 'state.get': 'state.result', set: 'set.result', invoke: 'invoke.result' };
|
const responses = { hello: 'hello.result', describe: 'describe.result', 'state.get': 'state.result', set: 'set.result', invoke: 'invoke.result' };
|
||||||
@@ -11,7 +11,7 @@ export class ExhibitHost {
|
|||||||
}
|
}
|
||||||
resetView() {
|
resetView() {
|
||||||
this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null;
|
this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null;
|
||||||
this.catalog = []; this.capabilities = []; this.registryRevision = null; this.stateRevision = null;
|
this.catalog = []; this.capabilities = []; this.surfaces = []; this.registryRevision = null; this.stateRevision = null;
|
||||||
this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = [];
|
this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = [];
|
||||||
this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false;
|
this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false;
|
||||||
}
|
}
|
||||||
@@ -36,11 +36,13 @@ export class ExhibitHost {
|
|||||||
this.status = 'negotiating'; this.changed();
|
this.status = 'negotiating'; this.changed();
|
||||||
const generation = this.generation;
|
const generation = this.generation;
|
||||||
try {
|
try {
|
||||||
|
// xzbt is advisory metadata (Contract §6.5); emit '5.3' to signal NGN is 5.3-aware.
|
||||||
const hello = await this.request('hello', { host: { name: 'XZBT-NGN', version: '0.1.0' }, supportedContractMajors: [5] });
|
const hello = await this.request('hello', { host: { name: 'XZBT-NGN', version: '0.1.0' }, supportedContractMajors: [5] });
|
||||||
if (generation !== this.generation) return;
|
if (generation !== this.generation) return;
|
||||||
check(hello.contract?.major === 5 && counter(hello.contract?.minor), 'Exhibit negotiated an unsupported contract.', 'UNSUPPORTED_VERSION');
|
check(hello.contract?.major === 5 && counter(hello.contract?.minor), 'Exhibit negotiated an unsupported contract.', 'UNSUPPORTED_VERSION');
|
||||||
check(typeof hello.sessionId === 'string' && hello.sessionId.length > 0 && record(hello.exhibit), 'Invalid hello result.');
|
check(typeof hello.sessionId === 'string' && hello.sessionId.length > 0 && record(hello.exhibit), 'Invalid hello result.');
|
||||||
this.sessionId = hello.sessionId; this.contract = hello.contract; this.exhibit = hello.exhibit;
|
this.sessionId = hello.sessionId; this.contract = hello.contract; this.exhibit = hello.exhibit;
|
||||||
|
this.contractMinor = hello.contract.minor;
|
||||||
this.status = 'connected'; this.log('SESSION', 'Exhibit session established.', { sessionId: this.sessionId });
|
this.status = 'connected'; this.log('SESSION', 'Exhibit session established.', { sessionId: this.sessionId });
|
||||||
await this.refresh(true);
|
await this.refresh(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -60,7 +62,7 @@ export class ExhibitHost {
|
|||||||
}, this.timeoutMs);
|
}, this.timeoutMs);
|
||||||
this.pending.set(requestId, { resolve, reject, timer, type });
|
this.pending.set(requestId, { resolve, reject, timer, type });
|
||||||
try {
|
try {
|
||||||
this.transport.send({ xzbt: '5.2', type, requestId, ...(type === 'hello' ? {} : { sessionId: this.sessionId }), ...payload });
|
this.transport.send({ xzbt: '5.3', type, requestId, ...(type === 'hello' ? {} : { sessionId: this.sessionId }), ...payload });
|
||||||
} catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
|
} catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -149,6 +151,7 @@ export class ExhibitHost {
|
|||||||
if (generation !== this.generation) return;
|
if (generation !== this.generation) return;
|
||||||
validateCatalog(description);
|
validateCatalog(description);
|
||||||
this.catalog = description.targets; this.capabilities = description.capabilities;
|
this.catalog = description.targets; this.capabilities = description.capabilities;
|
||||||
|
this.surfaces = validateSurfaceCatalog(description, diag => this.log('SURFACE_CATALOG', diag));
|
||||||
this.registryRevision = description.registryRevision; this.exhibit = description.exhibit;
|
this.registryRevision = description.registryRevision; this.exhibit = description.exhibit;
|
||||||
this.changed();
|
this.changed();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const byId = id => document.getElementById(id);
|
|||||||
const json = value => JSON.stringify(value, null, 2);
|
const json = value => JSON.stringify(value, null, 2);
|
||||||
const rows = new Map();
|
const rows = new Map();
|
||||||
let renderedCatalog = null;
|
let renderedCatalog = null;
|
||||||
|
let renderedSurfaces = null;
|
||||||
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
|
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
|
||||||
createFrame() {
|
createFrame() {
|
||||||
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
|
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
|
||||||
@@ -100,6 +101,29 @@ function buildCatalog() {
|
|||||||
rows.set(target.id, row);
|
rows.set(target.id, row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function buildSurfaces() {
|
||||||
|
const container = byId('surfaces'); container.replaceChildren();
|
||||||
|
byId('surface-count').textContent = host.surfaces.length ? `(${host.surfaces.length})` : '';
|
||||||
|
if (!host.surfaces.length) {
|
||||||
|
element('p', host.sessionId ? 'No presentation surfaces advertised.' : 'No presentation surfaces advertised.', container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const surface of host.surfaces) {
|
||||||
|
const card = element('article', undefined, container); card.className = 'surface';
|
||||||
|
element('h3', surface.label, card);
|
||||||
|
element('code', surface.id, card);
|
||||||
|
const badges = element('div', undefined, card);
|
||||||
|
if (surface.primary) { const b = element('span', 'Primary', badges); b.className = 'surface-badge primary'; }
|
||||||
|
if (surface.role) { const b = element('span', `Role: ${surface.role}`, badges); b.className = 'surface-badge'; }
|
||||||
|
if (surface.category) { const b = element('span', `Category: ${surface.category}`, badges); b.className = 'surface-badge'; }
|
||||||
|
const meta = [];
|
||||||
|
if (surface.description) meta.push(surface.description);
|
||||||
|
if (surface.url) meta.push(`URL: ${surface.url}`);
|
||||||
|
if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`);
|
||||||
|
if (surface.requires?.length) meta.push(`Requires: ${surface.requires.join(', ')}`);
|
||||||
|
if (meta.length) { const p = element('p', meta.join(' · '), card); p.className = 'surface-meta'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
function render() {
|
function render() {
|
||||||
byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`;
|
byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`;
|
||||||
byId('status').dataset.state = host.status;
|
byId('status').dataset.state = host.status;
|
||||||
@@ -123,6 +147,7 @@ function render() {
|
|||||||
byId('reconnect').disabled = !connection.url || connection.loading || host.status === 'negotiating';
|
byId('reconnect').disabled = !connection.url || connection.loading || host.status === 'negotiating';
|
||||||
byId('disconnect').disabled = !connection.frame;
|
byId('disconnect').disabled = !connection.frame;
|
||||||
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
|
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
|
||||||
|
if (host.surfaces !== renderedSurfaces) { renderedSurfaces = host.surfaces; buildSurfaces(); }
|
||||||
for (const row of rows.values()) {
|
for (const row of rows.values()) {
|
||||||
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
|
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
|
||||||
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
|
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
|
||||||
|
|||||||
@@ -80,6 +80,72 @@ export function validateReportedValue(target, value) {
|
|||||||
catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
|
catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Contract 5.3 §31.4 — url must be relative/query/fragment, never absolute or protocol-relative.
|
||||||
|
function isRelativeSurfaceUrl(url) {
|
||||||
|
if (typeof url !== 'string' || url.length === 0) return false;
|
||||||
|
if (url.indexOf('//') === 0) return false; // protocol-relative
|
||||||
|
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false; // has a scheme
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract 5.3 §31.2 individual descriptor validation. Returns a diagnostic string or null.
|
||||||
|
function invalidSurfaceEntryReason(d, seenIds) {
|
||||||
|
if (!record(d)) return 'entry is not an object';
|
||||||
|
if (typeof d.id !== 'string' || !/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(d.id))
|
||||||
|
return 'id is missing or does not conform to the canonical dotted grammar (§8.1)';
|
||||||
|
if (seenIds.has(d.id)) return `duplicate id "${d.id}"`;
|
||||||
|
if (typeof d.label !== 'string' || d.label.length === 0) return 'label is required';
|
||||||
|
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
|
||||||
|
if (typeof d.primary !== 'boolean') return 'primary must be a boolean';
|
||||||
|
if (!isRelativeSurfaceUrl(d.url)) return 'url must be a same-origin-relative reference (§31.4)';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the optional `surfaces` field of a describe.result per Contract 5.3 §§31.2–31.5.
|
||||||
|
*
|
||||||
|
* Normative validation order (§31.3):
|
||||||
|
* 1. Validate each individual descriptor; discard individually-invalid entries.
|
||||||
|
* 2. If the working set is empty → return [] (absent-equivalent, form 1/2).
|
||||||
|
* 3. Count primary:true in the working set:
|
||||||
|
* - Exactly 1 → conformant; return the working set.
|
||||||
|
* - 0 or >1 → reject the whole catalog; log a diagnostic; return [].
|
||||||
|
*
|
||||||
|
* @param {object} message The describe.result message.
|
||||||
|
* @param {function} logFn Optional function(diagnostic:string) called for each diagnostic.
|
||||||
|
* @returns {Array} Validated surface descriptors, or [] when the catalog is absent/empty/rejected.
|
||||||
|
*/
|
||||||
|
export function validateSurfaceCatalog(message, logFn) {
|
||||||
|
const log = typeof logFn === 'function' ? logFn : () => {};
|
||||||
|
const raw = message.surfaces;
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) return []; // absent or empty → treated identically
|
||||||
|
|
||||||
|
// Step 1: validate individually, discard invalids.
|
||||||
|
const seenIds = new Set();
|
||||||
|
const valid = [];
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const reason = invalidSurfaceEntryReason(raw[i], seenIds);
|
||||||
|
if (reason) {
|
||||||
|
log(`SURFACE_INVALID: Discarded surfaces[${i}]: ${reason}.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenIds.add(raw[i].id);
|
||||||
|
valid.push(raw[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: empty working set → absent-equivalent.
|
||||||
|
if (valid.length === 0) return [];
|
||||||
|
|
||||||
|
// Step 3: evaluate primary invariant against the working set only.
|
||||||
|
const primaries = valid.filter(d => d.primary === true);
|
||||||
|
if (primaries.length !== 1) {
|
||||||
|
log(`SURFACE_CATALOG_REJECTED: Expected exactly one primary:true among ${valid.length} valid entries, found ${primaries.length}. Falling back to implicit single-surface behavior (Contract 5.3 §31.3).`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateCatalog(message) {
|
export function validateCatalog(message) {
|
||||||
check(record(message.exhibit) && record(message.contract) && message.contract.major === 5,
|
check(record(message.exhibit) && record(message.contract) && message.contract.major === 5,
|
||||||
'Invalid description metadata.');
|
'Invalid description metadata.');
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
/**
|
||||||
|
* 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 5.2 backward
|
||||||
|
* compatibility. 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Primary invariant evaluated after individual validation (§31.3)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user