From d9a743a680a799f941e5b1c844928ef935a5a5d1 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 11:10:16 -0400 Subject: [PATCH] zebra-spaces: wireZebraMachines orchestrator + integration tests (83 green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step five — composition layer. wireZebraMachines() returns a coherent room: - one CallFSM - one SubscribeFSM - three PublishFSMs (mic / screen / camera) - lazy Map of RemoteTileFSMs created on first tileFor(kind, pubHex) - tileLeft(pubHex) fans LEFT to every tile keyed by that publisher Observers wire transitions between machines but the orchestrator itself stays pure — no WebRTC, no DOM, no fetch. The page's runtime layers its OWN observers on top to drive real side effects, and the test extracts the orchestrator directly. Cascades modelled: - CallFSM joined (from anything except reconnecting) ── starts the sub - CallFSM reconnecting → joined does NOT re-START (sub stayed alive) - CallFSM leaving / booted ── stops sub AND every live publish - RemoteTileFSMs lazy: tileFor returns the same instance per key - tileLeft sends LEFT to every kind for that pubHex + 11 integration tests + 1 full end-to-end scenario walking through host publishes mic+screen / listener joins late / listener sees the screen / host unshares / mute+prune cycle removes the tile / listener leaves and sub stops. Total: 83 tests passing. The pure-FSM layer + orchestrator are now ready to be wired into the imperative call sites in the live runtime. That's the next step — gradually replace the firefighting code paths (sfuPublishCamera, sfuSubscribe, role transitions) by feeding events into these machines from the existing handlers, then observing state changes to invoke the side effects. Tests catch regressions on the pure layer while the QA loop catches what touches the wire. --- test/zebra-fsm.test.js | 168 +++++++++++++++++++++++++++++++++++++++-- web/zebra-spaces.html | 75 +++++++++++++++++- 2 files changed, 233 insertions(+), 10 deletions(-) diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js index 22fd1c4..673e945 100644 --- a/test/zebra-fsm.test.js +++ b/test/zebra-fsm.test.js @@ -29,20 +29,21 @@ function extract(re){ return src.slice(m.index, j); } -const createFSMSrc = extract(/function createFSM\(/); -const publishSpecSrc = extract(/const publishSpec = /); -const subscribeSpecSrc = extract(/const subscribeSpec = /); -const remoteTileSpecSrc = extract(/const remoteTileSpec = /); -const callSpecSrc = extract(/const callSpec = /); +const createFSMSrc = extract(/function createFSM\(/); +const publishSpecSrc = extract(/const publishSpec = /); +const subscribeSpecSrc = extract(/const subscribeSpec = /); +const remoteTileSpecSrc = extract(/const remoteTileSpec = /); +const callSpecSrc = extract(/const callSpec = /); +const wireMachinesSrc = extract(/function wireZebraMachines\(/); /* Function-constructor scope so `const` declarations are visible at the * harness's `return` — they would NOT leak through a bare `eval()`. */ const harness = new Function( createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + - remoteTileSpecSrc + '\n' + callSpecSrc + - '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec };' + remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + wireMachinesSrc + + '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines };' ); -const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec } = harness(); +const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines } = harness(); let pass = 0, fail = 0; function test(name, fn){ @@ -698,5 +699,156 @@ test('leaving + every other event is a no-op', () => { eq(m.state, 'leaving'); }); +console.log('\n=== integration: wireZebraMachines ===\n'); + +console.log('CallFSM joined → SubscribeFSM auto-starts:'); + +test('idle: nothing is running', () => { + const w = wireZebraMachines(); + eq(w.call.state, 'idle'); + eq(w.sub.state, 'off'); + eq(w.pubs.mic.state, 'off'); +}); + +test('ENTER → WELCOME: sub goes off → connecting', () => { + const w = wireZebraMachines(); + w.call.send('ENTER', { code: 'r', handle: 'h' }); + w.call.send('WELCOME', { uuid: 'u', role: 'host' }); + eq(w.call.state, 'joined'); + eq(w.sub.state, 'connecting'); +}); + +test('coming back from reconnecting does NOT re-START sub', () => { + const w = wireZebraMachines(); + w.call.send('ENTER', { code: 'r', handle: 'h' }); + w.call.send('WELCOME', { uuid: 'u', role: 'host' }); + w.sub.send('CONNECTED', { pc: { id: 'pc' } }); /* sub is now subscribed */ + w.call.send('WS_DROPPED'); + w.call.send('WELCOME', { role: 'host' }); /* reconnect */ + /* sub should still be subscribed — we did NOT send START again */ + eq(w.sub.state, 'subscribed'); +}); + +console.log('CallFSM leaving / booted → all live publishes get STOP:'); + +test('LEAVE while publishing mic → mic transitions to stopping', () => { + const w = wireZebraMachines(); + w.call.send('ENTER', { code: 'r', handle: 'h' }); + w.call.send('WELCOME', { uuid: 'u', role: 'host' }); + w.pubs.mic.send('START'); + w.pubs.mic.send('ACQUIRED', { stream: {} }); + w.pubs.mic.send('NEGOTIATED', { pc: {}, peerID: 'p' }); + eq(w.pubs.mic.state, 'live'); + w.call.send('LEAVE'); + eq(w.pubs.mic.state, 'stopping'); +}); + +test('LEAVE while screen is mid-acquire → screen cancels into off', () => { + const w = wireZebraMachines(); + w.call.send('ENTER', { code: 'r', handle: 'h' }); + w.call.send('WELCOME', { uuid: 'u', role: 'host' }); + w.pubs.screen.send('START'); + eq(w.pubs.screen.state, 'acquiring'); + w.call.send('LEAVE'); + eq(w.pubs.screen.state, 'off'); +}); + +test('BOOTED stops every live publish at once', () => { + const w = wireZebraMachines(); + w.call.send('ENTER', { code: 'r', handle: 'h' }); + w.call.send('WELCOME', { uuid: 'u', role: 'host' }); + for (const k of ['mic', 'screen', 'camera']){ + w.pubs[k].send('START'); + w.pubs[k].send('ACQUIRED', { stream: {} }); + w.pubs[k].send('NEGOTIATED', { pc: {}, peerID: k }); + } + w.call.send('BOOTED', { by: 'host' }); + eq(w.pubs.mic.state, 'stopping'); + eq(w.pubs.screen.state, 'stopping'); + eq(w.pubs.camera.state, 'stopping'); +}); + +test('BOOTED stops sub too', () => { + const w = wireZebraMachines(); + w.call.send('ENTER', { code: 'r', handle: 'h' }); + w.call.send('WELCOME', { uuid: 'u', role: 'host' }); + w.sub.send('CONNECTED', { pc: {} }); + w.call.send('BOOTED', { by: 'host' }); + eq(w.sub.state, 'stopping'); +}); + +console.log('RemoteTileFSMs — tileFor lazy create, tileLeft fans LEFT:'); + +test('tileFor returns same FSM for repeat calls (same kind+pubHex)', () => { + const w = wireZebraMachines(); + const a = w.tileFor('camera', 'pub1'); + const b = w.tileFor('camera', 'pub1'); + truthy(a === b, 'tileFor is idempotent per key'); +}); + +test('tileFor creates separate FSMs per kind', () => { + const w = wireZebraMachines(); + const cam = w.tileFor('camera', 'pub1'); + const scr = w.tileFor('screen', 'pub1'); + truthy(cam !== scr, 'camera ≠ screen for same pubHex'); +}); + +test('tileLeft sends LEFT to every tile keyed by that pubHex (every kind)', () => { + const w = wireZebraMachines(); + const cam = w.tileFor('camera', 'pubA'); + const scr = w.tileFor('screen', 'pubA'); + const other = w.tileFor('camera', 'pubB'); + cam.send('TRACK_ARRIVED', { stream: {} }); + scr.send('TRACK_ARRIVED', { stream: {} }); + other.send('TRACK_ARRIVED', { stream: {} }); + w.tileLeft('pubA'); + eq(cam.state, 'removed'); + eq(scr.state, 'removed'); + eq(other.state, 'receiving'); /* unrelated publisher untouched */ +}); + +console.log('end-to-end scenario: host joins, publishes mic + screen, listener sees them, screen unshares:'); + +test('full round trip across the wire', () => { + const host = wireZebraMachines(); + const listener = wireZebraMachines(); + + host.call.send('ENTER', { code: 'room-a', handle: 'host' }); + host.call.send('WELCOME', { uuid: 'host-uuid', role: 'host' }); + /* host's sub starts connecting (then would CONNECT via signal) */ + host.sub.send('CONNECTED', { pc: {} }); + /* host publishes mic */ + host.pubs.mic.send('START'); + host.pubs.mic.send('ACQUIRED', { stream: { id: 'mic-stream' } }); + host.pubs.mic.send('NEGOTIATED', { pc: {}, peerID: 'mic-peer' }); + /* host publishes screen */ + host.pubs.screen.send('START'); + host.pubs.screen.send('ACQUIRED', { stream: { id: 'scr-stream' } }); + host.pubs.screen.send('NEGOTIATED', { pc: {}, peerID: 'scr-peer' }); + + /* listener joins late */ + listener.call.send('ENTER', { code: 'room-a', handle: 'listener' }); + listener.call.send('WELCOME', { uuid: 'l-uuid', role: 'listener' }); + /* their sub connects + receives host's screen track */ + listener.sub.send('CONNECTED', { pc: {} }); + const screenTile = listener.tileFor('screen', 'host-pub-hex'); + screenTile.send('TRACK_ARRIVED', { stream: { id: 'scr-on-listener' } }); + eq(screenTile.state, 'receiving'); + + /* host stops sharing the screen */ + host.pubs.screen.send('STOP'); + host.pubs.screen.send('DONE'); + eq(host.pubs.screen.state, 'off'); + /* on the listener side the SFU stops the transceiver → track mutes + * → debounce expires → PRUNE → removed */ + screenTile.send('MUTED'); + screenTile.send('PRUNE'); + eq(screenTile.state, 'removed'); + + /* listener leaves */ + listener.call.send('LEAVE'); + eq(listener.sub.state, 'stopping'); +}); + console.log('\n' + pass + ' passed, ' + fail + ' failed'); process.exit(fail === 0 ? 0 : 1); diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index fe2f1dd..043adf1 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1056,6 +1056,77 @@ const callSpec = { }, }; +/* ================================================================== + * wireZebraMachines — orchestrator. Composes one CallFSM, one + * SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of + * RemoteTileFSMs into a coherent room. Observers wire transitions + * between machines; no side effects in this layer — the page's + * runtime attaches its OWN observers on top to drive actual WebRTC + * and DOM work. That separation keeps this function fully testable + * in Node with synthetic events. + * + * Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */ +function wireZebraMachines(){ + const call = createFSM(callSpec); + const sub = createFSM(subscribeSpec); + const pubs = { + mic: createFSM(publishSpec), + screen: createFSM(publishSpec), + camera: createFSM(publishSpec), + }; + pubs.mic.context.kind = 'mic'; + pubs.screen.context.kind = 'screen'; + pubs.camera.context.kind = 'camera'; + + /* RemoteTileFSMs keyed by `${kind}:${pubHex}` — lazily created. */ + const remoteTiles = new Map(); + function tileFor(kind, pubHex){ + const key = kind + ':' + pubHex; + let t = remoteTiles.get(key); + if (!t){ + t = createFSM(remoteTileSpec); + t.context.kind = kind; + t.context.pubHex = pubHex; + remoteTiles.set(key, t); + } + return t; + } + /* peer-left: any tile keyed by this pubHex (any kind) goes LEFT */ + function tileLeft(pubHex){ + for (const [key, t] of remoteTiles){ + if (key.endsWith(':' + pubHex)) t.send('LEFT'); + } + } + + /* CallFSM → SubscribeFSM: subscribe whenever joined, stop when not. + * Coming back from reconnecting → joined doesn't re-START because the + * SubscribeFSM stayed alive through the signal-WS drop. */ + call.observe(({ state, prev }) => { + if (state === prev) return; + if (state === 'joined' && prev !== 'reconnecting'){ + sub.send('START'); + } + if (state === 'leaving' || state === 'booted'){ + sub.send('STOP'); + } + }); + + /* CallFSM → PublishFSMs: tearing down the call stops every live publish */ + call.observe(({ state, prev }) => { + if (state === prev) return; + if (state === 'leaving' || state === 'booted'){ + for (const k of Object.keys(pubs)){ + const ps = pubs[k].state; + if (ps === 'acquiring' || ps === 'negotiating' || ps === 'live'){ + pubs[k].send('STOP'); + } + } + } + }); + + return { call, sub, pubs, remoteTiles, tileFor, tileLeft }; +} + /* ================================================================== * identity — ed25519 keypair, persisted in localStorage as JWK. * @@ -3124,8 +3195,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');