diff --git a/AGENTS.md b/AGENTS.md index 8680d9e..eccb39a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,23 @@ Authoring Guide, and owns the NGN Implementation Plan. Documents in Reference exhibits under `test-fixtures/` are test/reference fixtures, not product code. Keep exhibit-specific names and paths in test configuration or documentation. -- XZBT Exhibit Contract Specification v5.2 +XZBT-NGN and its maintained reference exhibits move forward with the contract. +Contract 5.3 is the current maintained contract; it supersedes 5.2 additively +(no 5.2 normative requirement was removed, weakened, or reinterpreted). The +`xzbt` envelope field is advisory metadata only (Contract §6.5) and MUST NOT be +used as an exact-string compatibility gate anywhere in this repository -- +real compatibility is negotiated by the handshake and contract fields. +Backward compatibility with a genuine 5.2 exhibit remains a protocol-level +requirement of the contract itself (Section 28.2), but that is a different +thing from keeping a *currently maintained* reference exhibit or its test +coverage frozen on 5.2: there is no such requirement, and maintained exhibits +are expected to advance with the contract instead. + +- XZBT Exhibit Contract Specification v5.3 (current; supersedes v5.2 below) - XZBT-NGN Exhibit Engine Implementation Plan v5.2 - XZBT Exhibit Authoring Guide v5.2 - Step 3.7 reference exhibit report +- XZBT Exhibit Contract Specification v5.2 (historical predecessor, retained for context) - Do not infer XZBT-NGN behavior from SciFi-XZBT source code. - SciFi-XZBT is an external exhibit implementation, not the NGN architecture. diff --git a/README.md b/README.md index c6a8e4a..ee804ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # XZBT-NGN Exhibit Engine -A single-exhibit host for XZBT Exhibit Contract 5.2. It negotiates sessions, +A single-exhibit host for XZBT Exhibit Contract 5.3 (current; see +[Canonical documentation](#canonical-documentation)). It negotiates sessions, discovers descriptor-driven controls, inspects reported state, sends set/invoke operations, and displays capabilities, events, revisions, and protocol errors. @@ -9,7 +10,9 @@ Step 5 implementation and verification are recorded in descriptors are corrected and both text invocations pass through the operator UI. Step 5 is complete: 27 automated tests and all three browser reference checks pass. Haunted House reports conformant values, and publishing validation passes. -Step 6 is planned but has not begun. +Steps 6.1–6.3 established Contract 5.3 surface discovery and interoperability. +Step 6.4 adds local presentation panes; see the +[Step 6.4 report](docs/architecture/XZBT-NGN-Step6.4-Local-Surfaces.md). ## Run locally @@ -28,6 +31,12 @@ the discovered controls. Enter another path and select **Load exhibit** to switc exhibit instance starts with its own initial state. - **Disconnect** removes the frame and clears session, catalog, state and events. - **Refresh state** reads a new authoritative snapshot. +- **Presentation surfaces** provides Open, Reload and Close for each local view, + with its label, primary status and lifecycle state. URLs resolve against the + supplying exhibit's document URL, and must remain same-origin. NGN closes its + surface panes when disconnecting, reconnecting the session or switching exhibits. + A primary surface already represented by the control frame is marked as open; + its lifecycle remains under the Connection controls. - Controls are grouped by the exhibit's category (or Other when omitted). Reported values and draft inputs are distinct; select **Set** or **Invoke** to submit. Rediscovery resets drafts. @@ -41,6 +50,8 @@ Included reference fixture paths: /test-fixtures/reference-exhibits/aquarium/index.html /test-fixtures/reference-exhibits/planetarium/index.html /test-fixtures/reference-exhibits/haunted-house/index.html +/test-fixtures/reference-exhibits/museum-gallery/control.html +/test-fixtures/reference-exhibits/scifi/index.html ``` These are locally corrected test/reference fixtures, not NGN product code or @@ -69,34 +80,55 @@ needed for ordinary operation. ## Canonical documentation This repository is the canonical home for the shared -[XZBT Exhibit Contract Specification 5.2](docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md) -and [XZBT Exhibit Authoring Guide 5.2](docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md). +[XZBT Exhibit Contract Specification 5.3](docs/contract/XZBT-Exhibit-Contract-Specification-v5.3.md) -- +the current maintained contract, which additively supersedes +[Contract Specification 5.2](docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md) +(retained as a historical predecessor; no 5.2 normative requirement was +removed, weakened, or reinterpreted) -- and for the +[XZBT Exhibit Authoring Guide 5.2](docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md). The [NGN Implementation Plan 5.2](docs/architecture/XZBT-NGN-Exhibit-Engine-Implementation-Plan-v5.2.md) also belongs here. Its historical phase numbering describes the longer roadmap; the current Step 5 completion pass does not authorize those later features. SciFi-XZBT is an external exhibit implementation, not the NGN architecture. `docs/reference/` contains external implementation context, not host requirements. +NGN and its maintained reference exhibits (Museum Gallery, SciFi-XZBT, Haunted +House, Aquarium, Planetarium) move forward with the contract: there is no +requirement to keep a currently maintained exhibit, or its test coverage, +frozen on an old minor version. The `xzbt` envelope field is advisory metadata +only (Contract §6.5) and is never used as an exact-string compatibility gate; +real compatibility is negotiated by the handshake and contract fields. A +genuine, unmaintained Contract 5.2 exhibit remains protocol-compatible with +this host (Contract §28.2) -- that guarantee is unrelated to which minor +version the exhibits maintained in *this* repository currently declare. + ## Implementation and MVP boundary - `src/host.js`: negotiation, requests, discovery, state cache, events and recovery. - `src/connection.js`: URL validation and frame load/disconnect/reconnect lifecycle. +- `src/surface-url.js`, `src/local-surfaces.js`: canonical surface URL resolution + and local presentation frame lifecycle, without additional contract sessions. - `src/validation.js`: descriptor, state value and argument validation. - `src/transport/post-message.js`: same-origin frame transport. - `src/ui.js`, `public/`: generic operator interface. - `server/serve.js`: loopback static server. - `tests/`, `test-fixtures/`: automated checks, reference fixtures and browser evidence. -The MVP supports one exhibit, Contract 5.2 discovery, generic target rendering, +The MVP supports one exhibit, Contract 5.3 discovery, generic target rendering, state inspection, set/invoke, capabilities, events, session/revision/sequence tracking, reconnect and synchronization recovery. It has in-memory state only; logs retain 200 entries. Idle polling is not enabled. The last connection URL is -remembered only until the page is reloaded. +remembered only until the page is reloaded. Contract 5.3 presentation-surface +discovery and local rendering (Section 31) are implemented and exercised by the +Museum Gallery reference exhibit; other maintained exhibits do not declare +multiple surfaces. Surface errors stay within the affected pane. Remote/cast +display assignment remains deferred. Shared-state synchronization proof remains +Step 6.5; this step adds no host-side surface communication. -Not implemented: multi-surface presentation, casting/display endpoints, MIDI, -MCP/webhooks, scenarios/recording, telemetry acquisition, multi-exhibit -orchestration, persisted operator configurations, remote deployment, accounts, -databases, cloud services, plugins, commercial licensing or production branding. +Not implemented: casting/display endpoints, MIDI, MCP/webhooks, +scenarios/recording, telemetry acquisition, multi-exhibit orchestration, +persisted operator configurations, remote deployment, accounts, databases, +cloud services, plugins, commercial licensing or production branding. ## Publishing records diff --git a/docs/architecture/XZBT-NGN-Step6.4-Local-Surfaces.md b/docs/architecture/XZBT-NGN-Step6.4-Local-Surfaces.md new file mode 100644 index 0000000..4a6823f --- /dev/null +++ b/docs/architecture/XZBT-NGN-Step6.4-Local-Surfaces.md @@ -0,0 +1,165 @@ +# Step 6.4 — Local Multi-Surface Rendering + +Verified 2026-09-14. **Step 6.4 complete.** Contract 5.3 presentation surfaces +can be rendered locally through the normal NGN operator UI. + +## Pre-change architecture + +`ExhibitConnection` owned one iframe, remembered its normalized same-origin +URL in `connection.url`, and passed it to `ExhibitHost.connect()`. +`ExhibitHost` stored that URL as `exhibitBaseUrl`; discovery called +`validateSurfaceCatalog()` and stored the surviving descriptors in `host.surfaces`. +The UI displayed descriptive surface cards only. The primary iframe alone used +`postMessageTransport`, which checked both source window and origin. +There was no separate window/frame lifecycle helper for presentation views. + +Museum Gallery's `control.html` is both its entry point and declared primary. +Only this page constructs its Contract Core. `artifact.html` and `info-wall.html` +attach through the fixture's existing BroadcastChannel bus and construct no +independent Core. NGN does not participate in that bus. SciFi is an external +reference implementation, not a source of NGN architecture. + +The working tree already contained Step 6.3 contract/fixture/test/documentation +edits, including an untracked postMessage interoperability test. They were +preserved; this task did not commit, push or modify publishing records. + +## Files changed for this step + +| File | Change | +| --- | --- | +| `src/surface-url.js` | Canonical standards-based resolver | +| `src/validation.js` | Reuse resolver; validate optional metadata types | +| `src/local-surfaces.js` | Local frame ownership, resource checks and lifecycle | +| `src/connection.js` | Establish base from final same-origin control document after navigation | +| `src/ui.js` | Descriptor-driven lifecycle controls, state and pane rendering | +| `public/index.html` | Operator explanation of local surfaces | +| `tests/local-surfaces.test.js` | 17 URL, lifecycle, failure, trust and resource tests | +| `tests/connection.test.js` | Final-document URL/navigation regression test | +| `README.md` | Current functionality and deferred scope | +| This report | Architecture, behavior and verification | +| `test-fixtures/evidence/step6.4-*.txt` | Live Museum and SciFi DOM snapshots | + +No framework, dependencies, exhibit-specific host branches or schema fields +were added. Existing CSS supplies pane sizing and button styles. + +## Resolution and trust + +`resolveSurfaceURL(value, exhibitBaseUrl)` uses `new URL()` and returns a +normalized absolute URL. Both discovery and opening use it. Sibling paths, +`./`, parent paths, query-only and fragment-only references resolve against the +supplying exhibit document, never NGN's administrative page. Absolute URLs +(including same-origin ones), protocol-relative URLs, invalid values, control +characters, surrounding whitespace and cross-origin results are rejected. +HTTP(S) and credential-free results are required. The existing static server +continues to enforce mount and filesystem containment for both control and +presentation resources. + +Individual invalid entries produce discovery diagnostics without invalidating +targets/state. The exactly-one-primary rule is applied to the surviving set. +Malformed optional metadata cannot reach the UI's string/array operations. + +Each open/reload first performs a same-origin HEAD resource check with +`redirect: 'error'`. Missing pages become surface errors; redirects are refused +before the iframe is created. Frame loads additionally check the resulting +origin. Descriptor text is inserted as text, never evaluated. No presentation +frame receives a host transport or session, and the existing postMessage +source/origin checks are unchanged. A test demonstrates that a same-origin +presentation window cannot impersonate the control peer through this transport. + +## Rendering, primary handling and lifecycle + +NGN uses inline iframe panes, one per opened descriptor ID. Nothing is +automatically opened beyond the existing control frame. A primary descriptor +whose complete normalized URL matches the control document is shown as already +open there, with separate surface actions disabled. Its lifecycle belongs to +the Connection panel. A separate primary URL is offered through the same Open +action as any other presentation view. Queries/fragments remain significant. + +States are `closed`, `loading`, `open`, `error`, and `control` (already rendered +by the authoritative frame). Open during loading/open is a no-op. Reload +replaces only the selected presentation document. Close removes it and clears +its diagnostic; Open recreates it. UI cards show labels, primary/non-primary +status, capability availability, lifecycle state and failures. + +Disconnect, session reconnect, exhibit replacement and host page departure +release owned frames and abort pending checks. Late load/check completions +cannot recreate abandoned panes. Removed/changed descriptors release their +resources on rediscovery; unchanged frames survive metadata updates. An observer +detects externally removed pane frames and updates their state to closed. + +HTTP failures, frame errors, origin failures and a 15-second load timeout stay +local to the affected surface. Reload can retry them. The authoritative control +session and synchronized host state remain intact. + +## Automated verification + +Final focused command: + +```text +node --test tests/local-surfaces.test.js tests/surface-validation.test.js tests/postmessage-interop.test.js tests/connection.test.js +80 passed, 0 failed +``` + +Coverage includes every requested relative URL form and rejection category; +real Museum descriptors loaded from its actual adapter; duplicate-open, reload, +close, reopen, disconnect/switch cleanup, pending-load cancellation, registry +refresh, distinct primary pages, malformed metadata, timeouts, external removal, +cross-origin loads, source impersonation and real HTTP success/404/redirect cases. +Lifecycle tests use the production manager and ExhibitHost with EventTarget +frames; the existing Step 6.3 suite retains the real production host and exhibit +postMessage bridges in browser-like window realms. + +Full `npm test`: **118 passed, 0 failed**, including Museum Gallery and SciFi +connection/synchronization regressions and existing Step 6.3 tests. +Node test workers required execution outside the Windows sandbox after its +initial `spawn EPERM` denial. `python devlog_editor.py --validate` passed. +`git diff --check` passed. + +## Live browser verification + +Performed in the Codex in-app browser against `npm start` at +`http://127.0.0.1:4173/`, using the ordinary operator UI: + +1. Loaded the requested full Museum `control.html` URL: connected, + synchronized, Contract 5.3, three surface descriptors. +2. Primary card reported the existing control frame and disabled duplicate Open. +3. Opened Artifact Display and Information Wall: both reported `open`, rendered + their respective page headings/content and displayed “Attached to Control Room.” +4. Reloaded Artifact Display; closed Information Wall (confirmed `closed`), + then reopened it (confirmed `open`). +5. Switched to SciFi: both Museum cards/panes disappeared; SciFi reported + connected, synchronized, Contract 5.3. +6. Reloaded Museum: both secondary cards began closed and opened cleanly. +7. Explicit Disconnect cleared all frames/catalog; Reconnect rediscovered the + closed surfaces and Artifact Display reopened successfully. +8. Loaded SciFi again after the final UI changes: connected and synchronized; + no Museum surface cards remained. + +Saved observations: + +- [Museum rendered panes](../../test-fixtures/evidence/step6.4-museum-local.txt) +- [SciFi regression](../../test-fixtures/evidence/step6.4-scifi-regression.txt) + +An early batched browser Close action did not change the page; after inspecting +the unchanged state, individual Close/Reopen actions passed, including a repeat +on the final UI. Failure injection is automated coverage, not a claim of live +missing-page or timeout testing. + +## Limitations and deferred work + +- `open` means the document loaded, not that its application scripts are healthy + or that cross-surface state synchronization has been proven. +- HEAD support is required. HTTP redirects, including same-origin redirects, + are conservatively refused for presentation resources. The local static server + supports HEAD and does not issue redirects. +- These are trusted same-origin exhibit pages under the existing trust model, + not a sandbox for hostile exhibit code. A page's later script-driven navigation + can only be detected at load; resource checks do not certify application code. +- Museum's existing initial shared-state attachment was observed. Its Control + Room counter/footer text did not consistently reflect that attachment. No + fixture behavior was changed; mutation propagation proof remains **Step 6.5**. +- No remote/cast display assignment, device discovery, networking clients, + fullscreen orchestration, SciFi Observation adaptation or synchronization + architecture was introduced. + +All Step 6.4 acceptance categories pass. Step 6.5 has not been implemented. diff --git a/docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md b/docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md new file mode 100644 index 0000000..2783d8c --- /dev/null +++ b/docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md @@ -0,0 +1,945 @@ +# Step 6.7A — SciFi Observation Surface Architecture + +**Status:** design only. No implementation performed. No source files modified. +**Scope:** adapting SciFi-XZBT Observation to the already-proven Contract 5.3 +local presentation-surface model (Steps 6.3–6.6). +**Audience:** the implementation model executing Step 6.7B. + +### Repositories inspected + +| Repo | Path | Role | +| --- | --- | --- | +| XZBT-NGN | `G:/.vibe/XZBT-NGN` | Host engine, contract spec, test fixtures | +| SciFi-XZBT | `G:/.vibe/SciFi-XZBT` | **The real exhibit source.** Step 6.7B edits here. | + +`test-fixtures/reference-exhibits/scifi/` inside XZBT-NGN is a *copy* of +SciFi-XZBT, kept for NGN's own tests (`test-fixtures/PROVENANCE.md`). Step 6.7B +must change SciFi-XZBT and then resync the fixture. Both working trees carry +uncommitted work; nothing was modified by this task. + +--- + +## 1. Recommendation + +**Adopt Candidate A in its narrowest form: one document, one script set, two boot +modes.** SciFi advertises exactly two surfaces. The primary is the existing +`index.html` (unchanged, the full console). The Observation surface is +*the same document* re-served with a query string — `?surface=observation&xi=` +— which Contract 5.3 §31.4 explicitly provides for ("a bare query string and/or +fragment, resolving against the exhibit's own base document, for a single-page +exhibit whose surfaces are views within one already-served document"). A boot-mode +resolver read at the top of `app.js` decides which mode the instance runs in. In +Observation mode the instance constructs no audio graph, no generative engine, no +contract host bridge, and no ambient event scheduler; it renders the existing +`#observation-overlay` and mirrors state from the authoritative console instance +over an exhibit-internal, instance-scoped `BroadcastChannel`. + +This is the only candidate that survives contact with the actual code. Roughly +3,230 of `app.js`'s 4,703 lines (lines ~1261–4494) are the Observation renderer, +and they live inside `app.js`'s single IIFE closure, reading `activeUniverseId`, +`activePresetId`, `isPlaying`, `observationActivity`, `UniverseRegistry`, +`observationEngine`, `alerts` and `audioManager` as free variables. +`observation-engine.js` additionally reads five *console-panel* element IDs +(`toggle-observation-viewport`, `val-observation-viewport`, +`toggle-observation-pillars`, `val-observation-pillars`, and the preset select) +that do not exist outside the console document. Keeping one document means **zero +renderer extraction and zero DOM decoupling**: every element the renderer reaches +for is still present, merely hidden by a body class. A dedicated lightweight page +(Candidate B) would require lifting that entire closure into a shared module — +the single largest and riskiest refactor available in this project — and would +still not fit the distribution, because `tools/package.ps1` inlines exactly one +input document and emits exactly one output file. A second HTML document simply +cannot exist in the standalone single-file build. + +The second load-bearing decision: **surface existence is not `view.observation`.** +`view.observation` keeps its current meaning exactly — the console page's own +full-screen overlay — and opening or closing the NGN Observation pane never +touches it. Conflating them would black out the operator's console the moment +they opened the presentation pane, which is precisely the simultaneity failure +Candidate C is rejected for. The Observation surface renders the scene +unconditionally while it is open; that is its whole purpose and it needs no state +flag to say so. + +--- + +## 2. Current SciFi state architecture + +### 2.1 Where state actually lives + +`app.js` is one `DOMContentLoaded` IIFE. There is no state object. Authority is +distributed across subsystem instances and three closure variables, and +`getContractStateSnapshot()` (app.js:4666) is the one function that composes them +into the contract-visible view. + +| Contract state | Authoritative owner | Read path | +| --- | --- | --- | +| `universe.selected` | `let activeUniverseId` (app.js:29) | closure var | +| `preset.selected` | `let activePresetId` (app.js:30) | closure var | +| `transport.playing` | `let isPlaying` (app.js:28) | closure var | +| `transport.muted`, `mix.master` | `AudioManager` | `audioManager.isMuted` / `.getMasterVolume()` | +| `mix.hull.*` | `HullDroneSynth.params` | `hullDrone.params.*` | +| `mix.drive.*` | `WarpCoreSynth.params` | `warpCore.params.*` | +| `mix.environment.*` | `LifeSupportSynth.params` | `lifeSupport.params.*` | +| `mix.telemetry.*` | `TelemetrySynth.params` | `telemetry.params.*` | +| `fnc.enabled`, `fnc.level` | `FuturisticNoiceCancellation` | `fncSystem.enabled` / `.level` | +| `speech.robot-amount` | `XZBTGenerativeExperience` | `generativeExperience.roboticAmount()` | +| `alert.active` | `AlertSynth.activeAlert` | `alerts.activeAlert` | +| `view.observation` | `let observationActive` (app.js:1281) | closure var | +| `view.activity` | `let observationActivity` (app.js:1282) | closure var | +| `view.viewport-frame` | `ObservationEngine.showViewport` | `observationEngine.showViewport` | +| **`showPillars`** *(no target)* | `ObservationEngine.showPillars` | — | +| **warp flight** *(no target)* | `ObservationEngine` internal | — | +| **HUD hold** *(no target)* | console checkbox DOM only | — | + +**Canonical vs reflected.** The subsystem/closure values above are canonical. All +DOM is *reflected*: every setter in the adapter's `bindings.setters` writes the +subsystem first and then assigns `slider.value` / `label.textContent`. There is +exactly one genuine exception — **HUD hold** is stored nowhere but +`#toggle-observation-hud.checked`, which is why it is treated as surface-local in +§7 rather than promoted to a target. + +**Observation does not own its own mutation path.** `observationEngine.onMutation` +(app.js:259) routes every dock interaction straight into +`contractAdapter.applyMutation(...)` with source `'ui'` — except `togglePillars()` +and `toggleWarpFlight()`, which mutate engine-local fields directly because no +canonical target exists for them (see §11). + +**The adapter reads the same authority.** `bindings.getState` is literally +`() => getContractStateSnapshot()`. There is no second state computation anywhere. + +### 2.2 Flow diagram (actual) + +```text + NGN (postMessage) console UI hotkeys Observation dock + | | | | + | | | | observationEngine.onMutation + v v v v + adapter.handleMessage +------------+---------------+ + | | + v v + XZBTControlBus (intercepted) --> contractAdapter.applyMutation / invokeAction + | [ THE one chokepoint ] + v + bindings.setters[id] / invokers[id] + | + +-------------------+-----------+-----------+-------------------+ + v v v v + audio subsystems closure vars ObservationEngine reflected DOM + (AudioManager, (activeUniverseId, (showViewport, canvas (sliders, labels, + synths, FNC, activePresetId, scene, bezels) dock button text) + alerts) isPlaying, + observationActive, + observationActivity) + | | | + +---------+---------+-----------------------+ + v + _commitChanges() -> getContractStateSnapshot() diff + | + +--> stateRevision++ , state.changed / selection.changed + +--> _emitEvent(...) [ GATED on sessionActive ] + | + v + NGN host session +``` + +**The gate marked above is critical for 6.7B.** `_emitEvent` begins +`if (!this.sessionActive) return;` (contract-adapter.js:596). With no NGN attached, +**no events are emitted at all.** A mirror built on contract events would therefore +go dead in standalone multi-window use. §6.4 specifies the fix. + +### 2.3 Observation entry/exit today + +- `enterObservation()` (app.js:4443) — calls + `generativeExperience.prepareExperience()` **(the WebLLM/Kokoro loader)**, sets + `observationActive = true`, adds `.active` to `#observation-overlay`, calls + `refreshObservation()`, starts the HUD/return fade timers, and calls + `scheduleObservationAmbientActivity()`. +- `exitObservation()` (app.js:4460) — stops the engine, the ambient timer, live + instrumentation, spline morphs and the scene loop, then clears the stage markup. +- `refreshObservation()` (app.js:4422) — injects `getObservationSvg(activeUniverseId)`, + starts the scene and instrumentation, and calls `observationEngine.start(activeUniverseId)`. +- Every entry/exit path (button app.js:4502, background click 4508, return pill 4517, + Escape 4574) goes through `applyMutation('view.observation', …)`. Nothing toggles + `observationActive` directly. This is already clean. + +### 2.4 Assets, packaging and existing surface concepts + +- `index.html` loads one stylesheet and ten scripts in a **fixed order**, all + classic globals. `agents.md` forbids ES modules, dependencies and network access + outside the one bounded `generative-experience.js` exception. +- `tools/package.ps1` walks `index.html` line by line and inlines any + `` or `` that matches + its exact regex, emitting **one** file, `dist/SciFiAmbientDisplay_V.html`. + One input document, one output document. There is no other build step. +- **No existing surface concept.** `describe()` (contract-adapter.js:854) returns + `exhibit / contract / registryRevision / stateRevision / capabilities / targets` + and no `surfaces` field. SciFi is currently a conformant Contract 5.3 exhibit in + §31.3 form 1 ("absent"). +- The nearest adjacent concept is the **TV PRESENTATION** card (16:9 aspect lock, + render density). It is console-local presentation tuning, not a surface, and + Step 6.7 should not disturb it. +- **No Step 6.7 planning notes exist** in either repo. The only forward references + are `STEP5-MVP-COMPLETION-REPORT.md:234` (the step list) and + `docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md:393`, which names §7.2/§8 + as "the reusable template Phase 6.7 adapts" — that template is the attachment + sequence this design follows. + +--- + +## 3. Candidate comparison + +| | **A — same document, surface mode** | B — dedicated lightweight page | C — reuse control page/overlay | +| --- | --- | --- | --- | +| Architectural cleanliness | Good. One app, one code path, mode is a boot input. | Cleanest *in theory*; unreachable in practice. | Poor. Conflates host UI with exhibit presentation. | +| Renderer refactoring | **None.** All ~3,230 renderer lines and all DOM stay put. | Extract the renderer from `app.js`'s closure; decouple 5 console-panel element reads in `observation-engine.js`. Very large. | None. | +| Standalone behavior | Untouched. `index.html` with no query is byte-for-byte today's app. | Untouched, but the new page has no place in the single-file build. | Untouched. | +| Duplicate initialization | Controlled: mode guard skips audio, AI, adapter bridge, visualizer, hotkeys, timers. | Structurally impossible to duplicate. | N/A. | +| State authority | Single. Presentation instance never calls `applyMutation` locally. | Single. | Single, but only one view can exist. | +| Sync complexity | One instance-scoped BroadcastChannel; Museum's proven shape. | Same. | None. | +| Contract purity | Exactly the §31.4 query-string form the spec provides for. | Fine. | Fine, but defeats §31.1's "independently viewable". | +| NGN genericity | No NGN change. | No NGN change. | Would tempt NGN into exhibit-specific pane handling. | +| Future cast suitability | Good — the surface is already a standalone URL with its own viewport. | Best (smallest payload), marginally. | Bad. | +| Packaging | `package.ps1` unchanged; two new inlined scripts. | **Breaks.** Second document cannot be packaged. | Unchanged. | +| Implementation risk | Moderate, concentrated in `app.js` mode branching. | High. | Low, but wrong. | + +### Rejections + +- **B — Dedicated lightweight Observation surface.** Rejected on two independent + grounds, either sufficient. (i) *Packaging:* `tools/package.ps1` produces a single + self-contained document; a second HTML page has nowhere to go in the offline + deliverable, so SciFi would fork into two maintained applications — explicitly + forbidden by the brief. (ii) *Cost and risk:* it requires lifting ~3,230 lines out + of a shared closure that `agents.md` marks "surgical, scoped edits — do not + rewrite", plus decoupling `observation-engine.js` from five console-panel element + IDs. The payoff is a lighter payload; but under Candidate A the presentation + instance already parses the same bytes the browser has cached and *runs* almost + none of the heavy subsystems, so the real saving is parse time, not runtime. Not + worth the refactor. Revisit only if remote casting later demands a minimal payload. +- **C — Reuse the existing page / overlay.** Rejected: it makes simultaneous + control and observation impossible, which is the entire point of the multi-surface + model (§31.1 "independently viewable"); NGN would end up manipulating exhibit DOM, + breaking the generic boundary Step 6.4 established; and it has no path to casting. +- **D — Better architecture found in the repository.** None found. The repository + does, however, supply two refinements adopted into A: the §31.4 query-string + surface form, and Museum Gallery's attach/snapshot/mutate/detach bus — improved + here with an instance-scoped channel name (§6.2). + +--- + +## 4. Surface catalog + +Two surfaces. No more are justified: the console is one coherent operator view, +and Observation is the one thing an audience is meant to look at. Adding +per-universe or per-panel surfaces now would be speculative. + +```js +// js/surface-mode.js — instanceId is the live per-document id (see §6.2) +function SURFACES(instanceId) { + return [ + { + id: 'surface.console', + label: 'Main Console', + kind: 'surface', + primary: true, + url: 'index.html', + role: 'control', + category: 'console', + description: 'The full SciFi-XZBT operator console.' + }, + { + id: 'surface.observation', + label: 'Observation', + kind: 'surface', + primary: false, + url: '?surface=observation&xi=' + encodeURIComponent(instanceId), + role: 'ambient', + category: 'observation', + aspectRatio: '16:9', + requires: ['observation'], + description: 'Full-screen procedural viewscreen mirroring the console.' + } + ]; +} +``` + +Schema notes, all verified against §31.2 and `src/validation.js`: + +- `id` uses the canonical target-ID grammar (§8.1) and matches Museum's + `surface.control` / `surface.info-wall` convention. +- `kind: 'surface'` is the required constant. Exactly one `primary: true`. +- Only documented optional fields are used: `description`, `role`, `aspectRatio`, + `category`, `requires`. **No invented fields.** No field names a transport, + endpoint or device class (§31.9). +- `requires: ['observation']` reuses the adapter's already-declared `observation` + capability (contract-adapter.js:52). + +### Why `url: 'index.html'` for the primary + +`src/local-surfaces.js:35` marks a descriptor as the already-open control pane +with **full URL equality** against `host.exhibitBaseUrl`, and +`src/connection.js` sets that base from `frame.contentWindow.location.href` after +load. Connecting NGN to `…/scifi/index.html` — the URL already used by +`README.md:54` and the Step 6.6 procedure — makes `index.html` resolve to exactly +the base, so the primary reuses the existing session frame and is never opened a +second time. See risk **R1** for the trailing-slash case. + +### Why the Observation `url` is a bare query + +§31.4 sanctions it for single-page exhibits, `resolveSurfaceURL` accepts it +(relative, same-origin, no scheme), and `server/serve.js` routes on `url.pathname` +only, so the `HEAD` resource check in `checkSurfaceResource` returns 200. No new +file is served and `package.ps1` never sees a second document. + +--- + +## 5. Authority and synchronization + +### 5.1 Ownership rule + +> The instance whose resolved mode is `console` is the sole authority. It owns +> every subsystem, the one contract session, and the one mutation chokepoint. An +> instance whose mode is `observation` owns **nothing**: it holds a read-only +> mirror and may only *request* mutations. + +The presentation instance never calls `contractAdapter.applyMutation`, +`invokeAction`, or any `bindings.setter`. It never constructs a contract session: +it does not install the window message bridge, so a stray `hello` cannot reach it, +and NGN never offers one — `LocalSurfaces` attaches no transport to surface frames +(`src/local-surfaces.js`, and `tests/local-surfaces.test.js:232` asserts a +presentation window cannot impersonate an authoritative peer). + +### 5.2 Mechanism: instance-scoped BroadcastChannel + +Chosen mechanism: `BroadcastChannel`, named +`xzbt-scifi-surface-v1:`, with the console document as owner — +Museum Gallery's proven shape (`surface-bus.js`), with one deliberate improvement. + +Evaluated and rejected: + +- **Explicit parent/child `postMessage`.** *Structurally impossible here.* Under + NGN the console document and the Observation iframe are **siblings** inside the + NGN page, not parent and child. Neither holds a window handle to the other. + This alone decides the mechanism. +- **`storage` events / same-origin storage.** No request/response, so late join + needs a polled or mirrored snapshot key; fires only in *other* documents; + serializes on every change. Strictly worse. +- **`SharedWorker`.** Extra lifecycle and an unsupported-context risk, for no + benefit over BroadcastChannel. +- **Extending `XZBTControlBus`.** It is an in-document registry with no transport; + extending it means writing one of the above anyway. Keep it as-is. + +**The improvement over Museum: the channel name carries an instance id.** Museum +uses one fixed channel name, which means two independent Museum tabs on one origin +would cross-talk. For SciFi that matters more: two console tabs is a normal thing +to do. The console mints `instanceId` once per document load, publishes it inside +the Observation surface URL (which `describe()` produces at call time, so it is +always current), and the presentation instance reads it from its own query string. +A surface with an unknown or stale id finds no owner and lands in the waiting +state rather than attaching to the wrong console. + +### 5.3 Message shapes (exhibit-internal; never seen by a host) + +``` +surface -> owner : { type:'attach', requestId, participantId } +owner -> surface : { type:'attach.snapshot', inReplyTo, values, stateRevision, + registryRevision, presentation:{ tickerText } } +owner -> all : { type:'state', target, value, stateRevision } +owner -> all : { type:'action', target, args } +owner -> all : { type:'presentation', kind:'ticker'|'obs-activity', … } +surface -> owner : { type:'mutate', kind:'set'|'invoke', target, value|args } +surface -> owner : { type:'detach', participantId } +``` + +This carries no XZBT envelope, no `xzbt` field, no `sessionId`, and is never +observed by NGN — §31.7's explicit allowance. Participant bookkeeping is a +`Set` of `participantId`s, not a counter, so duplicate attach or late detach +cannot ratchet the count (Museum's lesson; see its participant-lifecycle tests). + +### 5.4 The event-gate correction (mandatory) + +Because `_emitEvent` early-returns unless `sessionActive`, the mirror **must not** +be fed from contract events. Add one exhibit-internal hook to the adapter, +independent of session state: + +```js +// contract-adapter.js, inside _commitChanges(), after the event loop: +if (this.onLocalChange) { + for (const target of changes) { + this.onLocalChange(target.id, after[target.id], this.stateRevision); + } +} +// and in _invokeAction(), after _commitChanges: +if (this.onLocalAction) this.onLocalAction(canonicalId, sanitizedArgs); +``` + +Contract event semantics are untouched — a host still only sees events inside a +session. The mirror gets a session-independent feed, so Observation works with or +without NGN attached. + +### 5.5 Guarantees + +- **One authority:** enforced structurally — the presentation branch never + constructs the mutation path's callers. +- **Late join:** `attach` → owner replies with the live + `contractAdapter.getContractState()` plus transient presentation extras. +- **Reload:** a reload is a fresh `attach` with a fresh `participantId`; identical + path to first join. +- **Prompt propagation:** `onLocalChange` fires inside `_commitChanges`, i.e. in + the same turn as the mutation. +- **Surface interactions:** routed as `mutate` and executed by the owner through + `contractAdapter.applyMutation(..., 'ui')` — the same chokepoint NGN's `set` + uses, satisfying §31.7's mutation/revision/source/event requirement. +- **No NGN-specific logic:** the bus is unaware NGN exists. + +--- + +## 6. Audio / AI / procedural subsystem ownership + +Audio is already safe by construction: `AudioManager`'s constructor +(`audio.js`) sets `this.ctx = null` and creates nothing; the `AudioContext` and +every node appear only in `init()`, reached only via `resume()`. So a presentation +instance that never engages transport never creates an audio graph. The dangerous +paths are the ones that *call* `resume()` or `prepareExperience()`. + +| Subsystem | Authoritative console | Observation surface | Note | +| --- | --- | --- | --- | +| `AudioManager` / `AudioContext` | **Yes** | Constructed, **never `init()`/`resume()`** | Needed only to satisfy `ObservationEngine`'s constructor arity. | +| Hull / Warp / LifeSupport / Telemetry / Alert / Whataverse / Expanded / EngineTransition synths | **Yes** | Constructed, silent | Same reason; they create nodes only on play. | +| `FuturisticNoiceCancellation` | **Yes** | Constructed, inert | Do not call `setEnabled`/`setLevel` locally. | +| `XZBTGenerativeExperience` (WebLLM + Kokoro) | **Yes** | **Not constructed. Not bound. Never prepared.** | `enterObservation()` calls `prepareExperience()` — **this call must be skipped in presentation mode**, or every opened pane downloads a 1.7B model. Highest-value guard in this design. | +| `XZBTContractAdapter` + window bridge | **Yes — the one session** | **Not constructed** | Guarantees no second contract session. | +| `XZBTControlBus` | **Yes** | Not constructed | | +| `StarshipVisualizer` (spectrum, warp core) | **Yes** | Not constructed | Its canvases are console-panel only. | +| `CoreAnimations` | **Yes** | Not constructed | | +| Sleep timer | **Yes** | Not constructed | | +| Hotkey handler (`window keydown`) | **Yes** | **Not installed** | A keystroke on the pane must not mutate state; see §7. | +| Ambient activity scheduler (`scheduleObservationAmbientActivity`) | **Yes — authority only** | **Disabled**; renders broadcast events | Prevents duplicate random event generation. | +| `triggerObservationActivity` | Decides + broadcasts | Renders on receipt | | +| AI announcement / ticker text | **Yes** (`onGenerated`) | Renders relayed text | Relayed as `presentation/ticker`; announcement text is not contract state. | +| `ObservationEngine` (canvas, bezels, waveform) | Yes (when overlay active) | **Yes — renderer only** | `am.analyser` guards already exist (`observation-engine.js:2262, 3097`). | +| Observation SVG scene, spline morphs, scene entity loop | Yes (when overlay active) | **Yes — renderer only** | Deterministic from universe + preset + activity. | +| Live instrumentation digit churn (`observationInstrumentTick`) | Yes | **Yes — local, deliberately** | See below. | +| `ObservationBezels` | Yes | Yes | Pure SVG generation. | +| Web3D / external visual deps | None exist | None | SciFi is canvas + SVG only. | + +### Two judgment calls, stated explicitly + +**1. Instrumentation digit churn stays local.** `observationInstrumentTick` mutates +cosmetic numerals in the HUD text. It is high-frequency, carries no narrative or +contract meaning, and mirroring it would need a continuous high-rate channel for +nothing. Two screens showing different filler digits is invisible. This is a +deliberate, bounded divergence — and it is the *only* one. + +**2. Transient activity events are mirrored, not regenerated.** By contrast, +`triggerObservationActivity` spawns visible narrative beats (contacts, expanding +rings, text cards). Regenerating them independently would give two screens +different events and would violate the brief's "no duplicate random event +generation". The authority decides; the surface renders. + +### The audience-scheduler correction (mandatory) + +`triggerObservationActivity` begins `if (!observationActive …) return;` and +`scheduleObservationAmbientActivity` refuses to schedule unless `observationActive`. +So with the console in normal mode and only the NGN Observation pane open, +**nothing would ever be generated** — the surface would render a static scene. +6.7B must decouple the scheduler from local overlay visibility: + +```js +const observationAudienceActive = () => observationActive || surfaceOwner.attachedCount() > 0; +``` + +Use that predicate for *scheduling and deciding* activity; keep `observationActive` +for *local rendering* only. The activity decision is then broadcast, and the +console renders it locally only when its own overlay is up. + +### Audio-reactive fidelity (accepted limitation) + +With no audio graph, `am.analyser` is null on the surface, so the waveform sill +and the audio-reactive modulation read zero. `ObservationEngine` already guards +this and degrades to a flat trace; the scene itself is procedural and unaffected. +Mirroring a scalar audio level is a reasonable future refinement and is **out of +scope for 6.7**. + +--- + +## 7. Observation interaction policy + +**Policy: minimally interactive — every retained control is a mutation request to +the authority; nothing is applied locally.** + +Strict read-only would be a regression (the dock already exists and is genuinely +useful on a second screen). Fully interactive would mean installing hotkeys and +local setters on a non-authoritative document. The middle is both correct and +already half-built: four of the six dock controls *already* route through +`observationEngine.onMutation`. + +| Control (`index.html`) | Decision | Mechanism | +| --- | --- | --- | +| `#obs-btn-warp` (WARP) | **Route** | Needs a new target — `view.warp-flight`. Today `toggleWarpFlight()` mutates engine-local state and would diverge. | +| `#obs-btn-frame` (VIEWPORT) | **Route** | Existing `view.viewport-frame`; already routed. | +| `#obs-btn-pillars` (PILLARS) | **Route** | Needs a new target — `view.pillars`. Today `togglePillars()` is engine-local and would diverge. | +| `#obs-btn-alert` (RED ALERT) | **Route** | Existing `alert.active`; already routed. | +| `#obs-select-preset` (PRESET) | **Route** | Existing `preset.selected`; already routed. | +| `#obs-btn-exit` (RETURN ✕) | **Remove** on the surface | Hidden by CSS. Closing a pane is NGN's job; posting `view.observation=false` from the pane would toggle the *console's* overlay — a confusing cross-surface side effect. | +| Background click-to-exit (app.js:4508) | **Remove** on the surface | Same reason; also makes the pane fragile to stray clicks. | +| `#observation-return-pill` | **Remove** on the surface | Same. | +| HUD HOLD (`#toggle-observation-hud`) | **Surface-local** | A per-screen fade preference, stored only in the checkbox. §31.7 explicitly leaves purely presentational surface-local interaction outside the contract. | +| ACTIVITY slider (console panel) | Console-only | Existing `view.activity`; the surface follows it. | +| Hotkeys | **Not installed** on the surface | Would be a local mutation path. | + +Removal is by CSS on the body class, so `observation-engine.js` needs no change — +it may keep binding listeners to elements the user can never reach. + +--- + +## 8. Observation lifecycle semantics + +Four concepts, deliberately kept distinct: + +| Concept | Owner | Meaning | Changed by | +| --- | --- | --- | --- | +| **Surface lifecycle** | NGN (`LocalSurfaces`) | Whether an Observation pane exists and is loaded | Operator clicking Open/Close/Reload in NGN | +| **`view.observation`** | SciFi console instance | Whether the **console page's own** full-screen overlay is up | WATCH EXPERIENCE, background click, Escape, contract `set` | +| **Fullscreen state** | Browser | Whether a document occupies the screen | User/OS; SciFi does not drive it | +| **Local overlay CSS state** | Each document | `.active` on `#observation-overlay` | Derived from the two above, per document | + +### The explicit answer + +> **Opening the secondary surface does NOT imply `view.observation = true`.** +> "A surface exists" and "Observation mode is active" are separate facts. + +Rationale: `view.observation` is declared `restorable: false` and is scoped to the +console's own presentation. If opening the pane set it true, the operator's console +would black out into its overlay the instant they opened the pane — destroying +simultaneous control and observation, the whole point of §31.1. Conversely, +closing the pane must not set it false, or an operator using the console overlay +would lose it when tidying up NGN panes. Contract 5.3 is additive; no existing +target may be reinterpreted (`AGENTS.md`), and this keeps `view.observation`'s +meaning exactly as it is today. + +Consequences, all of which 6.7B must honor: + +- The Observation **surface renders the scene whenever it is open**, with no state + flag gating it. It is an Observation surface; that is what it is for. +- **WATCH EXPERIENCE is unchanged** — same button, same target, same local overlay. +- A host `set view.observation true` still raises the *console's* overlay and is + reflected on the surface only as a state readout. It never opens or closes a pane; + NGN owns pane lifecycle and Contract 5.3 gives an exhibit no way to ask for one. +- Closing the pane detaches cleanly: `window.__xzbtSurfaceDispose` (called + synchronously by `local-surfaces.js` before frame removal) plus a `beforeunload` + fallback, both idempotent. The owner removes the participant; **no state mutates.** +- When the last participant detaches and the console overlay is down, + `observationAudienceActive()` goes false and the ambient scheduler stops — the + symmetric counterpart of §6's correction. + +--- + +## 9. Standalone behavior + +Unconditionally preserved, and mostly by construction. + +- **`index.html` opened with no query string is byte-for-byte today's application.** + The mode resolver returns `console` for the absence of `?surface=`, and the + console path is the existing code path. No behavior is gated on NGN. +- **Host attachment is not part of startup.** It is not today (the adapter only + installs a passive `message` listener) and must not become so. No `hello` is + ever initiated by SciFi. +- **In-page Observation is retained exactly.** WATCH EXPERIENCE, Escape, + background click, the dock, and the hotkeys all keep working with no NGN, no + surface, and no bus participant. +- **The renderer is shared, not duplicated.** Both modes run the same + `app.js`/`observation-engine.js` code; only the boot mode differs. There is no + second implementation to drift. +- **`?surface=observation` is directly usable without NGN** (§31.8's SHOULD): open + it in a second browser window over `http(s)` and it attaches to the console tab + on the same origin. With no console open it shows a waiting state and never + invents its own authority — Museum's proven behavior. +- **Single-file build:** opening `SciFiAmbientDisplay_V.html` normally is + unchanged. Under `file://`, `BroadcastChannel` is scoped to an opaque origin, so + the two-window pairing is **not** promised there; the single-file build's + Observation remains the in-page overlay. That is the correct guarantee, and it is + exactly what §31.8 requires (primary unconditional, non-primary best-effort). + +--- + +## 10. Packaging approach + +**No second application, no second document, no build change.** + +| Concern | Impact | +| --- | --- | +| Source files | +2 small JS files (`js/surface-mode.js`, `js/surface-bus.js`); edits to `index.html`, `js/app.js`, `js/contract-adapter.js`, `css/style.css`. | +| Generated HTML | Still one file, `dist/SciFiAmbientDisplay_V.html`. | +| Optional secondary page | **None. Deliberately.** The Observation surface is a query-string view of the same document (§31.4). | +| Shared scripts | 100% shared — the surface *is* the same script set. Divergence is structurally impossible. | +| Build process | `tools/package.ps1` unchanged. The two new tags are inlined automatically **only if written in the exact form** `` — the regex is whitespace- and quote-sensitive and fails silently otherwise (`agents.md`, "Adding a new JS file"). | +| Offline behavior | Unchanged. Nothing new touches the network. `BroadcastChannel` is a browser primitive with no dependency. | +| Future embedded offline package | Unaffected — still one artifact. | +| Resource duplication | The surface re-parses the same cached bytes and constructs the inert subsystem shells; it downloads nothing new and runs no second engine. | + +Script order in `index.html` — insert **after `control-bus.js`, before +`generative-experience.js`**, so the adapter and `app.js` can see both globals: + +``` +audio.js → config.js → core-animations.js → visualizer.js → +observation-bezels.js → observation-engine.js → control-bus.js → +surface-mode.js → surface-bus.js → +generative-experience.js → contract-adapter.js → app.js +``` + +--- + +## 11. Contract adapter changes + +All in `js/contract-adapter.js`. Additive only. No change to the handshake, the +envelope, message types, or generic semantics. + +**1. Surface catalog.** Accept `options.surfaces` (an array) and +`options.instanceId`; store them. Emit `surfaces` from `describe()` **only when +non-empty**, so absence stays §31.3 form 1: + +```js +describe() { + const description = { exhibit: {...}, contract: {...}, registryRevision, stateRevision, + capabilities: [...], targets: [...] }; + if (this.surfaces && this.surfaces.length) description.surfaces = this.surfaces; + return description; +} +``` + +The array is a plain static declaration with exactly one `primary: true`. +Host-side §31.3 validation is NGN's job, not the exhibit's; do not reimplement it. + +**2. Two missing absolute state targets.** Required for deterministic presentation +sync — without them, PILLARS and WARP diverge between console and surface, and a +host cannot read or restore them at all: + +```js +reg({ id: 'view.pillars', legacyId: 'observation-pillars', + label: 'Window Pillars / Mullions', kind: 'state', valueType: 'boolean', + readable: true, writable: true, restorable: true, + category: 'observation', requires: ['observation'] }); + +reg({ id: 'view.warp-flight', + label: 'Warp Flight Mode', kind: 'state', valueType: 'boolean', + readable: true, writable: true, restorable: true, + category: 'observation', requires: ['observation'] }); +``` + +Permitted by §28.7 (additive targets). Wire matching entries in +`bindings.setters` (→ `observationEngine.setPillars` / `setWarpFlight`) and in +`getContractStateSnapshot()` (→ `observationEngine.showPillars` and the engine's +warp-flight field — confirm its exact name when implementing). `registryRevision` +stays `1`; this is a new build, not a runtime registry change, so no +`registry.changed` is emitted. + +**3. Session-independent local hooks.** Add `onLocalChange` / `onLocalAction` as +described in §5.4. This is the only structural addition and it changes no +contract-visible behavior. + +**4. Identity.** `app.js:126` passes `version: '5.2.0'`; bump to `'5.3.0'` to +match the adapter's declared `contractMinor = 3` and this build's surface support. +Advisory metadata only. + +**No second command protocol.** Surface-originated interactions reuse `set` / +`invoke` semantics through `applyMutation` / `invokeAction`. **No capability +change** — `observation` is already declared `ready`. + +--- + +## 12. NGN changes + +### None. + +Verified against the current source rather than assumed: + +- **Query-string surface URLs already work.** `src/surface-url.js` accepts a + relative reference with a query and rejects only schemes, protocol-relative and + cross-origin values. `tests/local-surfaces.test.js:153` is literally + *"a separate primary page and query/fragment views are rendered from generic + descriptors"* — the case SciFi needs is already covered. +- **The resource check passes.** `server/serve.js` routes on `url.pathname` and + ignores the query, so `HEAD index.html?surface=observation` returns 200 and + `checkSurfaceResource` is satisfied. +- **Primary reuse already works.** `src/local-surfaces.js:35` compares full URLs + and marks the matching primary as the control pane, so it is never re-opened. + `tests/local-surfaces.test.js:54` covers it with Museum's real descriptors. +- **Lifecycle already works.** `release()` calls `__xzbtSurfaceDispose` synchronously + before removing a frame (`src/local-surfaces.js:51`); SciFi implements the hook. +- **Catalog validation already works.** `host.js:154` runs `validateSurfaceCatalog` + against §31.3 ordering, exercised by `tests/museum-gallery.test.js:172–253`. + +If a genuine NGN gap emerges during 6.7B, it must be fixed **generically** and +justified against Museum Gallery; a SciFi-specific branch is not an acceptable +outcome (`AGENTS.md`: "Do not infer XZBT-NGN behavior from SciFi-XZBT source code"). + +--- + +## 13. File-by-file implementation plan + +### A. SciFi-XZBT — `G:/.vibe/SciFi-XZBT` (the real exhibit) + +| # | File | Why it changes | Responsibility added | Must not change | +| --- | --- | --- | --- | --- | +| A1 | `js/surface-mode.js` **(new, ~60 lines)** | A pure, testable seam for mode resolution and the catalog | `XZBTSurfaceMode.resolve(locationLike) → { mode, instanceId }`; `XZBTSurfaceMode.newInstanceId()`; `XZBTSurfaceMode.SURFACES(instanceId)`; `XZBTSurfaceMode.channelName(instanceId)` | No DOM, no globals beyond `window.XZBTSurfaceMode`, no imports. Unknown `?surface=` values resolve to `console`. | +| A2 | `js/surface-bus.js` **(new, ~180 lines)** | Exhibit-internal state mirror | `createOwner(adapter, { channelName, onParticipantsChanged })` and `attach({ channelName, timeoutMs, onSnapshot, onChange, onAction, onPresentation, onTimeout })`, plus idempotent `detach()` and `mutate(kind, target, valueOrArgs)` | Carries no XZBT envelope. `Set`-based participant bookkeeping (not a counter). Owner routes every `mutate` through `adapter.applyMutation/invokeAction` with source `'ui'` — never a second computation of state. | +| A3 | `index.html` | Load the two new files | Two ` + + diff --git a/test-fixtures/reference-exhibits/scifi/js/app.js b/test-fixtures/reference-exhibits/scifi/js/app.js index 652be11..7d4f287 100644 --- a/test-fixtures/reference-exhibits/scifi/js/app.js +++ b/test-fixtures/reference-exhibits/scifi/js/app.js @@ -5,6 +5,20 @@ */ document.addEventListener('DOMContentLoaded', () => { + // Step 6.7B: boot-mode resolver (Contract 5.3 SS31.4 query-string surface + // form). 'console' is today's application, byte-for-byte -- the resolver + // returns 'console' for no query string and for any unrecognized + // ?surface= value. 'observation' renders only the Observation viewscreen + // and mirrors an authoritative console instance over an exhibit-internal + // BroadcastChannel; it never constructs a second contract session, audio + // graph or generative engine (see the isConsoleMode guards below). + const xzbtSurface = XZBTSurfaceMode.resolve(window.location); + const isConsoleMode = xzbtSurface.mode !== 'observation'; + const xzbtInstanceId = isConsoleMode ? XZBTSurfaceMode.newInstanceId() : xzbtSurface.instanceId; + if (!isConsoleMode) document.body.classList.add('surface-observation'); + let surfaceOwner = null; // console mode: bus owner, feeds attached surfaces + let surfaceLink = null; // observation mode: bus attachment to the console + // 1. Initialize Audio Subsystems const audioManager = new AudioManager(); const fncSystem = new FuturisticNoiceCancellation(audioManager); @@ -21,7 +35,9 @@ document.addEventListener('DOMContentLoaded', () => { window.expandedAudio = expandedAudio; window.whataverseAudio = whataverseAudio; window.engineTransitions = engineTransitions; - const visualizer = new StarshipVisualizer(audioManager, warpCore); + // Step 6.7B: console-only. Its canvases (spectrum, warp core) are + // console-panel elements; never construct it on the Observation surface. + const visualizer = isConsoleMode ? new StarshipVisualizer(audioManager, warpCore) : null; const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport); window.observationEngine = observationEngine; @@ -97,34 +113,55 @@ document.addEventListener('DOMContentLoaded', () => { const timerDisplay = document.getElementById('timer-display'); const timerButtons = document.querySelectorAll('.btn-timer'); - // XZBT Generative Experience subsystem. Deterministic engine remains authoritative. - const generativeExperience = new XZBTGenerativeExperience({ - getUniverse: () => UniverseRegistry[activeUniverseId], - getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null, - isPlaying: () => isPlaying, - isObservation: () => observationActive, - audioManager, - onGenerated: (line, state) => { - const ticker = document.getElementById('observation-ticker-text'); - if (ticker && observationActive) ticker.textContent = line.toUpperCase(); - window.dispatchEvent(new CustomEvent('xzbt:generated-content', { detail: { line, state } })); - } - }); - generativeExperience.bindUI(); - window.generativeExperience = generativeExperience; + // XZBT Generative Experience subsystem. Deterministic engine remains + // authoritative. Step 6.7B: console-only -- WebLLM/Kokoro must never be + // constructed or prepared on the Observation surface (highest-risk guard + // in this design; see the isConsoleMode guard on prepareExperience() in + // enterObservation() below). + let generativeExperience = null; + if (isConsoleMode) { + generativeExperience = new XZBTGenerativeExperience({ + getUniverse: () => UniverseRegistry[activeUniverseId], + getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null, + isPlaying: () => isPlaying, + isObservation: () => observationActive, + audioManager, + onGenerated: (line, state) => { + const ticker = document.getElementById('observation-ticker-text'); + const tickerText = line.toUpperCase(); + if (ticker && observationActive) ticker.textContent = tickerText; + // Mirror the announcement to any attached Observation surface(s): + // the AI only verbalizes state the authority already decided, so + // this is presentation-only text, not contract state (report SS6). + if (surfaceOwner) surfaceOwner.broadcastPresentation('ticker', { tickerText }); + window.dispatchEvent(new CustomEvent('xzbt:generated-content', { detail: { line, state } })); + } + }); + generativeExperience.bindUI(); + window.generativeExperience = generativeExperience; + } // Forward-declare absolute state setters & helpers for Contract Adapter let setMasterVolumeDirect, setPlayingDirect, setMutedDirect, setFncEnabledDirect; let setObservationDirect, setViewportFrameDirect, setAlertActiveDirect; let setUniverseDirect, selectPresetDirect, getContractStateSnapshot; + // Step 6.7B: console-only. Never constructed on the Observation surface -- + // this is what makes "no second contract session" a structural fact. + let controlBus = null; + let contractAdapter = null; - // Semantic control bus & XZBT Contract Adapter - const controlBus = new XZBTControlBus(); + // Semantic control bus & XZBT Contract Adapter. Step 6.7B: constructed + // only in console mode -- see the isConsoleMode guard closing after + // generativeExperience.onSpeechStateChanged below. + if (isConsoleMode) { + controlBus = new XZBTControlBus(); window.xzbtControlBus = controlBus; - const contractAdapter = new XZBTContractAdapter({ + contractAdapter = new XZBTContractAdapter({ product: 'SciFi-XZBT', - version: '5.2.0', + version: '5.3.0', + surfaces: XZBTSurfaceMode.SURFACES(xzbtInstanceId), + instanceId: xzbtInstanceId, bindings: { getUniverse: () => activeUniverseId, getPreset: () => activePresetId, @@ -203,7 +240,21 @@ document.addEventListener('DOMContentLoaded', () => { 'fnc.enabled': (v, src) => setFncEnabledDirect(v, src), 'view.observation': (v, src) => setObservationDirect(v, src), 'view.viewport-frame': (v, src) => setViewportFrameDirect(v, src), - 'alert.active': (v, src) => setAlertActiveDirect(v, src) + 'alert.active': (v, src) => setAlertActiveDirect(v, src), + // Step 6.7B: newly-canonical targets. observation-engine.js keeps + // setPillars(); warp-flight has no engine-side setter (only + // toggleWarpFlight()), so the desired boolean is compared against + // the engine's current flightMode before toggling -- this keeps + // observation-engine.js's own simulation code untouched. + 'view.pillars': v => { + if (observationEngine) observationEngine.setPillars(!!v); + }, + 'view.warp-flight': v => { + if (!observationEngine) return; + const want = !!v; + const isWarp = observationEngine.flightMode === 'warp'; + if (want !== isWarp) observationEngine.toggleWarpFlight(); + } }, invokers: { 'speech.say': async args => { @@ -252,43 +303,130 @@ document.addEventListener('DOMContentLoaded', () => { } }); window.xzbtContractAdapter = contractAdapter; - fncSystem.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui'); - generativeExperience.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui'); - generativeExperience.onInvoke = id => contractAdapter.invokeAction(id, {}, 'ui'); - generativeExperience.onSpeechStateChanged = state => contractAdapter.updateCapability('speech', state); - observationEngine.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui'); + fncSystem.onMutation = (id, value) => contractAdapter?.applyMutation(id, value, 'ui'); + generativeExperience.onMutation = (id, value) => contractAdapter?.applyMutation(id, value, 'ui'); + generativeExperience.onInvoke = id => contractAdapter?.invokeAction(id, {}, 'ui'); + generativeExperience.onSpeechStateChanged = state => contractAdapter?.updateCapability('speech', state); + // Step 6.7B: exhibit-internal surface-attachment bus, owner side. Fed + // from the adapter's session-independent onLocalChange/onLocalAction + // hooks (contract-adapter.js SS5.4) rather than contract events, which are + // gated on an NGN session and would go silent standalone. + surfaceOwner = XZBTSurfaceBus.createOwner(contractAdapter, { + channelName: XZBTSurfaceMode.channelName(xzbtInstanceId), + // A surface attaching/detaching can flip observationAudienceActive(); + // re-evaluate the scheduler so a pane opened with the console overlay + // off (or the last pane closing with it still off) starts/stops the + // ambient generator correctly (report SS6, SS8's symmetric detach case). + onParticipantsChanged: () => scheduleObservationAmbientActivity() + }); + contractAdapter.onLocalChange = (target, value, stateRevision) => { + surfaceOwner.broadcastState(target, value, stateRevision); + }; + contractAdapter.onLocalAction = (target, args) => { + surfaceOwner.broadcastAction(target, args); + }; + } else { + // Step 6.7B: Observation surface (non-authoritative). Never constructs + // a contract session, control bus, or generative engine; attaches to + // the console instance named by ?xi= and mirrors its state over the + // exhibit-internal bus. `applyPresentationState` is defined further + // down (function declarations hoist; these callbacks only actually run + // once a message arrives, long after the rest of this script has run). + surfaceLink = XZBTSurfaceBus.attach({ + channelName: XZBTSurfaceMode.channelName(xzbtInstanceId), + timeoutMs: 1500, + onSnapshot: (values, stateRevision, registryRevision, presentation) => { + Object.keys(values || {}).forEach(target => applyPresentationState(target, values[target])); + if (presentation && typeof presentation.tickerText === 'string' && presentation.tickerText) { + const ticker = document.getElementById('observation-ticker-text'); + if (ticker) ticker.textContent = presentation.tickerText; + } + // The surface renders the scene unconditionally while open -- no + // view.observation gating (report SS8: surface existence and the + // console's own overlay flag are deliberately separate concepts). + enterObservation(); + }, + onChange: (target, value) => applyPresentationState(target, value), + onAction: (target, args) => { + if (target === 'display.ticker' && args && typeof args.text === 'string') { + const ticker = document.getElementById('observation-ticker-text'); + if (ticker) ticker.textContent = args.text; + } + }, + onPresentation: (kind, payload) => { + if (kind === 'ticker' && typeof payload.tickerText === 'string') { + const ticker = document.getElementById('observation-ticker-text'); + if (ticker) ticker.textContent = payload.tickerText; + } else if (kind === 'obs-activity') { + // Render the authority's decision; never independently generate + // one (report SS6's "authority decides, surface renders"). + triggerObservationActivity(payload.source || 'ambient'); + } + }, + onTimeout: () => { + // No console instance open on this channel (or a stale/unknown + // ?xi=). Degrade to a clear waiting state rather than inventing + // local authority (report SS9, Museum's proven behavior). + console.warn('[SciFi-XZBT] Observation surface: no console instance found for xi=' + xzbtInstanceId); + } + }); + + window.__xzbtSurfaceDispose = () => { if (surfaceLink) surfaceLink.detach(); }; + window.addEventListener('beforeunload', () => { if (surfaceLink) surfaceLink.detach(); }); + } // end isConsoleMode / else observation-surface attach (control bus, contract adapter, surface bus) + + // Step 6.7B: the one dock-interaction chokepoint (already true before this + // step for every control except PILLARS/WARP, now including them). Console + // mode routes into the one mutation chokepoint as before; the Observation + // surface routes the same five interactive controls to the console over the + // bus instead, and drops view.observation (RETURN) locally -- posting it + // would toggle the *console's* own overlay, a cross-surface side effect + // the design explicitly forbids (report SS8). RETURN/exit are additionally + // hidden by CSS (body.surface-observation) as the primary defense. + const XZBT_PRESENTATION_ROUTED_TARGETS = new Set([ + 'view.warp-flight', 'view.viewport-frame', 'view.pillars', 'alert.active', 'preset.selected' + ]); + observationEngine.onMutation = isConsoleMode + ? (id, value) => contractAdapter?.applyMutation(id, value, 'ui') + : (id, value) => { + if (!XZBT_PRESENTATION_ROUTED_TARGETS.has(id)) return; + if (surfaceLink) surfaceLink.mutate('set', id, value); + }; + + // Step 6.7B: console-only (controlBus/contractAdapter don't exist otherwise). + if (isConsoleMode) { // Intercept XZBTControlBus calls to route through contractAdapter const origBusSet = controlBus.set.bind(controlBus); const origBusTrigger = controlBus.trigger.bind(controlBus); controlBus.set = function (id, value, source = 'external') { - if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) { - const canonicalId = contractAdapter.legacyIdMap.get(id) || id; - const target = contractAdapter.targets.get(canonicalId); + if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) { + const canonicalId = contractAdapter?.legacyIdMap.get(id) || id; + const target = contractAdapter?.targets.get(canonicalId); let normVal = value; if (target && target.kind === 'range') { - if (contractAdapter.legacyIdMap.has(id)) { + if (contractAdapter?.legacyIdMap.has(id)) { const legacy = controlBus.targets.get(id); if (legacy?.step) normVal = Math.round(Number(value) / legacy.step) * legacy.step; if (canonicalId === 'view.activity') normVal = Number(normVal) / 100; } } - contractAdapter.applyMutation(canonicalId, normVal, source); + contractAdapter?.applyMutation(canonicalId, normVal, source); return true; } return origBusSet(id, value, source); }; controlBus.trigger = function (id, source = 'external') { - if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) { - const canonicalId = contractAdapter.legacyIdMap.get(id) || id; - const target = contractAdapter.targets.get(canonicalId); + if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) { + const canonicalId = contractAdapter?.legacyIdMap.get(id) || id; + const target = contractAdapter?.targets.get(canonicalId); if (target && target.kind === 'state') { // Toggle state if boolean - const curr = contractAdapter.getContractState()[canonicalId]; - contractAdapter.applyMutation(canonicalId, !curr, source); + const curr = contractAdapter?.getContractState()[canonicalId]; + contractAdapter?.applyMutation(canonicalId, !curr, source); return true; } - contractAdapter.invokeAction(canonicalId, {}, source); + contractAdapter?.invokeAction(canonicalId, {}, source); return true; } return origBusTrigger(id, source); @@ -331,6 +469,7 @@ document.addEventListener('DOMContentLoaded', () => { controlBus.register('red-alert', { label: 'Red Alert Toggle', type: 'action' }); controlBus.register('generate-announcement', { label: 'Generate AI Announcement', type: 'action' }); controlBus.register('mute', { label: 'Master Mute', type: 'action' }); + } // end isConsoleMode (control bus semantic registration) // TV presentation controls. The final Cast stream quality remains Chrome-controlled. @@ -365,7 +504,7 @@ document.addEventListener('DOMContentLoaded', () => { btn.innerHTML = `${u.icon} ${u.name}`; btn.addEventListener('click', (e) => { e.stopPropagation(); - contractAdapter.applyMutation('universe.selected', u.id, 'ui'); + contractAdapter?.applyMutation('universe.selected', u.id, 'ui'); }); universeDropdown.appendChild(btn); }); @@ -399,6 +538,8 @@ document.addEventListener('DOMContentLoaded', () => { document.body.className = universe.themeClass; // Assigning className wholesale drops the TV presentation classes, so re-apply them. applyTV(); + // ...and the Step 6.7B surface-mode class, for the same reason. + if (!isConsoleMode) document.body.classList.add('surface-observation'); headerMatrixLabel.textContent = universe.headerTitle; // Custom titles per universe @@ -472,7 +613,7 @@ document.addEventListener('DOMContentLoaded', () => { soundboardTitle.textContent = conf.soundboard; // Configure Visualizer mode - visualizer.setMode(universe.visualizer || 'warp-core'); + visualizer?.setMode(universe.visualizer || 'warp-core'); // Render Event Buttons & Soundboard renderUniverseEvents(universe); @@ -509,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => { async function invokeConsoleEvent(evt, btn, source) { if (evt.type === 'alert-red' || evt.type === 'alert-yellow') { const value = evt.type === 'alert-red' ? 'red' : 'yellow'; - return contractAdapter.applyMutation('alert.active', alerts.activeAlert === value ? 'none' : value, source); + return contractAdapter?.applyMutation('alert.active', alerts.activeAlert === value ? 'none' : value, source); } - const canonical = [...contractAdapter.targets.values()].find(t => t.kind === 'impulse' && t.id.startsWith('event.') && (t.id === `event.${evt.type}` || `btn-${t.id.slice(6)}` === evt.id)); - if (canonical) return contractAdapter.invokeAction(canonical.id, {}, source); + const canonical = [...contractAdapter?.targets.values()].find(t => t.kind === 'impulse' && t.id.startsWith('event.') && (t.id === `event.${evt.type}` || `btn-${t.id.slice(6)}` === evt.id)); + if (canonical) return contractAdapter?.invokeAction(canonical.id, {}, source); await audioManager.resume(); handleUniverseEvent(evt.type, btn); } @@ -531,7 +672,7 @@ document.addEventListener('DOMContentLoaded', () => { btn.addEventListener('click', async () => { const aliases = { 'btn-chirp-single': 'sfx.console-chirp', 'btn-telepathic': 'sfx.telepathic-chime', 'btn-commlock': 'sfx.comlock', 'btn-afterburner-snd': 'sfx.afterburner' }; const canonical = aliases[key.id] || `sfx.${key.id.replace(/^btn-/, '')}`; - if (contractAdapter.targets.has(canonical)) return contractAdapter.invokeAction(canonical, {}, 'ui'); + if (contractAdapter?.targets.has(canonical)) return contractAdapter?.invokeAction(canonical, {}, 'ui'); await audioManager.resume(); handleSoundboardTrigger(key.id); }); @@ -553,7 +694,7 @@ document.addEventListener('DOMContentLoaded', () => { `; btn.addEventListener('click', () => { - contractAdapter.applyMutation('preset.selected', preset.id, 'ui'); + contractAdapter?.applyMutation('preset.selected', preset.id, 'ui'); }); presetContainer.appendChild(btn); @@ -763,7 +904,7 @@ document.addEventListener('DOMContentLoaded', () => { } btnPlay.addEventListener('click', () => { - contractAdapter.applyMutation('transport.playing', !isPlaying, 'ui'); + contractAdapter?.applyMutation('transport.playing', !isPlaying, 'ui'); }); // 6. Master Volume Control System @@ -800,65 +941,65 @@ document.addEventListener('DOMContentLoaded', () => { } function setMasterVolume(val) { - contractAdapter.applyMutation('mix.master', val, 'ui'); + contractAdapter?.applyMutation('mix.master', val, 'ui'); } function toggleMute() { - contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'ui'); + contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'ui'); } sliderMasterVol.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.master', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.master', parseFloat(e.target.value), 'ui'); }); btnMasterMute.addEventListener('click', toggleMute); btnHeaderMute.addEventListener('click', toggleMute); btnVolDown.addEventListener('click', () => { - contractAdapter.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'ui'); + contractAdapter?.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'ui'); }); btnVolUp.addEventListener('click', () => { - contractAdapter.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'ui'); + contractAdapter?.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'ui'); }); volStepButtons.forEach(btn => { btn.addEventListener('click', () => { const stepVal = parseFloat(btn.getAttribute('data-vol')); - contractAdapter.applyMutation('mix.master', stepVal, 'ui'); + contractAdapter?.applyMutation('mix.master', stepVal, 'ui'); }); }); // 7. Channel Synthesizer Sliders sliderHullVol.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.hull.level', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.hull.level', parseFloat(e.target.value), 'ui'); }); sliderHullFreq.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.hull.frequency', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.hull.frequency', parseFloat(e.target.value), 'ui'); }); sliderHullCutoff.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.hull.cutoff', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.hull.cutoff', parseFloat(e.target.value), 'ui'); }); sliderWarpVol.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.drive.level', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.drive.level', parseFloat(e.target.value), 'ui'); }); sliderWarpBpm.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.drive.pulse-rate', parseInt(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.drive.pulse-rate', parseInt(e.target.value), 'ui'); }); sliderWarpCarrier.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.drive.carrier', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.drive.carrier', parseFloat(e.target.value), 'ui'); }); sliderAirVol.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.environment.level', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.environment.level', parseFloat(e.target.value), 'ui'); }); sliderAirCutoff.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.environment.cutoff', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.environment.cutoff', parseFloat(e.target.value), 'ui'); }); sliderTelemetryVol.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.telemetry.level', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.telemetry.level', parseFloat(e.target.value), 'ui'); }); sliderTelemetryDensity.addEventListener('input', (e) => { - contractAdapter.applyMutation('mix.telemetry.density', parseFloat(e.target.value), 'ui'); + contractAdapter?.applyMutation('mix.telemetry.density', parseFloat(e.target.value), 'ui'); }); // 8. Universe Event Handlers @@ -872,7 +1013,7 @@ document.addEventListener('DOMContentLoaded', () => { case 'warp': case 'quantum': alerts.synthesizeWarpJump(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'alert-red': case 'action-stations': @@ -902,11 +1043,11 @@ document.addEventListener('DOMContentLoaded', () => { break; case 'demat': whataverseAudio.synthesizeDematCycle(4); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'vortex': whataverseAudio.synthesizeDematCycle(1); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'cloister': if (whataverseAudio.activeCloister) { @@ -923,7 +1064,7 @@ document.addEventListener('DOMContentLoaded', () => { case 'epstein': case 'subdrive': expandedAudio.synthesizeEpsteinBurn(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'purge': case 'decompress': @@ -936,11 +1077,11 @@ document.addEventListener('DOMContentLoaded', () => { case 'starburst': case 'biodefense': expandedAudio.synthesizeStarburst(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'neural': expandedAudio.synthesizeNeuralBondSwell(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'centrifuge': case 'astrogator': @@ -951,7 +1092,7 @@ document.addEventListener('DOMContentLoaded', () => { break; case 'ftl-jump': expandedAudio.synthesizeFtlJump(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'flak': expandedAudio.synthesizeFlakBarrageBurst(); @@ -960,29 +1101,29 @@ document.addEventListener('DOMContentLoaded', () => { case 'gravity': case 'rotation': expandedAudio.synthesizeSingularityEngage(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'solar': expandedAudio.synthesizeSolarRoar(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'afterburner': expandedAudio.synthesizeAfterburner(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'ludicrous': expandedAudio.synthesizeLudicrousSpeed(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; case 'improbability': expandedAudio.synthesizeImprobabilityFlip(); - visualizer.spawnWarpPulses(); + visualizer?.spawnWarpPulses(); break; default: telemetry.synthesizeLCARDSingleChirp(); break; } - generativeExperience.describeExistingEvent(type); + generativeExperience?.describeExistingEvent(type); } function handleSoundboardTrigger(id) { @@ -2675,8 +2816,22 @@ document.addEventListener('DOMContentLoaded', () => { return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500); } + // Step 6.7B: an "audience" is the console's own overlay OR at least one + // attached Observation surface. Scheduling/deciding activity uses this; + // *local rendering* still gates on observationActive alone (report SS6's + // audience-scheduler correction -- without it, an audience watching only + // the surface would see a static scene forever). + const observationAudienceActive = () => observationActive || !!(surfaceOwner && surfaceOwner.attachedCount() > 0); + function triggerObservationActivity(source = 'ambient') { - if (!observationActive || observationActivity <= 0.001) return; + if (!observationAudienceActive() || observationActivity <= 0.001) return; + // The authority decides; attached surfaces render the same decision + // rather than independently rolling their own random event (report SS6, + // "Transient activity events are mirrored, not regenerated"). Exact + // visual parameters (which contact, which ring) are NOT byte-identical + // across documents -- see the accepted-limitations note in README.md / + // agents.md; only the "an event happens now" decision is synchronized. + if (isConsoleMode && surfaceOwner) surfaceOwner.broadcastPresentation('obs-activity', { source }); // Telemetry is a synchronization pulse, but the slider controls whether // that pulse becomes visible. At maximum every telemetry pulse produces @@ -2713,7 +2868,12 @@ document.addEventListener('DOMContentLoaded', () => { clearTimeout(observationAmbientTimer); observationAmbientTimer = null; } - if (!observationActive || observationActivity <= .01) return; + // The Observation surface never runs its own scheduler -- it only + // renders 'obs-activity' decisions relayed from the authority. Without + // this guard two documents would each roll independent random timers, + // violating "no duplicate random event generation" (report SS6/R3). + if (!isConsoleMode) return; + if (!observationAudienceActive() || observationActivity <= .01) return; // Quiet = occasional subtle life. Active = a busy generative display. const slow = 12500; @@ -2798,7 +2958,7 @@ document.addEventListener('DOMContentLoaded', () => { updateObservationHudMode(); sliderObservationActivity.addEventListener('input', () => { - contractAdapter.applyMutation('view.activity', Number(sliderObservationActivity.value) / 100, 'ui'); + contractAdapter?.applyMutation('view.activity', Number(sliderObservationActivity.value) / 100, 'ui'); }); window.addEventListener('scifi-telemetry-activity', (event) => { @@ -4419,6 +4579,48 @@ document.addEventListener('DOMContentLoaded', () => { } } + // Step 6.7B: applies one mirrored contract-state value on the + // Observation surface. Reuses the existing local setters/functions + // directly -- never contractAdapter.applyMutation (there is none here) -- + // so this is local *rendering*, not a second computation of authority. + // Targets the renderer doesn't care about (mix.*, fnc.*, transport.muted, + // speech.*) are intentionally ignored. + function applyPresentationState(target, value) { + switch (target) { + case 'universe.selected': + if (value && value !== activeUniverseId) setUniverse(value); + break; + case 'preset.selected': + if (value && value !== activePresetId) selectPreset(value); + break; + case 'view.viewport-frame': + if (observationEngine) observationEngine.setViewport(!!value); + break; + case 'view.pillars': + if (observationEngine) observationEngine.setPillars(!!value); + break; + case 'view.warp-flight': { + if (!observationEngine) break; + const isWarp = observationEngine.flightMode === 'warp'; + if (!!value !== isWarp) observationEngine.toggleWarpFlight(); + break; + } + case 'alert.active': + if (typeof setAlertActiveDirect === 'function') setAlertActiveDirect(value); + break; + case 'view.activity': + setObservationActivity(Number(value) * 100); + break; + case 'transport.playing': + // Cosmetic only (the "PROCEDURAL AUDIO LINK" status line) -- never + // engages audio on the surface. + isPlaying = !!value; + break; + default: + break; // view.observation, mix.*, fnc.*, speech.*, transport.muted: not rendered here. + } + } + function refreshObservation() { const universe = UniverseRegistry[activeUniverseId]; const preset = universe && universe.presets ? universe.presets[activePresetId] : null; @@ -4442,18 +4644,29 @@ document.addEventListener('DOMContentLoaded', () => { function enterObservation() { if (observationActive) return; - generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err)); + // Step 6.7B / R2: the single most important guard in this design. + // prepareExperience() lazily loads WebLLM + Kokoro (agents.md's one + // documented network/import exception); calling it unconditionally + // here would make every opened Observation surface download a + // multi-gigabyte model. Console-only. + if (isConsoleMode) { + generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err)); + } observationActive = true; observationOverlay.classList.add('active'); observationOverlay.setAttribute('aria-hidden', 'false'); refreshObservation(); resetObservationHudFade(); resetObservationReturnFade(); - scheduleObservationAmbientActivity(); + if (isConsoleMode) { + // The Observation surface never runs its own scheduler -- it renders + // activity decisions broadcast by the authority (report SS6). + scheduleObservationAmbientActivity(); - // Give the display an immediate but restrained sign of life. - if (observationActivity > .08) { - window.setTimeout(() => triggerObservationActivity('ambient'), 700); + // Give the display an immediate but restrained sign of life. + if (observationActivity > .08) { + window.setTimeout(() => triggerObservationActivity('ambient'), 700); + } } } @@ -4502,7 +4715,7 @@ document.addEventListener('DOMContentLoaded', () => { btnObservation.addEventListener('click', (e) => { e.stopPropagation(); - contractAdapter.applyMutation('view.observation', !observationActive, 'ui'); + contractAdapter?.applyMutation('view.observation', !observationActive, 'ui'); }); // Click background to exit, but ignore clicks on the control dock or its buttons @@ -4510,7 +4723,7 @@ document.addEventListener('DOMContentLoaded', () => { if (e.target.closest('#observation-control-dock')) return; e.preventDefault(); e.stopPropagation(); - contractAdapter.applyMutation('view.observation', false, 'ui'); + contractAdapter?.applyMutation('view.observation', false, 'ui'); }); const observationReturnPill = document.getElementById('observation-return-pill'); @@ -4518,19 +4731,21 @@ document.addEventListener('DOMContentLoaded', () => { observationReturnPill.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - contractAdapter.applyMutation('view.observation', false, 'ui'); + contractAdapter?.applyMutation('view.observation', false, 'ui'); }); } - // 10. Keyboard Shortcuts + // 10. Keyboard Shortcuts. Step 6.7B: console-only -- a keystroke on the + // Observation surface must not mutate state (report SS7). + if (isConsoleMode) { window.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; if (e.code === 'Space') { e.preventDefault(); - contractAdapter.applyMutation('transport.playing', !isPlaying, 'hotkey'); + contractAdapter?.applyMutation('transport.playing', !isPlaying, 'hotkey'); } else if (e.code === 'KeyM') { - contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey'); + contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey'); } else if (e.code === 'KeyW') { if (observationActive && observationEngine) { observationEngine.toggleWarpFlight(); @@ -4541,7 +4756,7 @@ document.addEventListener('DOMContentLoaded', () => { if (primaryEvent) invokeConsoleEvent(primaryEvent, primaryActionBtn, 'hotkey'); } else if (e.code === 'KeyF') { if (observationActive && observationEngine) { - contractAdapter.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey'); + contractAdapter?.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey'); return; } } else if (e.code === 'KeyP') { @@ -4552,7 +4767,7 @@ document.addEventListener('DOMContentLoaded', () => { } else if (e.code === 'KeyR') { if (observationActive && observationEngine) { const nextAlert = alerts.activeAlert === 'red' ? 'none' : 'red'; - contractAdapter.applyMutation('alert.active', nextAlert, 'hotkey'); + contractAdapter?.applyMutation('alert.active', nextAlert, 'hotkey'); return; } const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0]; @@ -4561,23 +4776,24 @@ document.addEventListener('DOMContentLoaded', () => { if (alertEvent) invokeConsoleEvent(alertEvent, firstAlertBtn, 'hotkey'); } else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') { e.preventDefault(); - contractAdapter.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'hotkey'); + contractAdapter?.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'hotkey'); } else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') { e.preventDefault(); - contractAdapter.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'hotkey'); + contractAdapter?.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'hotkey'); } else if (e.code >= 'Digit1' && e.code <= 'Digit9') { const num = parseInt(e.code.replace('Digit', '')); - contractAdapter.applyMutation('mix.master', num * 0.1, 'hotkey'); + contractAdapter?.applyMutation('mix.master', num * 0.1, 'hotkey'); } else if (e.code === 'Digit0') { - contractAdapter.applyMutation('mix.master', 1.0, 'hotkey'); + contractAdapter?.applyMutation('mix.master', 1.0, 'hotkey'); } else if (e.code === 'Escape') { if (observationActive) { - contractAdapter.applyMutation('view.observation', false, 'hotkey'); + contractAdapter?.applyMutation('view.observation', false, 'hotkey'); return; } - contractAdapter.applyMutation('alert.active', 'none', 'hotkey'); + contractAdapter?.applyMutation('alert.active', 'none', 'hotkey'); } }); + } // end isConsoleMode (keyboard shortcuts) // --- Implementation of Absolute Setters and Contract Helpers --- setMasterVolumeDirect = function (val, isMuted = false) { @@ -4688,6 +4904,8 @@ document.addEventListener('DOMContentLoaded', () => { 'fnc.level': fncSystem ? fncSystem.level : 0.5, 'view.observation': observationActive, 'view.viewport-frame': observationEngine ? observationEngine.showViewport : true, + 'view.pillars': observationEngine ? observationEngine.showPillars : false, + 'view.warp-flight': observationEngine ? observationEngine.flightMode === 'warp' : false, 'view.activity': observationActivity, 'alert.active': alertStatus, 'universe.selected': activeUniverseId, @@ -4699,5 +4917,5 @@ document.addEventListener('DOMContentLoaded', () => { initUniverseDropdown(); setUniverse('starfleet'); updateMasterVolumeUI(0.75, false); - visualizer.init('spectrum-canvas', 'warp-core-canvas'); + visualizer?.init('spectrum-canvas', 'warp-core-canvas'); }); diff --git a/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js b/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js index 5be71af..8835218 100644 --- a/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js +++ b/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js @@ -1,5 +1,5 @@ /** - * XZBT Contract Adapter — Specification v5.2 + * XZBT Contract Adapter — Specification v5.3 * * Implements the normalized contract interface for SciFi-XZBT: * - Canonical external target catalog & descriptors @@ -15,10 +15,10 @@ class XZBTContractAdapter { constructor(options = {}) { this.product = options.product || 'SciFi-XZBT'; - this.version = options.version || '5.2.0'; + this.version = options.version || '5.3.0'; this.build = options.build || 'production'; this.contractMajor = 5; - this.contractMinor = 2; + this.contractMinor = 3; this.sessionId = null; this.sessionActive = false; @@ -28,6 +28,17 @@ this.operationQueue = Promise.resolve(); this.eventSink = null; + // Surface catalog (Contract 5.3 §31.2/§31.3). Advertised in describe() + // only when non-empty, so absence stays §31.3 form 1 ("absent"). + this.surfaces = Array.isArray(options.surfaces) ? options.surfaces : null; + this.instanceId = options.instanceId || null; + + // Session-independent local-change hooks (Step 6.7B). Unlike + // _emitEvent, these fire regardless of sessionActive so an + // exhibit-internal surface mirror keeps working with no NGN attached. + this.onLocalChange = typeof options.onLocalChange === 'function' ? options.onLocalChange : null; + this.onLocalAction = typeof options.onLocalAction === 'function' ? options.onLocalAction : null; + // Transport hooks this.onMessage = options.onMessage || (() => {}); this.hostOrigin = options.hostOrigin || null; // optional origin filter @@ -355,6 +366,31 @@ requires: ['observation'] }); + reg({ + id: 'view.pillars', + legacyId: 'observation-pillars', + label: 'Window Pillars / Mullions', + kind: 'state', + valueType: 'boolean', + readable: true, + writable: true, + restorable: true, + category: 'observation', + requires: ['observation'] + }); + + reg({ + id: 'view.warp-flight', + label: 'Warp Flight Mode', + kind: 'state', + valueType: 'boolean', + readable: true, + writable: true, + restorable: true, + category: 'observation', + requires: ['observation'] + }); + reg({ id: 'alert.active', label: 'Active Tactical Alert Status', @@ -416,9 +452,9 @@ restorable: false, category: 'speech', requires: ['speech'], - args: { - text: { type: 'string', required: true, maxLength: 2000 } - } + arguments: [ + { name: 'text', type: 'string', required: true, maxLength: 2000 } + ] }); reg({ @@ -442,9 +478,9 @@ restorable: false, category: 'display', requires: ['display-text'], - args: { - text: { type: 'string', required: true, maxLength: 512 } - } + arguments: [ + { name: 'text', type: 'string', required: true, maxLength: 512 } + ] }); // --- 4. Canonical Universe Event Impulses --- @@ -555,7 +591,7 @@ return; } // Ignore non-XZBT messages - if (!e.data || typeof e.data !== 'object' || e.data.xzbt !== '5.2') { + if (!e.data || typeof e.data !== 'object' || typeof e.data.xzbt !== 'string') { return; } if (e.data.sequence || e.data.type === 'error' || /\.result$/.test(e.data.type)) return; @@ -616,6 +652,15 @@ stateRevision: this.stateRevision, target: target.id, value: after[target.id] }, correlationId, source); } + // Session-independent mirror feed (Step 6.7B). _emitEvent above is + // gated on sessionActive and therefore silent with no NGN attached; + // onLocalChange is not, so an exhibit-internal surface mirror keeps + // working standalone. Fires for every changed target, same as above. + if (this.onLocalChange) { + for (const target of changes) { + this.onLocalChange(target.id, after[target.id], this.stateRevision); + } + } return true; } @@ -755,11 +800,13 @@ // Validate args schema & text length limits if defined const sanitizedArgs = (args && typeof args === 'object' && !Array.isArray(args)) ? { ...args } : {}; - if (Object.keys(sanitizedArgs).some(key => !target.args || !target.args[key])) { + const argumentSpecs = target.arguments || []; + if (Object.keys(sanitizedArgs).some(key => !argumentSpecs.some(spec => spec.name === key))) { return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' }; } - if (target.args) { - for (const [argKey, argSpec] of Object.entries(target.args)) { + if (argumentSpecs.length) { + for (const argSpec of argumentSpecs) { + const argKey = argSpec.name; if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) { return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` }; } @@ -823,6 +870,11 @@ args: sanitizedArgs }, correlationId, source); + // Session-independent mirror feed (Step 6.7B); see _commitChanges. + if (this.onLocalAction) { + this.onLocalAction(canonicalId, sanitizedArgs); + } + return { ok: true }; } @@ -836,7 +888,7 @@ // Public API: Protocol Description describe() { - return { + const description = { exhibit: { product: this.product, version: this.version, @@ -851,6 +903,10 @@ capabilities: Array.from(this.capabilities.values()), targets: Array.from(this.targets.values()) }; + if (this.surfaces && this.surfaces.length) { + description.surfaces = this.surfaces; + } + return description; } // Update Capability state and emit capability.changed diff --git a/test-fixtures/reference-exhibits/scifi/js/observation-engine.js b/test-fixtures/reference-exhibits/scifi/js/observation-engine.js index 7785b6b..25732f0 100644 --- a/test-fixtures/reference-exhibits/scifi/js/observation-engine.js +++ b/test-fixtures/reference-exhibits/scifi/js/observation-engine.js @@ -204,7 +204,12 @@ class ObservationEngine { if (this.btnWarp) { this.btnWarp.addEventListener('click', (e) => { e.stopPropagation(); - this.toggleWarpFlight(); + // Step 6.7B: route through the canonical view.warp-flight target, + // like btnFrame's view.viewport-frame, instead of mutating engine + // state directly -- this is what lets the Observation surface and + // the console converge through the one mutation chokepoint. + if (this.onMutation) this.onMutation('view.warp-flight', this.flightMode !== 'warp'); + else this.toggleWarpFlight(); }); } @@ -219,7 +224,9 @@ class ObservationEngine { if (this.btnPillars) { this.btnPillars.addEventListener('click', (e) => { e.stopPropagation(); - this.togglePillars(); + // Step 6.7B: route through the canonical view.pillars target. + if (this.onMutation) this.onMutation('view.pillars', !this.showPillars); + else this.togglePillars(); }); } diff --git a/test-fixtures/reference-exhibits/scifi/js/surface-bus.js b/test-fixtures/reference-exhibits/scifi/js/surface-bus.js new file mode 100644 index 0000000..f1b19de --- /dev/null +++ b/test-fixtures/reference-exhibits/scifi/js/surface-bus.js @@ -0,0 +1,254 @@ +/** + * XZBT Surface Bus — SciFi-XZBT (Step 6.7B) + * + * Exhibit-internal state mirror between the authoritative console document + * and the non-authoritative Observation surface document. Adapted from + * Museum Gallery's proven `surface-bus.js` shape (Steps 6.3-6.6), with one + * deliberate improvement: the BroadcastChannel name is scoped to a single + * console instance id (XZBTSurfaceMode.channelName), so two console tabs on + * one origin cannot cross-talk. + * + * This carries NO XZBT Exhibit Contract envelope — no `xzbt` field, no + * `sessionId` — and is never observed by an NGN host (Contract 5.3 §31.7's + * explicit allowance for exhibit-internal transports). Message shapes: + * + * surface -> owner : { type:'attach', requestId, participantId } + * owner -> surface : { type:'attach.snapshot', inReplyTo, values, + * stateRevision, registryRevision, + * presentation:{ tickerText } } + * owner -> all : { type:'state', target, value, stateRevision } + * owner -> all : { type:'action', target, args } + * owner -> all : { type:'presentation', kind:'ticker'|'obs-activity', ... } + * surface -> owner : { type:'mutate', kind:'set'|'invoke', target, value|args } + * surface -> owner : { type:'detach', participantId } + */ +(function (root) { + 'use strict'; + + function randomId() { + return 'req-' + Math.random().toString(16).slice(2) + Date.now().toString(16); + } + + /** + * Owner side. Call once, in the console (authoritative) document, after + * the XZBTContractAdapter has been constructed. + * + * @param {object} adapter an XZBTContractAdapter instance + * @param {object} options + * @param {string} options.channelName instance-scoped channel name + * @param {function} [options.onParticipantsChanged] (attachedCount) + */ + function createOwner(adapter, options) { + options = options || {}; + if (!options.channelName) { + throw new Error('surface-bus: createOwner requires options.channelName'); + } + var channel = new BroadcastChannel(options.channelName); + var lastPresentation = { tickerText: '' }; + + // Set-based participant bookkeeping (not a counter): a duplicate/late + // attach or detach can never ratchet the count (Museum's lesson). + var participants = new Set(); + var closed = false; + + function notifyParticipantsChanged() { + if (typeof options.onParticipantsChanged === 'function') { + options.onParticipantsChanged(participants.size); + } + } + + function currentValues() { + var state = adapter.getContractState(); + var values = {}; + adapter.targets.forEach(function (target, id) { + if (target.readable && target.kind !== 'impulse') { + values[id] = state[id]; + } + }); + return values; + } + + channel.onmessage = function (ev) { + var msg = ev.data; + if (!msg || typeof msg !== 'object') return; + + if (msg.type === 'attach') { + if (typeof msg.participantId === 'string') { + participants.add(msg.participantId); + } + channel.postMessage({ + type: 'attach.snapshot', + inReplyTo: msg.requestId, + values: currentValues(), + stateRevision: adapter.stateRevision, + registryRevision: adapter.registryRevision, + presentation: lastPresentation + }); + notifyParticipantsChanged(); + return; + } + + if (msg.type === 'mutate') { + // Every surface-originated interaction goes through exactly the + // same applyMutation/invokeAction chokepoint a contract set/invoke + // from NGN would use. Source is always 'ui'. + if (msg.kind === 'set' && typeof msg.target === 'string') { + adapter.applyMutation(msg.target, msg.value, 'ui'); + } else if (msg.kind === 'invoke' && typeof msg.target === 'string') { + adapter.invokeAction(msg.target, msg.args || {}, 'ui'); + } + return; + } + + if (msg.type === 'detach') { + if (typeof msg.participantId === 'string' && participants.delete(msg.participantId)) { + notifyParticipantsChanged(); + } + } + }; + + return { + channel: channel, + attachedCount: function () { return participants.size; }, + /** Broadcast one changed target (fed from adapter.onLocalChange). */ + broadcastState: function (target, value, stateRevision) { + if (closed) return; + channel.postMessage({ type: 'state', target: target, value: value, stateRevision: stateRevision }); + }, + /** Broadcast an executed impulse (fed from adapter.onLocalAction). */ + broadcastAction: function (target, args) { + if (closed) return; + channel.postMessage({ type: 'action', target: target, args: args || {} }); + }, + /** Broadcast a transient presentation-only extra (ticker text, an + * ambient-activity decision). Not contract state. */ + broadcastPresentation: function (kind, payload) { + if (closed) return; + var msg = Object.assign({ type: 'presentation', kind: kind }, payload || {}); + if (kind === 'ticker' && payload && typeof payload.tickerText === 'string') { + lastPresentation = { tickerText: payload.tickerText }; + } + channel.postMessage(msg); + }, + detach: function () { + if (closed) return; + closed = true; + channel.close(); + } + }; + } + + /** + * Surface side. Call from the Observation surface document. Requests + * attachment and waits `timeoutMs` for a reply; if no owner document is + * open on the same channel, `onTimeout` fires and the surface must + * degrade to a waiting state rather than inventing its own authority. + * + * @param {object} options + * @param {string} options.channelName instance-scoped channel name + * @param {number} [options.timeoutMs] + * @param {function} [options.onSnapshot] (values, stateRevision, registryRevision, presentation) + * @param {function} [options.onChange] (target, value, stateRevision) + * @param {function} [options.onAction] (target, args) + * @param {function} [options.onPresentation] (kind, payload) + * @param {function} [options.onTimeout] + */ + function attach(options) { + options = options || {}; + if (!options.channelName) { + throw new Error('surface-bus: attach requires options.channelName'); + } + var channel = new BroadcastChannel(options.channelName); + var requestId = randomId(); + // A fresh participantId per attach() call. A reload is a brand-new + // attach() and therefore a brand-new id, which is what lets the + // owner's Set-based bookkeeping retire the old identity cleanly. + var participantId = randomId(); + var attachedFlag = false; + var detachedFlag = false; + + var timer = setTimeout(function () { + if (attachedFlag) return; + if (typeof options.onTimeout === 'function') options.onTimeout(); + }, options.timeoutMs || 1500); + + channel.onmessage = function (ev) { + var msg = ev.data; + if (!msg || typeof msg !== 'object') return; + + if (msg.type === 'attach.snapshot' && msg.inReplyTo === requestId && !attachedFlag) { + attachedFlag = true; + clearTimeout(timer); + if (typeof options.onSnapshot === 'function') { + options.onSnapshot(msg.values, msg.stateRevision, msg.registryRevision, msg.presentation); + } + return; + } + + if (!attachedFlag) return; + + if (msg.type === 'state') { + if (typeof options.onChange === 'function') { + options.onChange(msg.target, msg.value, msg.stateRevision); + } + return; + } + + if (msg.type === 'action') { + if (typeof options.onAction === 'function') { + options.onAction(msg.target, msg.args); + } + return; + } + + if (msg.type === 'presentation') { + if (typeof options.onPresentation === 'function') { + options.onPresentation(msg.kind, msg); + } + } + }; + + channel.postMessage({ type: 'attach', requestId: requestId, participantId: participantId }); + + function detach() { + // Idempotent: local-surfaces.js's pre-removal dispose call and this + // document's own beforeunload handler can both fire for the same + // attachment. Only the first actually posts; every call tears down + // the timer/channel so repeated calls are always safe. + if (attachedFlag && !detachedFlag) { + detachedFlag = true; + channel.postMessage({ type: 'detach', participantId: participantId }); + } + clearTimeout(timer); + channel.close(); + } + + return { + channel: channel, + isAttached: function () { return attachedFlag; }, + /** Route a native surface interaction through the owner's canonical + * mutation path. No-op (returns false) if not yet attached. */ + mutate: function (kind, target, valueOrArgs) { + if (!attachedFlag) return false; + var payload = { type: 'mutate', kind: kind, target: target }; + if (kind === 'set') payload.value = valueOrArgs; + else payload.args = valueOrArgs || {}; + channel.postMessage(payload); + return true; + }, + detach: detach + }; + } + + var XZBTSurfaceBus = { + createOwner: createOwner, + attach: attach + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = XZBTSurfaceBus; + } + if (root) { + root.XZBTSurfaceBus = XZBTSurfaceBus; + } +})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this)); diff --git a/test-fixtures/reference-exhibits/scifi/js/surface-mode.js b/test-fixtures/reference-exhibits/scifi/js/surface-mode.js new file mode 100644 index 0000000..d7a4c75 --- /dev/null +++ b/test-fixtures/reference-exhibits/scifi/js/surface-mode.js @@ -0,0 +1,108 @@ +/** + * XZBT Surface Mode — SciFi-XZBT (Step 6.7B) + * + * Pure, testable seam for resolving which boot mode this document load is in + * (Contract 5.3 §31.4 query-string surface form) and for describing the + * exhibit's two-entry surface catalog. No DOM access, no imports, no + * side effects — safe to load and exercise under Node's `vm` module. + * + * Two surfaces only: + * - surface.console the full operator console (index.html, primary) + * - surface.observation the same document, rendering only the + * Observation viewscreen, addressed by + * ?surface=observation&xi= + */ +(function (root) { + 'use strict'; + + var CHANNEL_PREFIX = 'xzbt-scifi-surface-v1:'; + + /** + * Resolve the boot mode + instance id for a location-like object. + * @param {object} locationLike anything with a `.search` (or `.href`) + * string, e.g. `window.location`. + * @returns {{mode: 'console'|'observation', instanceId: string|null}} + */ + function resolve(locationLike) { + var search = ''; + if (locationLike && typeof locationLike.search === 'string') { + search = locationLike.search; + } else if (locationLike && typeof locationLike.href === 'string') { + var qIdx = locationLike.href.indexOf('?'); + search = qIdx >= 0 ? locationLike.href.slice(qIdx) : ''; + } + + var params; + try { + params = new URLSearchParams(search); + } catch (err) { + params = null; + } + + var surfaceParam = params ? params.get('surface') : null; + var instanceId = params ? params.get('xi') : null; + + if (surfaceParam === 'observation') { + return { mode: 'observation', instanceId: instanceId || null }; + } + + // Unknown/absent `?surface=` values resolve to console. `xi` is + // meaningless in console mode. + return { mode: 'console', instanceId: null }; + } + + /** Mint a fresh per-document-load instance id. */ + function newInstanceId() { + return 'xi-' + Math.random().toString(16).slice(2) + Date.now().toString(16); + } + + /** + * The exhibit's two-entry surface catalog (Contract 5.3 §31.2/§31.3). + * @param {string} instanceId the live console instance id. + */ + function SURFACES(instanceId) { + return [ + { + id: 'surface.console', + label: 'Main Console', + kind: 'surface', + primary: true, + url: 'index.html', + role: 'control', + category: 'console', + description: 'The full SciFi-XZBT operator console.' + }, + { + id: 'surface.observation', + label: 'Observation', + kind: 'surface', + primary: false, + url: '?surface=observation&xi=' + encodeURIComponent(instanceId), + role: 'ambient', + category: 'observation', + aspectRatio: '16:9', + requires: ['observation'], + description: 'Full-screen procedural viewscreen mirroring the console.' + } + ]; + } + + /** The instance-scoped BroadcastChannel name for a console instance id. */ + function channelName(instanceId) { + return CHANNEL_PREFIX + instanceId; + } + + var XZBTSurfaceMode = { + resolve: resolve, + newInstanceId: newInstanceId, + SURFACES: SURFACES, + channelName: channelName + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = XZBTSurfaceMode; + } + if (root) { + root.XZBTSurfaceMode = XZBTSurfaceMode; + } +})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this)); diff --git a/test-fixtures/reference-exhibits/shared/contract-core.js b/test-fixtures/reference-exhibits/shared/contract-core.js index c5c594f..109a3db 100644 --- a/test-fixtures/reference-exhibits/shared/contract-core.js +++ b/test-fixtures/reference-exhibits/shared/contract-core.js @@ -1,5 +1,5 @@ /* - * XZBT Exhibit Contract 5.2 — generic contract core. + * XZBT Exhibit Contract 5.3 — generic contract core. * * WHY THIS FILE IS GENERIC * ------------------------ @@ -26,25 +26,33 @@ 'use strict'; var CONTRACT_MAJOR = 5; - var CONTRACT_MINOR = 2; - var XZBT_VERSION = '5.2'; + var CONTRACT_MINOR = 3; + var XZBT_VERSION = '5.3'; /* - * Contract 5.3 presentation-surface support (additive, optional). + * Contract 5.3 presentation-surface support. * - * Every existing exhibit that does not pass `contractMinor`, `xzbtVersion`, - * or `surfaces` to ContractCore gets byte-identical behavior to before this - * addition: the defaults below equal the pre-5.3 constants exactly, and - * `describe()` omits the `surfaces` key entirely unless a SurfaceCatalog - * was supplied. This file remains domain-free; it knows the *shape* of - * Contract 5.3 Section 31, not any exhibit's surface content. + * `surfaces` remains OPTIONAL per-exhibit: an exhibit that does not supply + * a SurfaceCatalog simply gets no `surfaces` key in `describe()` at all + * (Contract 5.3 §7). This file remains domain-free; it knows the *shape* + * of Contract 5.3 Section 31, not any exhibit's surface content. + * + * `CONTRACT_MINOR`/`XZBT_VERSION` above are this module's defaults, used by + * any exhibit that does not pass its own `contractMinor`/`xzbtVersion` to + * ContractCore. XZBT-NGN's maintained exhibit set moves forward with the + * contract (Contract 5.3 Changelog, §39): there is no standing requirement + * to keep a *currently maintained* exhibit frozen on an old minor version + * merely because it once shipped against it. An exhibit that has a real, + * independently justified reason to stay on an older minor may still pass + * an explicit lower `contractMinor`/`xzbtVersion` — nothing here prevents + * that — but it is no longer the silent default. */ - /* Contract 5.2 §15. The exhibit assigns source at its own trusted + /* Contract §15. The exhibit assigns source at its own trusted * boundary; a source supplied by a caller is never trusted. */ var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system']; - /* Contract 5.2 §24. */ + /* Contract §24. */ var ERROR_CODES = [ 'UNSUPPORTED_VERSION', 'INVALID_MESSAGE', @@ -59,10 +67,10 @@ 'INTERNAL_ERROR' ]; - /* Contract 5.2 §17. */ + /* Contract §17. */ var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error']; - /* Contract 5.2 §16. The base event set is closed; exhibits do not invent + /* Contract §16. The base event set is closed; exhibits do not invent * new canonical event types. */ var EVENT_TYPES = [ 'state.changed', @@ -746,14 +754,13 @@ if (!isPlainObject(message)) { return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.'); } - if (message.xzbt !== this.xzbtVersion) { - return this._errorEnvelope( - message.requestId || null, - message.sessionId || null, - 'UNSUPPORTED_VERSION', - 'This exhibit implements contract ' + this.xzbtVersion + '.' - ); - } + /* `xzbt` is advisory metadata, not a compatibility gate (Contract §6.5): + * a 5.3-aware host must still interoperate with a 5.2 exhibit and vice + * versa. Real compatibility is negotiated by handleHello below via + * `supportedContractMajors`/`contract.major`/`minor`; rejecting here on + * an exact string mismatch pre-empted that negotiation and made it + * impossible for a host and exhibit that both implement the contract, + * but stamp different advisory xzbt strings, to ever connect. */ if (typeof message.type !== 'string') { return this._errorEnvelope( message.requestId || null, diff --git a/test-fixtures/reference-exhibits/shared/host-transport.js b/test-fixtures/reference-exhibits/shared/host-transport.js index f1baeef..f42c2c9 100644 --- a/test-fixtures/reference-exhibits/shared/host-transport.js +++ b/test-fixtures/reference-exhibits/shared/host-transport.js @@ -1,5 +1,5 @@ /* - * XZBT Exhibit Contract 5.2 — same-origin postMessage host transport. + * XZBT Exhibit Contract 5.3 — same-origin postMessage host transport. * * This is layer 6 of the Authoring Guide's recommended separation, and it is * deliberately the thinnest file in the project. It knows how to move @@ -61,7 +61,15 @@ var message = event.data; if (!message || typeof message !== 'object') return; - if (message.xzbt !== window.XZBTContractCore.VERSION) return; + /* `xzbt` is advisory metadata, not a version gate (Contract §6.5): real + * compatibility is negotiated by ContractCore.handleRequest/handleHello + * via contract major/minor, not by string-matching the caller's + * advisory tag against this exhibit's own static module version here. + * (This used to compare against window.XZBTContractCore.VERSION, the + * shared module's fixed default -- never the per-instance version an + * exhibit actually negotiates -- so it silently discarded every + * message from any host whose advisory tag didn't happen to equal + * that hardcoded default, including well-formed hellos.) */ /* Source is assigned here, at the trusted receiving boundary. */ var response = this.core.handleRequest(message, 'host'); diff --git a/tests/connection.test.js b/tests/connection.test.js index 0e6c34e..f662d2e 100644 --- a/tests/connection.test.js +++ b/tests/connection.test.js @@ -50,6 +50,19 @@ test('stalled document load ends with recoverable error', async t => { assert.equal(errors[0].code, 'LOAD_TIMEOUT'); connection.reconnect(); assert.equal(frames.length, 2); }); + +test('redirected control document supplies the final same-origin base; foreign navigation is refused', t => { + const { connection, frames, connections, errors } = setup(t); + connection.load('/public/entry.html'); + frames[0].contentWindow = { location: { href: base + 'public/gallery/control.html' } }; + frames[0].handlers.load(); + assert.equal(connection.url, base + 'public/gallery/control.html'); + assert.equal(connections.length, 1); + frames[0].contentWindow.location.href = 'https://other.example/'; + frames[0].handlers.load(); + assert.equal(connection.frame, null); assert.equal(errors[0].code, 'INVALID_LOCATION'); + assert.equal(connections.length, 1); +}); test('optional descriptor metadata is permissive when omitted and typed when supplied', () => { const message = { exhibit: {}, contract: { major: 5 }, registryRevision: 0, stateRevision: 0, capabilities: [], targets: [{ id: 'sample.enabled', kind: 'state', readable: true, writable: true, valueType: 'boolean', requires: [] }] }; diff --git a/tests/local-surfaces.test.js b/tests/local-surfaces.test.js new file mode 100644 index 0000000..0753daf --- /dev/null +++ b/tests/local-surfaces.test.js @@ -0,0 +1,296 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { readFileSync } from 'node:fs'; +import { ExhibitHost } from '../src/host.js'; +import { LocalSurfaces, checkSurfaceResource } from '../src/local-surfaces.js'; +import { resolveSurfaceURL } from '../src/surface-url.js'; +import { postMessageTransport } from '../src/transport/post-message.js'; +import { createServer } from '../server/serve.js'; +import { createServer as createHttpServer } from 'node:http'; + +const base = 'http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html'; +const directory = new URL('.', base).href; +for (const [reference, expected] of [ + ['artifact.html', directory + 'artifact.html'], ['./artifact.html', directory + 'artifact.html'], + ['../foo/bar.html', new URL('../foo/bar.html', base).href], + ['?mode=wall', base + '?mode=wall'], ['#section', base + '#section'], + ['room/../artifact.html', directory + 'artifact.html'] +]) test(`surface URL resolves ${reference} against supplying exhibit`, () => assert.equal(resolveSurfaceURL(reference, base), expected)); + +test('surface URL rejects invalid, absolute, protocol-relative and cross-origin forms', () => { + for (const value of [null, {}, 1, '', ' ', 'https://evil.example/', base, '//evil.example/', + '\\\\evil.example/x', '/\\evil.example/x', ' javascript:alert(1)', 'java\nscript:alert(1)']) { + assert.throws(() => resolveSurfaceURL(value, base), undefined, String(value)); + } + assert.throws(() => resolveSurfaceURL('view.html', 'data:text/html,test')); +}); + +function galleryCatalog() { + const context = vm.createContext({ window: {}, console, Date, Math, setTimeout, clearTimeout }); + for (const file of ['shared/contract-core.js', 'museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js']) { + vm.runInContext(readFileSync(new URL('../test-fixtures/reference-exhibits/' + file, import.meta.url), 'utf8'), context); + } + const core = context.window.MuseumGalleryContract.create(new context.window.MuseumGalleryExhibit.Gallery()); + return JSON.parse(JSON.stringify(core.describe().surfaces)); +} + +/** SciFi-XZBT's contract-adapter.js is self-contained (no shared + * contract-core.js dependency, unlike Museum Gallery); its own + * surface-mode.js supplies the real, production two-entry catalog. */ +function sciFiCatalog() { + const context = vm.createContext({ window: {}, console, Date, Math, setTimeout, clearTimeout, URLSearchParams }); + for (const file of ['scifi/js/surface-mode.js', 'scifi/js/contract-adapter.js']) { + vm.runInContext(readFileSync(new URL('../test-fixtures/reference-exhibits/' + file, import.meta.url), 'utf8'), context); + } + const surfaces = context.window.XZBTSurfaceMode.SURFACES('xi-test-instance'); + const adapter = new context.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', surfaces, instanceId: 'xi-test-instance', bindings: {} }); + return JSON.parse(JSON.stringify(adapter.describe().surfaces)); +} +function setup(t, options = {}) { + const host = new ExhibitHost(); + Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() }); + const frames = []; + const manager = new LocalSurfaces({ host, checkResource: async () => {}, ...options, + createFrame(entry) { + const frame = new EventTarget(); + frame.contentWindow = { location: { href: entry.url } }; + frame.isConnected = true; + frame.remove = () => { frame.isConnected = false; }; + frames.push(frame); return frame; + } + }); + t.after(() => manager.dispose()); + const secondary = host.surfaces.find(s => !s.primary).id; + return { host, manager, frames, secondary }; +} +test('real Museum descriptors: primary reuses control; opening, duplicate open, reload, close and reopen', async t => { + const { host, manager, frames, secondary } = setup(t); + const primary = host.surfaces.find(s => s.primary).id; + assert.equal(manager.entries.get(primary).state, 'control'); + await manager.open(primary); manager.close(primary); await manager.reload(primary); + assert.equal(frames.length, 0); + const opening = manager.open(secondary); + assert.equal(manager.entries.get(secondary).state, 'loading'); + await manager.open(secondary); await opening; + assert.equal(frames.length, 1); + assert.equal(frames[0].src, resolveSurfaceURL(host.surfaces.find(s => s.id === secondary).url, base)); + frames[0].dispatchEvent(new Event('load')); + assert.equal(manager.entries.get(secondary).state, 'open'); + await manager.open(secondary); assert.equal(frames.length, 1); + await manager.reload(secondary); assert.equal(frames[0].isConnected, false); + frames[0].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'loading'); + frames[1].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'open'); + manager.close(secondary); assert.equal(manager.entries.get(secondary).state, 'closed'); + assert.equal(frames[1].isConnected, false); + await manager.open(secondary); assert.equal(frames.length, 3); + assert.equal(host.sessionId, 'control-session'); assert.equal(host.sync, 'synchronized'); +}); +test('release() invokes a frame\'s __xzbtSurfaceDispose hook synchronously before removing it, on reload, close and disconnect', async t => { + // Deterministic lifecycle fix (Step 6.5B): local-surfaces.js does not know + // or care what an exhibit's own attach/detach protocol looks like -- it + // just gives a same-origin frame one last synchronous chance to clean + // itself up, via a well-known optional global, before the frame is + // removed. This proves the call happens, happens before removal, and + // that a frame with no such hook (or one that throws) is unaffected. + const host = new ExhibitHost(); + Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() }); + const frames = []; + const disposeCalls = []; + const manager = new LocalSurfaces({ + host, checkResource: async () => {}, + createFrame(entry) { + const frame = new EventTarget(); + const order = []; + frame.contentWindow = { + location: { href: entry.url }, + __xzbtSurfaceDispose: () => { order.push('dispose'); disposeCalls.push(entry.url); } + }; + frame.isConnected = true; + frame.remove = () => { order.push('remove'); frame.isConnected = false; frame._order = order; }; + frames.push(frame); return frame; + } + }); + t.after(() => manager.dispose()); + const secondary = host.surfaces.find(s => !s.primary).id; + + await manager.open(secondary); + frames[0].dispatchEvent(new Event('load')); + await manager.reload(secondary); + assert.deepEqual(frames[0]._order, ['dispose', 'remove'], 'dispose runs before remove on reload'); + assert.equal(disposeCalls.length, 1); + + frames[1].dispatchEvent(new Event('load')); + manager.close(secondary); + assert.deepEqual(frames[1]._order, ['dispose', 'remove'], 'dispose runs before remove on close'); + assert.equal(disposeCalls.length, 2); + + await manager.open(secondary); + frames[2].dispatchEvent(new Event('load')); + host.disconnect(); + assert.deepEqual(frames[2]._order, ['dispose', 'remove'], 'dispose runs before remove on disconnect/exhibit-switch teardown'); + assert.equal(disposeCalls.length, 3); +}); +test('release() tolerates frames with no dispose hook, and a throwing hook does not block or corrupt teardown', async t => { + const host = new ExhibitHost(); + Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() }); + const frames = []; + let throwingHookCalls = 0; + const manager = new LocalSurfaces({ + host, checkResource: async () => {}, + createFrame(entry) { + const frame = new EventTarget(); + // No __xzbtSurfaceDispose at all -- matches every non-Museum-Gallery + // reference exhibit local-surfaces.js also serves. + frame.contentWindow = { location: { href: entry.url } }; + frame.isConnected = true; + frame.remove = () => { frame.isConnected = false; }; + frames.push(frame); return frame; + } + }); + t.after(() => manager.dispose()); + const secondary = host.surfaces.find(s => !s.primary).id; + await manager.open(secondary); + frames[0].dispatchEvent(new Event('load')); + await manager.reload(secondary); // must not throw despite no hook present + assert.equal(frames[0].isConnected, false); + assert.equal(manager.entries.get(secondary).state, 'loading'); + + frames[1].contentWindow.__xzbtSurfaceDispose = () => { throwingHookCalls += 1; throw new Error('boom'); }; + frames[1].dispatchEvent(new Event('load')); + manager.close(secondary); // a throwing hook must not prevent removal or the state transition + assert.equal(throwingHookCalls, 1); + assert.equal(frames[1].isConnected, false); + assert.equal(manager.entries.get(secondary).state, 'closed'); +}); +test('a separate primary page and query/fragment views are rendered from generic descriptors', async t => { + const { host, manager, frames } = setup(t); + for (const url of ['primary.html', '?mode=wall', '#primary']) { + host.surfaces = [{ id: 'other.main', label: 'Another primary', primary: true, kind: 'surface', url }]; host.changed(); + assert.equal(manager.entries.get('other.main').state, 'closed'); + await manager.open('other.main'); assert.equal(frames.at(-1).src, resolveSurfaceURL(url, base)); + } +}); +test('real SciFi-XZBT descriptors: the primary resolves to the already-open control pane and opens no second frame', async t => { + const sciFiBase = 'http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html'; + const host = new ExhibitHost(); + Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: sciFiBase, surfaces: sciFiCatalog() }); + const frames = []; + const manager = new LocalSurfaces({ + host, checkResource: async () => {}, + createFrame(entry) { + const frame = new EventTarget(); + frame.contentWindow = { location: { href: entry.url } }; + frame.isConnected = true; + frame.remove = () => { frame.isConnected = false; }; + frames.push(frame); return frame; + } + }); + t.after(() => manager.dispose()); + const primary = host.surfaces.find(s => s.primary).id; + const observation = host.surfaces.find(s => !s.primary).id; + assert.equal(primary, 'surface.console'); + assert.equal(observation, 'surface.observation'); + // The primary's url ('index.html') resolves to exactly exhibitBaseUrl, so + // it is recognized as the already-open control pane -- opening it must + // not create a second frame (report §4 "Why url: 'index.html' for the primary"). + assert.equal(manager.entries.get(primary).state, 'control'); + await manager.open(primary); + assert.equal(frames.length, 0, 'the primary must never be opened as a second frame'); + + // The observation surface is a bare query string against the same base + // and opens normally as any other secondary surface would. + await manager.open(observation); + assert.equal(frames.length, 1); + assert.equal(frames[0].src, resolveSurfaceURL(host.surfaces.find(s => s.id === observation).url, sciFiBase)); + assert.ok(frames[0].src.includes('?surface=observation&xi=')); +}); +test('disconnect and exhibit switch release all frames and pending opens', async t => { + const { host, manager, frames, secondary } = setup(t); + await manager.open(secondary); + host.disconnect(); assert.equal(manager.entries.size, 0); assert.equal(frames[0].isConnected, false); + Object.assign(host, { status: 'connected', exhibitBaseUrl: base, surfaces: galleryCatalog() }); host.changed(); + await manager.open(secondary); + host.exhibitBaseUrl = new URL('../another/control.html', base).href; host.changed(); + assert.equal(frames[1].isConnected, false); + assert.equal(manager.entries.get(secondary).state, 'closed'); + let finish; + manager.checkResource = () => new Promise(resolve => { finish = resolve; }); + const pending = manager.open(secondary); + host.disconnect(); finish(); await pending; + assert.equal(frames.length, 2); assert.equal(manager.entries.size, 0); +}); +test('rediscovery preserves unchanged frames and closes removed/changed descriptors', async t => { + const { host, manager, frames, secondary } = setup(t); + await manager.open(secondary); + host.surfaces = structuredClone(host.surfaces); host.changed(); + assert.equal(manager.entries.get(secondary).frame, frames[0]); + host.surfaces = host.surfaces.map(s => s.id === secondary ? { ...s, url: 'other.html' } : s); host.changed(); + assert.equal(frames[0].isConnected, false); + await manager.open(secondary); + host.surfaces = host.surfaces.filter(s => s.id !== secondary); host.changed(); + assert.equal(frames[1].isConnected, false); +}); +test('HTTP, frame and reload failures are isolated; external removal is detected', async t => { + const { host, manager, frames, secondary } = setup(t); + manager.checkResource = async () => { throw new Error('HTTP 404'); }; + await manager.open(secondary); assert.equal(manager.entries.get(secondary).state, 'error'); + assert.match(manager.entries.get(secondary).error, /404/); + manager.checkResource = async () => {}; + await manager.reload(secondary); frames[0].dispatchEvent(new Event('error')); + assert.equal(manager.entries.get(secondary).state, 'error'); + await manager.reload(secondary); frames[1].contentWindow.location.href = 'https://evil.example/'; + frames[1].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'error'); + await manager.reload(secondary); frames[2].remove(); manager.sweep(); + assert.equal(manager.entries.get(secondary).state, 'closed'); + assert.equal(host.status, 'connected'); assert.equal(host.sync, 'synchronized'); +}); +test('stalled surface resource fails without harming the session', async t => { + const { host, manager, secondary } = setup(t, { loadTimeoutMs: 5, checkResource: () => new Promise(() => {}) }); + void manager.open(secondary); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(manager.entries.get(secondary).state, 'error'); + assert.match(manager.entries.get(secondary).error, /timed out/); + assert.equal(host.status, 'connected'); +}); +test('malformed surfaces and invalid primary catalogs do not change authoritative session', t => { + const { host, manager } = setup(t); + for (const metadata of [{ requires: 'bad' }, { requires: [{}] }, { role: {} }, { description: [] }]) { + host.surfaces = [...galleryCatalog(), { id: 'bad.optional', label: 'Bad', kind: 'surface', primary: false, url: 'bad.html', ...metadata }]; host.changed(); + assert.equal(manager.entries.size, 3); + } + host.surfaces = [...host.surfaces, { id: 'bad.view', url: '//evil.example/' }]; host.changed(); + assert.equal(manager.entries.size, 3); + host.surfaces = host.surfaces.map(s => ({ ...s, primary: false })); host.changed(); + assert.equal(manager.entries.size, 0); assert.equal(host.sessionId, 'control-session'); +}); +test('resource check refuses redirects before frame navigation', async t => { + let foreignRequests = 0; + const foreign = createHttpServer((_req, res) => { foreignRequests++; res.end('external'); }); + await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve)); + const server = createHttpServer((_req, res) => { + res.writeHead(302, { Location: `http://127.0.0.1:${foreign.address().port}/` }); res.end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => Promise.all([server, foreign].map(s => new Promise(resolve => s.close(resolve))))); + await assert.rejects(checkSurfaceResource(`http://127.0.0.1:${server.address().port}/`)); + assert.equal(foreignRequests, 0); +}); +test('presentation window cannot impersonate authoritative peer over production transport', () => { + const parent = new EventTarget(); parent.location = { href: base, origin: new URL(base).origin }; + const control = {}, surface = {}, received = []; + const transport = postMessageTransport({ src: base, contentWindow: control }, parent); + transport.subscribe(message => received.push(message)); + for (const source of [surface, control]) { + const event = new Event('message'); Object.assign(event, { source, origin: parent.location.origin, data: { type: 'state.changed' } }); parent.dispatchEvent(event); + } + assert.equal(received.length, 1); transport.close(); +}); +test('real static server resource check accepts Museum pages and rejects missing/outside mounts', async t => { + const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => server.close(resolve))); + const origin = `http://127.0.0.1:${server.address().port}`; + for (const descriptor of galleryCatalog()) await checkSurfaceResource(resolveSurfaceURL(descriptor.url, origin + new URL(base).pathname)); + await assert.rejects(checkSurfaceResource(origin + '/test-fixtures/missing-surface.html'), /404/); + await assert.rejects(checkSurfaceResource(origin + '/README.md'), /404/); +}); diff --git a/tests/museum-gallery.test.js b/tests/museum-gallery.test.js index bab0084..66e689a 100644 --- a/tests/museum-gallery.test.js +++ b/tests/museum-gallery.test.js @@ -41,7 +41,7 @@ function makeContext() { }); } -function makeOwner() { +function makeOwner(options) { const ctx = makeContext(); vm.runInContext(source(SHARED, 'contract-core.js'), ctx); for (const file of ['exhibit.js', 'contract-adapter.js', 'surface-bus.js']) { @@ -49,7 +49,7 @@ function makeOwner() { } const gallery = new ctx.window.MuseumGalleryExhibit.Gallery(); const core = ctx.window.MuseumGalleryContract.create(gallery); - const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core); + const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core, options); return { ctx, gallery, core, bus }; } @@ -162,7 +162,7 @@ test('Non-surface-aware exhibits are unaffected: describe() omits `surfaces` ent const description = plain(core.describe()); assert.equal('surfaces' in description, false); assert.equal(description.contract.major, 5); - assert.equal(description.contract.minor, 2, 'defaults are byte-identical to pre-5.3 behavior'); + assert.equal(description.contract.minor, 3, 'shared contract-core.js now defaults every exhibit forward to Contract 5.3; surfaces stays opt-in regardless'); }); /* ------------------------------------------------------------------ * @@ -362,6 +362,59 @@ test('Event sequence remains one stream regardless of which surface originated t } }); +test('Attachment triggers connection callback without state mutation', async () => { + const counts = []; + let detached; + const detachNotification = new Promise((resolve) => { detached = resolve; }); + const { core, bus } = makeOwner({ + onConnectionChange(count) { + assert.equal(count, bus.attachedCount(), 'callback reports the current owner count'); + counts.push(count); + if (count === 0) detached(); + } + }); + let surface; + try { + const before = plain(core.stateSnapshot()); + const eventsBefore = plain(core.eventLog()); + assert.equal(bus.attachedCount(), 0); + assert.deepEqual(counts, []); + + surface = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 1); + assert.deepEqual(counts, [1]); + assert.deepEqual(plain(core.stateSnapshot()), before); + assert.deepEqual(plain(core.eventLog()), eventsBefore); + + surface.link.detach(); + surface = undefined; + let timer; + try { + await Promise.race([ + detachNotification, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('detach callback timed out')), 500); + }) + ]); + } finally { + clearTimeout(timer); + } + assert.equal(bus.attachedCount(), 0); + assert.deepEqual(counts, [1, 0]); + + // Irrelevant messages and an extra detach at zero are not count changes. + for (const data of [null, { type: 'unrelated' }, { type: 'detach' }]) { + bus.channel.onmessage({ data }); + } + assert.equal(bus.attachedCount(), 0); + assert.deepEqual(counts, [1, 0]); + assert.deepEqual(plain(core.stateSnapshot()), before); + assert.deepEqual(plain(core.eventLog()), eventsBefore, 'no fake core events'); + } finally { + closeAll(bus, surface); + } +}); + test('Detach does not mutate state, and a later reattach still observes it correctly', async () => { const { core, bus } = makeOwner(); let first, second; @@ -421,3 +474,191 @@ test('A non-primary surface opened without the Control Room present times out an assert.equal(ctx.window.MuseumGalleryContract, undefined); closeAll({ link }); }); + +/* ------------------------------------------------------------------ * + * Participant lifecycle bookkeeping (Step 6.5B live defect regression) + * + * These simulate, at the surface-bus level, exactly what NGN's + * local-surfaces.js now does around a frame reload/close/reopen: it calls + * the frame's `__xzbtSurfaceDispose` hook (== the surface's own + * `link.detach()`) synchronously before removing the old iframe, and a + * reload/reopen produces a brand-new attach() call with its own fresh + * participant identity, exactly as a freshly-loaded document would. + * ------------------------------------------------------------------ */ + +/** Simulates NGN reloading/replacing one secondary's iframe: dispose the + * old attachment (as local-surfaces.js's release() does before removing + * the frame), then attach a fresh one (as the reloaded document does). */ +async function simulateReload(oldSurface) { + oldSurface.link.detach(); + return attachSurface(makeSurfaceContext()); +} + +test('Participant lifecycle: two secondaries attach to a count of exactly 2', async () => { + const { bus } = makeOwner(); + let a, b; + try { + assert.equal(bus.attachedCount(), 0); + a = await attachSurface(makeSurfaceContext()); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2); + } finally { + closeAll(bus, a, b); + } +}); + +test('Participant lifecycle: reloading one secondary does not ratchet the count up', async () => { + const { bus } = makeOwner(); + let a, b; + try { + a = await attachSurface(makeSurfaceContext()); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2); + + a = await simulateReload(a); + assert.equal(bus.attachedCount(), 2, 'reload retires the old participant and adopts the new one -- net zero'); + } finally { + closeAll(bus, a, b); + } +}); + +test('Participant lifecycle: repeated reloads leave the count stable', async () => { + const { bus } = makeOwner(); + let a, b; + try { + a = await attachSurface(makeSurfaceContext()); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2); + + for (let i = 0; i < 5; i += 1) { + a = await simulateReload(a); + assert.equal(bus.attachedCount(), 2, `count must remain 2 after reload #${i + 1}`); + } + } finally { + closeAll(bus, a, b); + } +}); + +test('Participant lifecycle: closing a secondary decrements deterministically without waiting for a Core mutation', async () => { + const { core, bus } = makeOwner(); + let a, b; + try { + a = await attachSurface(makeSurfaceContext()); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2); + + const revisionBeforeClose = core.stateRevision; + b.link.detach(); + b = undefined; + await new Promise((r) => setTimeout(r, 20)); + + assert.equal(bus.attachedCount(), 1, 'close must decrement immediately, with no Core mutation involved'); + assert.equal(core.stateRevision, revisionBeforeClose, 'closing a secondary never mutates exhibit state'); + } finally { + closeAll(bus, a, b); + } +}); + +test('Participant lifecycle: reopening a closed secondary increments exactly once', async () => { + const { bus } = makeOwner(); + let a, b; + try { + a = await attachSurface(makeSurfaceContext()); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2); + + b.link.detach(); + b = undefined; + await new Promise((r) => setTimeout(r, 20)); + assert.equal(bus.attachedCount(), 1); + + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2, 'reopen brings the count back to 2, not higher'); + } finally { + closeAll(bus, a, b); + } +}); + +test('Participant lifecycle: repeated close/reopen never ratchets in either direction', async () => { + const { bus } = makeOwner(); + let a, b; + try { + a = await attachSurface(makeSurfaceContext()); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2); + + for (let i = 0; i < 3; i += 1) { + b.link.detach(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(bus.attachedCount(), 1, `count must be 1 after close #${i + 1}`); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2, `count must be 2 after reopen #${i + 1}`); + } + } finally { + closeAll(bus, a, b); + } +}); + +test('Participant lifecycle: duplicate/late attach and detach messages cannot inflate or underflow the count', async () => { + const { bus } = makeOwner(); + let a; + try { + a = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 1); + + // A replayed/duplicated attach for a participant id that is already + // live (e.g. a retried message) must not inflate the count -- the + // owner tracks identities in a Set, so re-adding a live id is a no-op. + bus.channel.onmessage({ data: { type: 'attach', requestId: 'dup-req', participantId: 'known-participant' } }); + bus.channel.onmessage({ data: { type: 'attach', requestId: 'dup-req', participantId: 'known-participant' } }); + assert.equal(bus.attachedCount(), 2, 'two attach messages for the SAME id count as exactly one participant'); + + bus.channel.onmessage({ data: { type: 'detach', participantId: 'known-participant' } }); + assert.equal(bus.attachedCount(), 1, 'detaching that id removes exactly the one participant it represents'); + + // A late/duplicate detach for an id that is no longer (or never was) + // present must not underflow the count. + bus.channel.onmessage({ data: { type: 'detach', participantId: 'known-participant' } }); + bus.channel.onmessage({ data: { type: 'detach', participantId: 'not-a-real-participant' } }); + assert.equal(bus.attachedCount(), 1, 'stale/unknown detach ids must not drive the count below the real count'); + + a.link.detach(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(bus.attachedCount(), 0); + + bus.channel.onmessage({ data: { type: 'detach', participantId: 'not-a-real-participant' } }); + assert.equal(bus.attachedCount(), 0, 'never goes negative or otherwise corrupts at zero'); + } finally { + closeAll(bus, a); + } +}); + +test('Participant lifecycle: synchronization is unaffected by the lifecycle bookkeeping change', async () => { + const { core, bus } = makeOwner(); + let a, b; + try { + a = await attachSurface(makeSurfaceContext()); + b = await attachSurface(makeSurfaceContext()); + assert.equal(bus.attachedCount(), 2); + + // Control mutation still reaches both mirrors. + core.applyMutation('lighting.level', 0.42, 'ui'); + await waitForEvent(a.events, (e) => e.target === 'lighting.level' && e.value === 0.42, 500); + await waitForEvent(b.events, (e) => e.target === 'lighting.level' && e.value === 0.42, 500); + + // A secondary mutation request still reaches the primary and the other + // mirror through the canonical mutation path. + a.link.mutate('set', 'labels.enabled', false); // default is true, so false is an actual change + await waitForEvent(b.events, (e) => e.target === 'labels.enabled' && e.value === false, 500); + assert.equal(core.readValue('labels.enabled'), false); + + // A reload (dispose + fresh attach) still gets a correct, current + // snapshot -- late-join/attach.snapshot behavior survives the change. + a = await simulateReload(a); + assert.equal(a.snapshot.values['lighting.level'], 0.42); + assert.equal(a.snapshot.values['labels.enabled'], false); + assert.equal(bus.attachedCount(), 2); + } finally { + closeAll(bus, a, b); + } +}); diff --git a/tests/postmessage-interop.test.js b/tests/postmessage-interop.test.js new file mode 100644 index 0000000..220acea --- /dev/null +++ b/tests/postmessage-interop.test.js @@ -0,0 +1,247 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { ExhibitHost } from '../src/host.js'; +import { postMessageTransport } from '../src/transport/post-message.js'; + +const SHARED = new URL('../test-fixtures/reference-exhibits/shared/', import.meta.url); +const ROOT = new URL('../test-fixtures/reference-exhibits/', import.meta.url); +const BASE_ORIGIN = 'http://127.0.0.1:4173'; + +function source(base, file) { + return readFileSync(new URL(file, base), 'utf8'); +} + +/** + * A REAL postMessage transport between two window-like objects -- not a + * synthetic in-memory peer that hand-crafts contract responses (see + * tests/host.test.js's peer() fixture, which fakes the wire protocol + * itself). This harness only supplies the generic browser plumbing -- + * origin-checked delivery, async dispatch, and window.source identity, + * exactly per the postMessage spec -- and lets the REAL, unmodified + * production code on both ends (src/host.js + src/transport/post-message.js + * for NGN, and the exhibit-side code under test-fixtures/reference-exhibits/) + * do 100% of the actual protocol handling. This is the only way an + * exact-string envelope-gating bug living inside an exhibit's inbound + * message filter can ever be caught by an automated test -- a hand-rolled + * peer() fixture that answers `hello` directly can never exercise that gate + * at all. An optional `rewriteOutgoing` hook lets a test mutate every + * message NGN sends before it crosses the wire, without touching src/host.js + * itself -- used below to prove the advisory `xzbt` tag really is ignored. + */ +function makeWindowPair(exhibitPath, { rewriteOutgoing } = {}) { + const ngn = { location: { origin: BASE_ORIGIN, href: BASE_ORIGIN + '/' }, listeners: new Set() }; + const exhibit = { location: { origin: BASE_ORIGIN, href: BASE_ORIGIN + exhibitPath }, listeners: new Set() }; + ngn.parent = ngn; + exhibit.parent = ngn; + for (const w of [ngn, exhibit]) { + w.addEventListener = (type, fn) => { if (type === 'message') w.listeners.add(fn); }; + w.removeEventListener = (type, fn) => { if (type === 'message') w.listeners.delete(fn); }; + } + // Real postMessage semantics: targetOrigin is checked at delivery time, + // delivery is asynchronous, and event.source is the caller's own window + // object -- never something the message payload can spoof. + exhibit.postMessage = (data, targetOrigin) => { + if (targetOrigin !== '*' && targetOrigin !== exhibit.location.origin) return; + let payload = data; + if (rewriteOutgoing) payload = rewriteOutgoing(payload) || payload; + const cloned = JSON.parse(JSON.stringify(payload)); + setTimeout(() => { for (const fn of [...exhibit.listeners]) fn({ origin: ngn.location.origin, source: ngn, data: cloned }); }, 0); + }; + ngn.postMessage = (data, targetOrigin) => { + if (targetOrigin !== '*' && targetOrigin !== ngn.location.origin) return; + const cloned = JSON.parse(JSON.stringify(data)); + setTimeout(() => { for (const fn of [...ngn.listeners]) fn({ origin: exhibit.location.origin, source: exhibit, data: cloned }); }, 0); + }; + return { ngn, exhibit }; +} + +/** Boots the real shared-core exhibit-side scripts (unmodified) in their own + * vm realm, with `window` bound to the fake-but-spec-faithful exhibit window. */ +function bootSharedCoreExhibit(exhibitWindow, files) { + const ctx = vm.createContext({ window: exhibitWindow, setTimeout, clearTimeout, console, Date, Math }); + vm.runInContext(source(SHARED, 'contract-core.js'), ctx); + vm.runInContext(source(SHARED, 'host-transport.js'), ctx); + for (const file of files) vm.runInContext(source(ROOT, file), ctx); + return ctx; +} + +async function connectThroughRealTransport({ exhibitPath, bootFiles, createCore, rewriteOutgoing }) { + const { ngn, exhibit } = makeWindowPair(exhibitPath, { rewriteOutgoing }); + const ctx = bootSharedCoreExhibit(exhibit, bootFiles); + const core = createCore(ctx); + const hostTransport = new ctx.window.XZBTHostTransport({ core }); + + const frame = { src: exhibit.location.href, contentWindow: exhibit }; + const transport = postMessageTransport(frame, ngn); + const host = new ExhibitHost({ timeoutMs: 2000 }); + await host.connect(transport, exhibit.location.href); + return { host, core, hostTransport }; +} + +/** Boots the real, self-contained SciFi-XZBT contract-adapter.js (unmodified) + * -- this adapter does NOT use the shared contract-core.js/host-transport.js + * at all; it is its own complete Contract implementation with its own + * window-message bridge (`_setupWindowBridge`). Minimal, real (not + * hand-rolled-protocol) bindings are supplied so `state.get` and `set` round + * trip through actual application-shaped state instead of the adapter's own + * empty {} default -- the bindings only stand in for SciFi's app.js/audio.js + * runtime, never for any part of the contract adapter itself. */ +function defaultValueFor(target) { + if (target.kind === 'range') return target.min; + if (target.kind === 'selection') return target.options[0].value; + if (target.kind === 'state') { + if (target.valueType === 'boolean') return false; + if (target.valueType === 'string') return ''; + return 0; + } + return null; +} + +function bootSciFiAdapter(exhibitWindow) { + const ctx = vm.createContext({ window: exhibitWindow, setTimeout, clearTimeout, console, Date, Math }); + vm.runInContext(source(ROOT, 'scifi/js/contract-adapter.js'), ctx); + const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT' }); + + const state = new Map(); + for (const target of adapter.targets.values()) { + if (target.readable && target.kind !== 'impulse') state.set(target.id, defaultValueFor(target)); + } + adapter.bindings.getState = () => Object.fromEntries(state); + adapter.bindings.setters = new Proxy({}, { get: (_t, id) => (value) => { state.set(id, value); } }); + + return { ctx, adapter }; +} + +async function connectToSciFiThroughRealTransport({ rewriteOutgoing } = {}) { + const { ngn, exhibit } = makeWindowPair('/test-fixtures/reference-exhibits/scifi/index.html', { rewriteOutgoing }); + const { adapter } = bootSciFiAdapter(exhibit); // constructor wires the real _setupWindowBridge() + + const frame = { src: exhibit.location.href, contentWindow: exhibit }; + const transport = postMessageTransport(frame, ngn); + const host = new ExhibitHost({ timeoutMs: 2000 }); + await host.connect(transport, exhibit.location.href); + return { host, adapter }; +} + +test('NGN hello reaches a real Contract 5.3 Museum Gallery iframe over the real postMessage transport, session establishes, describe surfaces surfaces', async (t) => { + const { host, hostTransport } = await connectThroughRealTransport({ + exhibitPath: '/test-fixtures/reference-exhibits/museum-gallery/control.html', + bootFiles: ['museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js'], + createCore: (ctx) => { + const gallery = new ctx.window.MuseumGalleryExhibit.Gallery(); + return ctx.window.MuseumGalleryContract.create(gallery); + } + }); + t.after(() => host.disconnect()); + + assert.equal(host.status, 'connected'); + assert.equal(hostTransport.connected, true, 'host-transport must have accepted the hello and replied'); + assert.equal(host.contract.major, 5); + assert.equal(host.contract.minor, 3); + assert.equal(host.sync, 'synchronized'); + assert.ok(Array.isArray(host.surfaces), 'describe() must surface Contract 5.3 presentation surfaces'); + const ids = host.surfaces.map((s) => s.id).sort(); + assert.deepEqual(ids, ['surface.artifact', 'surface.control', 'surface.info-wall']); +}); + +/* ------------------------------------------------------------------ * + * SciFi-XZBT: the real bridge, over the real transport, forward on + * Contract 5.3 -- this is the fix for the "hello timed out" regression. + * ------------------------------------------------------------------ */ + +test('NGN hello reaches the real SciFi-XZBT bridge over the real postMessage transport and negotiates Contract 5.3', async (t) => { + const { host, adapter } = await connectToSciFiThroughRealTransport(); + t.after(() => host.disconnect()); + + assert.equal(host.status, 'connected', 'hello must not time out against the real SciFi bridge'); + assert.equal(adapter.sessionActive, true, 'the real adapter must have accepted the hello and opened a session'); + assert.equal(host.contract.major, 5); + assert.equal(host.contract.minor, 3, 'SciFi-XZBT is a maintained exhibit and must negotiate the current contract, not stay pinned to 5.2'); + assert.equal(host.sessionId, adapter.sessionId); +}); + +test('SciFi-XZBT: describe, state.get and synchronization all complete over the real transport', async (t) => { + const { host } = await connectToSciFiThroughRealTransport(); + t.after(() => host.disconnect()); + + assert.equal(host.sync, 'synchronized', 'connect() must run describe + state.get and reach synchronized'); + assert.ok(host.catalog.length > 0, 'describe.result must report a non-empty target catalog'); + assert.ok(host.capabilities.length > 0, 'describe.result must report capabilities'); + assert.ok(host.values.size > 0, 'state.get must report readable persistent state'); + assert.ok(host.values.has('mix.master'), 'a known SciFi target must be present in the snapshot'); +}); + +test('SciFi-XZBT: a representative command (set on a harmless mixer target) round-trips after synchronization', async (t) => { + const { host } = await connectToSciFiThroughRealTransport(); + t.after(() => host.disconnect()); + + assert.equal(host.sync, 'synchronized'); + const before = host.stateRevision; + await host.set('mix.master', 0.42); + assert.equal(host.values.get('mix.master'), 0.42, 'the set value must be reflected in NGN\'s cache'); + assert.ok(host.stateRevision > before, 'a genuine value change must advance stateRevision, proving more than handshake-only connectivity'); + + await assert.doesNotReject(host.invoke('sfx.comm-badge', {}), 'a zero-argument impulse must invoke cleanly through the real bridge'); +}); + +/* ------------------------------------------------------------------ * + * Regression coverage for the exact bug class: `xzbt` must never be an + * equality gate, anywhere a maintained exhibit or the shared transport + * decides whether to accept a message. + * ------------------------------------------------------------------ */ + +test('Regression: no maintained bridge source gates on exact equality against the advisory `xzbt` field', () => { + // Matches `.xzbt ''` (in either operand order) + // for op in {===, !==, ==, !=}. This is a static guard against exactly the + // bug class fixed here: the field is metadata (Contract §6.5), never a + // condition a bridge is allowed to branch on to accept or reject a message. + const gatePattern = /\.xzbt\s*(===|!==|==|!=)\s*['"]5\.\d['"]|['"]5\.\d['"]\s*(===|!==|==|!=)\s*[\w.]*\.xzbt\b/; + + const files = [ + [SHARED, 'contract-core.js'], + [SHARED, 'host-transport.js'], + [ROOT, 'scifi/js/contract-adapter.js'], + [ROOT, 'museum-gallery/contract-adapter.js'], + [ROOT, 'haunted-house/contract-adapter.js'], + [ROOT, 'aquarium/contract-adapter.js'], + [ROOT, 'planetarium/contract-adapter.js'] + ]; + + for (const [base, file] of files) { + const text = source(base, file); + assert.ok(!gatePattern.test(text), `${file} must not gate on exact equality against the advisory xzbt field`); + } +}); + +test('Regression: Museum Gallery (shared-core) still negotiates when NGN stamps an unexpected advisory xzbt tag', async (t) => { + const { host, hostTransport } = await connectThroughRealTransport({ + exhibitPath: '/test-fixtures/reference-exhibits/museum-gallery/control.html', + bootFiles: ['museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js'], + createCore: (ctx) => { + const gallery = new ctx.window.MuseumGalleryExhibit.Gallery(); + return ctx.window.MuseumGalleryContract.create(gallery); + }, + // A caller stamping a nonsense advisory tag is still a well-formed, + // major-5-compatible request. If anything on the exhibit side were + // still comparing this string for equality, this would time out again. + rewriteOutgoing: (message) => ({ ...message, xzbt: 'not-a-real-version' }) + }); + t.after(() => host.disconnect()); + + assert.equal(host.status, 'connected', 'an unrecognized advisory xzbt tag must not block negotiation'); + assert.equal(hostTransport.connected, true); + assert.equal(host.contract.minor, 3); +}); + +test('Regression: SciFi-XZBT still negotiates when NGN stamps an unexpected advisory xzbt tag', async (t) => { + const { host, adapter } = await connectToSciFiThroughRealTransport({ + rewriteOutgoing: (message) => ({ ...message, xzbt: 'not-a-real-version' }) + }); + t.after(() => host.disconnect()); + + assert.equal(host.status, 'connected', 'an unrecognized advisory xzbt tag must not block the real SciFi bridge either'); + assert.equal(adapter.sessionActive, true); + assert.equal(host.contract.minor, 3); +}); diff --git a/tests/scifi-surfaces.test.js b/tests/scifi-surfaces.test.js new file mode 100644 index 0000000..e6014e0 --- /dev/null +++ b/tests/scifi-surfaces.test.js @@ -0,0 +1,535 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +/* + * Step 6.7B -- SciFi Observation Surface Integration. + * + * SciFi-XZBT's contract-adapter.js is self-contained (no shared + * test-fixtures/reference-exhibits/shared/contract-core.js dependency, + * unlike Museum Gallery) -- it is one IIFE publishing window.XZBTContractAdapter + * directly. surface-mode.js and surface-bus.js are likewise standalone, pure + * files with no imports, which is what makes them separately loadable and + * testable under vm here even though js/app.js (DOM-bound, ~4900 lines) is + * not. See docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md + * SS14 for the full test plan this file implements a representative subset of. + */ + +const ROOT = new URL('../test-fixtures/reference-exhibits/scifi/', import.meta.url); + +function source(file) { + return readFileSync(new URL(file, ROOT), 'utf8'); +} + +/** Strips vm-realm object identity so assert.deepEqual compares plain + * host-realm structures (same technique as museum-gallery.test.js). */ +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function makeContext(extra = {}) { + return vm.createContext({ + window: {}, + BroadcastChannel, + URLSearchParams, + setTimeout, + clearTimeout, + Math, + Date, + console, + ...extra + }); +} + +/** Loads surface-mode.js + contract-adapter.js only -- structurally + * incapable of constructing app.js's audio/AI/visualizer subsystems, + * exactly as makeSurfaceContext() does for Museum Gallery. */ +function makeAdapterContext() { + const ctx = makeContext(); + vm.runInContext(source('js/surface-mode.js'), ctx); + vm.runInContext(source('js/contract-adapter.js'), ctx); + return ctx; +} + +function sciFiCatalog(instanceId = 'xi-test-instance') { + const ctx = makeAdapterContext(); + const surfaces = ctx.window.XZBTSurfaceMode.SURFACES(instanceId); + const adapter = new ctx.window.XZBTContractAdapter({ + product: 'SciFi-XZBT', version: '5.3.0', surfaces, instanceId, bindings: {} + }); + return { ctx, adapter, surfaces: plain(surfaces), description: plain(adapter.describe()) }; +} + +function makeBusContext() { + const ctx = makeContext(); + vm.runInContext(source('js/surface-bus.js'), ctx); + return ctx; +} + +function closeAll(...handles) { + for (const h of handles) { + try { + if (!h) continue; + if (typeof h.detach === 'function') h.detach(); + else if (h.owner && typeof h.owner.detach === 'function') h.owner.detach(); + else if (h.link && typeof h.link.detach === 'function') h.link.detach(); + } catch { + /* best-effort cleanup */ + } + } +} + +function waitFor(predicate, timeoutMs = 500) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + (function poll() { + if (predicate()) return resolve(); + if (Date.now() > deadline) return reject(new Error('timed out waiting for condition')); + setTimeout(poll, 5); + })(); + }); +} + +/* ------------------------------------------------------------------ * + * 1-7: Catalog and contract conformance + * ------------------------------------------------------------------ */ + +test('describe() includes a surfaces array with exactly one primary and only documented §31.2 fields', () => { + const { description } = sciFiCatalog(); + assert.ok(Array.isArray(description.surfaces)); + assert.equal(description.surfaces.length, 2); + + const ids = description.surfaces.map((s) => s.id).sort(); + assert.deepEqual(ids, ['surface.console', 'surface.observation']); + + const primaries = description.surfaces.filter((s) => s.primary === true); + assert.equal(primaries.length, 1, 'exactly one primary surface'); + assert.equal(primaries[0].id, 'surface.console'); + assert.equal(primaries[0].url, 'index.html'); + + const ALLOWED = new Set(['id', 'label', 'kind', 'primary', 'url', 'role', 'category', + 'aspectRatio', 'requires', 'description']); + 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'); + for (const key of Object.keys(s)) { + assert.ok(ALLOWED.has(key), `unexpected field '${key}' on ${s.id}`); + } + } +}); + +test('the observation surface url is a bare query string against the same document, carrying the instance id', () => { + const { description } = sciFiCatalog('xi-abc123'); + const observation = description.surfaces.find((s) => s.id === 'surface.observation'); + assert.equal(observation.primary, false); + assert.equal(observation.url, '?surface=observation&xi=xi-abc123'); + assert.ok(!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(observation.url), 'url must not carry a scheme'); + assert.ok(observation.url.indexOf('//') !== 0, 'url must not be protocol-relative'); + assert.deepEqual(observation.requires, ['observation']); +}); + +test('constructing the adapter with no surfaces option omits `surfaces` entirely (§31.3 form 1 preserved)', () => { + const ctx = makeAdapterContext(); + const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', bindings: {} }); + const description = plain(adapter.describe()); + assert.equal('surfaces' in description, false); + assert.equal(description.contract.major, 5); + assert.equal(description.exhibit.version, '5.3.0'); +}); + +test('view.pillars and view.warp-flight are readable/writable boolean state targets and appear in the snapshot key set', () => { + const { ctx, adapter } = sciFiCatalog(); + const pillars = adapter.targets.get('view.pillars'); + const warp = adapter.targets.get('view.warp-flight'); + for (const target of [pillars, warp]) { + assert.ok(target, 'target must be registered'); + assert.equal(target.kind, 'state'); + assert.equal(target.valueType, 'boolean'); + assert.equal(target.readable, true); + assert.equal(target.writable, true); + assert.equal(target.restorable, true); + assert.equal(target.category, 'observation'); + assert.deepEqual(plain(target.requires), ['observation']); + } + const description = plain(adapter.describe()); + const ids = description.targets.map((t) => t.id); + assert.ok(ids.includes('view.pillars')); + assert.ok(ids.includes('view.warp-flight')); + void ctx; +}); + +test('registryRevision stays 1 -- the new targets are a build-time addition, not a runtime registry change', () => { + const { adapter } = sciFiCatalog(); + assert.equal(adapter.registryRevision, 1); +}); + +/* ------------------------------------------------------------------ * + * 8-9: Mode resolution (pure, no DOM) + * ------------------------------------------------------------------ */ + +test('mode resolution: no query -> console; ?surface=observation -> observation; unknown values -> console', () => { + const ctx = makeContext(); + vm.runInContext(source('js/surface-mode.js'), ctx); + const resolve = ctx.window.XZBTSurfaceMode.resolve; + + assert.equal(resolve({ search: '' }).mode, 'console'); + assert.equal(resolve({ search: '?foo=bar' }).mode, 'console'); + assert.equal(resolve({ search: '?surface=bogus' }).mode, 'console'); + assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).mode, 'observation'); + assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).instanceId, 'xi-1'); + // Order and extra params are tolerated. + assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).mode, 'observation'); + assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).instanceId, 'xi-2'); +}); + +test('channelName is instance-scoped: two instance ids yield two different channel names', () => { + const ctx = makeContext(); + vm.runInContext(source('js/surface-mode.js'), ctx); + const { channelName, newInstanceId } = ctx.window.XZBTSurfaceMode; + const a = newInstanceId(); + const b = newInstanceId(); + assert.notEqual(a, b); + assert.notEqual(channelName(a), channelName(b)); + assert.ok(channelName(a).startsWith('xzbt-scifi-surface-v1:')); +}); + +/* ------------------------------------------------------------------ * + * 10-17: Bus / synchronization (surface-bus.js against a minimal stub + * adapter -- exercises the bus mechanism itself, independent of app.js) + * ------------------------------------------------------------------ */ + +/** A minimal stand-in for XZBTContractAdapter: just enough surface for + * surface-bus.js's owner side (getContractState/applyMutation/invokeAction/ + * targets/stateRevision/registryRevision), with the same session-independent + * onLocalChange/onLocalAction contract the real adapter now provides. */ +function makeStubAdapter() { + const targets = new Map([ + ['view.pillars', { id: 'view.pillars', kind: 'state', readable: true, writable: true }], + ['preset.selected', { id: 'preset.selected', kind: 'selection', readable: true, writable: true }] + ]); + const state = { 'view.pillars': false, 'preset.selected': 'a' }; + const adapter = { + targets, + stateRevision: 0, + registryRevision: 1, + onLocalChange: null, + onLocalAction: null, + getContractState: () => ({ ...state }), + applyMutation: (id, value) => { + if (!targets.has(id)) return { ok: false }; + state[id] = value; + adapter.stateRevision += 1; + if (adapter.onLocalChange) adapter.onLocalChange(id, value, adapter.stateRevision); + return { ok: true }; + }, + invokeAction: (id, args) => { + if (adapter.onLocalAction) adapter.onLocalAction(id, args || {}); + return { ok: true }; + } + }; + return adapter; +} + +function makeOwner(channelName) { + const ctx = makeBusContext(); + const adapter = makeStubAdapter(); + const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName }); + adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev); + adapter.onLocalAction = (id, args) => owner.broadcastAction(id, args); + return { ctx, adapter, owner }; +} + +function attachSurface(channelName, timeoutMs) { + const ctx = makeBusContext(); + const changes = []; + const actions = []; + const presentations = []; + return new Promise((resolve, reject) => { + const link = ctx.window.XZBTSurfaceBus.attach({ + channelName, + timeoutMs: timeoutMs || 500, + onSnapshot: (values, stateRevision, registryRevision, presentation) => + resolve({ ctx, link, changes, actions, presentations, values: plain(values), stateRevision, registryRevision, presentation: plain(presentation) }), + onChange: (target, value, stateRevision) => changes.push({ target, value, stateRevision }), + onAction: (target, args) => actions.push({ target, args: plain(args) }), + onPresentation: (kind, payload) => presentations.push({ kind, payload: plain(payload) }), + onTimeout: () => reject(new Error('attach timed out waiting for an owner')) + }); + }); +} + +test('late join: attach after a mutation carries the current value and stateRevision in the snapshot', async () => { + const channel = 'xzbt-scifi-surface-v1:test-latejoin-' + Math.random(); + const { adapter, owner } = makeOwner(channel); + let surface; + try { + adapter.applyMutation('view.pillars', true); + surface = await attachSurface(channel); + assert.equal(surface.values['view.pillars'], true); + assert.equal(surface.stateRevision, adapter.stateRevision); + } finally { + closeAll(owner, surface); + } +}); + +test('propagation: an authority mutation reaches an attached surface as one state message with the post-mutation value', async () => { + const channel = 'xzbt-scifi-surface-v1:test-propagate-' + Math.random(); + const { adapter, owner } = makeOwner(channel); + let surface; + try { + surface = await attachSurface(channel); + adapter.applyMutation('preset.selected', 'b'); + await waitFor(() => surface.changes.some((c) => c.target === 'preset.selected' && c.value === 'b')); + const matches = surface.changes.filter((c) => c.target === 'preset.selected'); + assert.equal(matches.length, 1); + assert.equal(matches[0].stateRevision, adapter.stateRevision); + } finally { + closeAll(owner, surface); + } +}); + +test('mutation routing: a surface-side mutate results in exactly one applyMutation call and converges on a second attached surface', async () => { + const channel = 'xzbt-scifi-surface-v1:test-routing-' + Math.random(); + const { adapter, owner } = makeOwner(channel); + let applyCount = 0; + const origApply = adapter.applyMutation; + adapter.applyMutation = (...args) => { applyCount += 1; return origApply(...args); }; + let surfaceA, surfaceB; + try { + surfaceA = await attachSurface(channel); + surfaceB = await attachSurface(channel); + surfaceA.link.mutate('set', 'view.pillars', true); + await waitFor(() => surfaceB.changes.some((c) => c.target === 'view.pillars' && c.value === true)); + assert.equal(applyCount, 1); + assert.equal(adapter.getContractState()['view.pillars'], true, 'authority converged'); + assert.ok(surfaceA.changes.some((c) => c.target === 'view.pillars' && c.value === true), 'originating surface also converges (no local write)'); + } finally { + closeAll(owner, surfaceA, surfaceB); + } +}); + +test('no second authority: a surface-side mutate with no owner present is a no-op and the surface stays in the waiting state', async () => { + const channel = 'xzbt-scifi-surface-v1:test-noowner-' + Math.random(); + const ctx = makeBusContext(); + let timedOut = false; + const link = await new Promise((resolve) => { + const l = ctx.window.XZBTSurfaceBus.attach({ + channelName: channel, + timeoutMs: 60, + onSnapshot: () => resolve(l), + onTimeout: () => { timedOut = true; resolve(l); } + }); + }); + try { + assert.equal(timedOut, true); + assert.equal(link.isAttached(), false); + assert.equal(link.mutate('set', 'view.pillars', true), false, 'mutate before/without attachment is a no-op'); + } finally { + closeAll(link); + } +}); + +test('reload: re-attaching with a fresh participantId yields the current snapshot and does not ratchet the participant count', async () => { + const channel = 'xzbt-scifi-surface-v1:test-reload-' + Math.random(); + const { adapter, owner } = makeOwner(channel); + let first, second; + try { + first = await attachSurface(channel); + assert.equal(owner.attachedCount(), 1); + first.link.detach(); + await waitFor(() => owner.attachedCount() === 0); + adapter.applyMutation('view.pillars', true); + second = await attachSurface(channel); + assert.equal(owner.attachedCount(), 1, 'reload does not ratchet the count'); + assert.equal(second.values['view.pillars'], true, 'reattach observes current state'); + } finally { + closeAll(owner, first, second); + } +}); + +test('detach is idempotent, never mutates state, and cannot underflow the participant count', async () => { + const channel = 'xzbt-scifi-surface-v1:test-detach-' + Math.random(); + const { adapter, owner } = makeOwner(channel); + let surface; + try { + surface = await attachSurface(channel); + const revBefore = adapter.stateRevision; + surface.link.detach(); + surface.link.detach(); // duplicate detach must not underflow + await waitFor(() => owner.attachedCount() === 0); + assert.equal(owner.attachedCount(), 0); + assert.equal(adapter.stateRevision, revBefore, 'detaching must not itself mutate state'); + } finally { + closeAll(owner, surface); + } +}); + +test('session independence: onLocalChange fires regardless of any session concept -- the bus has no idea NGN exists', async () => { + // The stub adapter here has no sessionActive/eventSequence at all, which is + // the point: onLocalChange is a plain method call, not gated on a session, + // unlike the real adapter's _emitEvent (contract-adapter.js §5.4). + const channel = 'xzbt-scifi-surface-v1:test-sessionindep-' + Math.random(); + const { adapter, owner } = makeOwner(channel); + let surface; + try { + surface = await attachSurface(channel); + adapter.applyMutation('view.pillars', true); + await waitFor(() => surface.changes.length > 0); + assert.equal(surface.changes[0].target, 'view.pillars'); + } finally { + closeAll(owner, surface); + } +}); + +test('presentation messages (ticker, obs-activity) are relayed to attached surfaces and are not contract state', async () => { + const channel = 'xzbt-scifi-surface-v1:test-presentation-' + Math.random(); + const { owner } = makeOwner(channel); + let surface; + try { + surface = await attachSurface(channel); + owner.broadcastPresentation('ticker', { tickerText: 'HELLO WORLD' }); + owner.broadcastPresentation('obs-activity', { source: 'ambient' }); + await waitFor(() => surface.presentations.length >= 2); + assert.deepEqual(surface.presentations[0], { kind: 'ticker', payload: { type: 'presentation', kind: 'ticker', tickerText: 'HELLO WORLD' } }); + assert.equal(surface.presentations[1].kind, 'obs-activity'); + } finally { + closeAll(owner, surface); + } +}); + +/* ------------------------------------------------------------------ * + * 18: Duplicate-subsystem prevention -- construction spy on surface-bus.js + * ------------------------------------------------------------------ */ + +test('surface-bus.js never touches AudioContext, fetch or dynamic import, in either owner or surface role', async () => { + let audioContextConstructions = 0; + const ctx = vm.createContext({ + window: {}, + BroadcastChannel, + setTimeout, clearTimeout, Math, Date, console, + AudioContext: class { constructor() { audioContextConstructions += 1; } }, + fetch: () => { throw new Error('surface-bus.js must never fetch'); } + }); + vm.runInContext(source('js/surface-bus.js'), ctx); + const channel = 'xzbt-scifi-surface-v1:test-spy-' + Math.random(); + const adapter = makeStubAdapter(); + const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName: channel }); + adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev); + let link; + try { + link = await new Promise((resolve, reject) => { + const l = ctx.window.XZBTSurfaceBus.attach({ + channelName: channel, timeoutMs: 500, + onSnapshot: () => resolve(l), + onTimeout: () => reject(new Error('attach timed out')) + }); + }); + link.mutate('set', 'view.pillars', true); + await waitFor(() => adapter.getContractState()['view.pillars'] === true); + assert.equal(audioContextConstructions, 0); + } finally { + closeAll(owner, link); + } +}); + +/* ------------------------------------------------------------------ * + * 19-21: app.js is too DOM-bound for vm (per the architecture doc's own + * assessment) -- these are asserted as source-structure tests, the same + * technique museum-gallery.test.js uses for "exactly one Exhibit State + * Core exists". + * ------------------------------------------------------------------ */ + +function appJsSource() { + return source('js/app.js'); +} + +test('source structure: enterObservation() only calls prepareExperience() inside an isConsoleMode guard', () => { + const src = appJsSource(); + const fnStart = src.indexOf('function enterObservation()'); + assert.ok(fnStart >= 0, 'enterObservation() must exist'); + const fnEnd = src.indexOf('\n function exitObservation()', fnStart); + assert.ok(fnEnd > fnStart); + const body = src.slice(fnStart, fnEnd); + // Search for the real call site, not the word appearing in a comment. + const callIdx = body.indexOf('generativeExperience.prepareExperience()'); + assert.ok(callIdx >= 0, 'enterObservation() must still prepare the generative experience somewhere'); + const guardIdx = body.lastIndexOf('if (isConsoleMode) {', callIdx); + assert.ok(guardIdx >= 0 && guardIdx < callIdx, 'prepareExperience() must be reached only through an isConsoleMode guard'); + // The guard's closing brace must come after the call (i.e. the call is + // actually nested inside the guard, not merely preceded by one elsewhere). + const closeIdx = body.indexOf('\n }', callIdx); + assert.ok(closeIdx > callIdx, 'the isConsoleMode guard must close after the prepareExperience() call'); +}); + +test('source structure: XZBTGenerativeExperience, XZBTContractAdapter, XZBTControlBus and StarshipVisualizer construction sites are each reached only through an isConsoleMode check', () => { + const src = appJsSource(); + for (const ctor of ['new XZBTGenerativeExperience(', 'new XZBTControlBus(', 'new XZBTContractAdapter(', 'new StarshipVisualizer(']) { + const idx = src.indexOf(ctor); + assert.ok(idx >= 0, `${ctor} construction site must exist`); + // The isConsoleMode identifier (either as an if-guard or a ternary + // condition) must appear on a line at or before the construction site, + // within a reasonably tight window -- resilient to exact formatting, + // unlike a brittle single-line regex. + const window_ = src.slice(Math.max(0, idx - 400), idx); + assert.ok(window_.includes('isConsoleMode'), `${ctor} must be structurally gated on isConsoleMode`); + } +}); + +test('source structure: the keydown hotkey listener is installed only inside an isConsoleMode guard', () => { + const src = appJsSource(); + const idx = src.indexOf("window.addEventListener('keydown'"); + assert.ok(idx >= 0); + const before = src.slice(Math.max(0, idx - 200), idx); + assert.ok(before.includes('if (isConsoleMode)'), 'keydown listener must be console-mode only'); +}); + +test('source structure: scheduleObservationAmbientActivity() refuses to run at all outside console mode (no independent surface-side timer)', () => { + const src = appJsSource(); + const fnStart = src.indexOf('function scheduleObservationAmbientActivity()'); + assert.ok(fnStart >= 0); + const fnBody = src.slice(fnStart, fnStart + 600); + assert.match(fnBody, /if\s*\(!isConsoleMode\)\s*return;/, 'the scheduler must early-return outside console mode'); +}); + +test('source structure: the observation-audience predicate considers attached surfaces, not just the local overlay flag', () => { + const src = appJsSource(); + assert.match(src, /observationAudienceActive\s*=\s*\(\)\s*=>\s*observationActive\s*\|\|/, 'audience-active must OR in attached-surface state'); + assert.match(src, /surfaceOwner\s*&&\s*surfaceOwner\.attachedCount\(\)\s*>\s*0/, 'must consult surfaceOwner.attachedCount()'); +}); + +test('source structure: opening the surface never sets view.observation (surface lifecycle and the console overlay flag stay distinct)', () => { + const src = appJsSource(); + // The presentation attach path's onSnapshot handler must call + // enterObservation() (unconditional local render) and must never call + // applyMutation('view.observation', ...) or mutate('set', 'view.observation', ...). + const snapshotIdx = src.indexOf('onSnapshot: (values, stateRevision, registryRevision, presentation)'); + assert.ok(snapshotIdx >= 0); + const handlerEnd = src.indexOf('onChange:', snapshotIdx); + const handlerBody = src.slice(snapshotIdx, handlerEnd); + assert.ok(handlerBody.includes('enterObservation()')); + assert.ok(!handlerBody.includes("'view.observation'"), 'the snapshot handler must never touch view.observation'); +}); + +test('source structure: presentation-side dock routing excludes view.observation from the routed target set', () => { + const src = appJsSource(); + const idx = src.indexOf('XZBT_PRESENTATION_ROUTED_TARGETS'); + assert.ok(idx >= 0); + const block = src.slice(idx, idx + 300); + assert.ok(!block.includes("'view.observation'"), 'view.observation must never be routed from the surface to the owner'); + for (const target of ['view.warp-flight', 'view.viewport-frame', 'view.pillars', 'alert.active', 'preset.selected']) { + assert.ok(block.includes(`'${target}'`), `${target} must be routed`); + } +}); + +test('source structure: contract-adapter.js exposes onLocalChange/onLocalAction independent of the sessionActive gate on _emitEvent', () => { + const src = source('js/contract-adapter.js'); + assert.match(src, /if\s*\(!this\.sessionActive\)\s*return;/, '_emitEvent\'s session gate must remain'); + assert.match(src, /this\.onLocalChange\s*\(/, 'onLocalChange must be invoked'); + assert.match(src, /this\.onLocalAction\s*\(/, 'onLocalAction must be invoked'); + // The onLocalChange call site must not itself be behind a sessionActive check. + const idx = src.indexOf('if (this.onLocalChange) {'); + assert.ok(idx >= 0); + const nearby = src.slice(Math.max(0, idx - 150), idx); + assert.ok(!nearby.includes('sessionActive'), 'onLocalChange must fire regardless of sessionActive'); +}); diff --git a/tests/surface-validation.test.js b/tests/surface-validation.test.js index 2b22169..a836530 100644 --- a/tests/surface-validation.test.js +++ b/tests/surface-validation.test.js @@ -1,9 +1,14 @@ /** * 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. + * 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';