#!/usr/bin/env node /* zebra-spaces state-machine tests — extract the FSM framework + each * machine spec from web/zebra-spaces.html and drive synthetic events * through them, asserting the transition table. * * node test/zebra-fsm.test.js * * Tracks the shipped code exactly: the page IS the source of truth, the * tests just splice the relevant blocks out (same pattern as * web-protocol.test.js). When a new FSM is added to zebra-spaces.html, * add an extract() + a test block here. */ const fs = require('fs'); const path = require('path'); const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8'); /* lift a top-level function or const decl out of zebra-spaces.html by * locating its head, then brace-matching to the closing }. Returns the * full literal so it can be eval'd into a sandbox. */ function extract(re){ const m = src.match(re); if (!m) throw new Error('could not find ' + re + ' in zebra-spaces.html'); let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i; for (; j < src.length; j++){ if (src[j] === '{') depth++; else if (src[j] === '}') { if (--depth === 0) { j++; break; } } } /* for `const NAME = { ... };` we want the trailing semicolon too */ if (src[j] === ';') j++; 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 selfListenerSpecSrc = extract(/const selfListenerSpec = /); const muteSpecSrc = extract(/const muteSpec = /); 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 + '\n' + selfListenerSpecSrc + '\n' + muteSpecSrc + '\n' + wireMachinesSrc + '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, muteSpec, wireZebraMachines };' ); const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, muteSpec, wireZebraMachines } = harness(); let pass = 0, fail = 0; function test(name, fn){ try { fn(); console.log(' ✓ ' + name); pass++; } catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; } } function eq(a, b, msg){ if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b)); } function truthy(v, msg){ if (!v) throw new Error(msg || 'expected truthy'); } console.log('createFSM:'); test('starts in initial state', () => { const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } }); eq(m.state, 'a'); }); test('transitions via send', () => { const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } }); truthy(m.send('GO')); eq(m.state, 'b'); }); test('refuses unknown events', () => { const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } }); eq(m.send('NOPE'), false); eq(m.state, 'a'); }); test('refuses transitions to unknown states', () => { const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'ghost' } } } }); eq(m.send('GO'), false); eq(m.state, 'a'); }); test('fires entry and exit hooks in correct order', () => { const log = []; const m = createFSM({ initial: 'a', states: { a: { entry: () => log.push('a-enter'), exit: () => log.push('a-exit'), on: { GO: 'b' } }, b: { entry: () => log.push('b-enter') }, }, }); m.start(); m.send('GO'); eq(JSON.stringify(log), JSON.stringify(['a-enter', 'a-exit', 'b-enter'])); }); test('runs action between exit and entry', () => { const log = []; const m = createFSM({ initial: 'a', states: { a: { exit: () => log.push('a-exit'), on: { GO: { target: 'b', action: () => log.push('act') } } }, b: { entry: () => log.push('b-enter') }, }, }); m.send('GO'); eq(JSON.stringify(log), JSON.stringify(['a-exit', 'act', 'b-enter'])); }); test('action mutates context', () => { const m = createFSM({ initial: 'a', context: { count: 0 }, states: { a: { on: { BUMP: { target: 'a', action: (ctx) => { ctx.count++; } } } }, }, }); m.send('BUMP'); m.send('BUMP'); m.send('BUMP'); eq(m.context.count, 3); }); test('observers fire after transitions and see prev + ev', () => { const seen = []; const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } }); m.observe(({ state, prev, ev }) => seen.push({ state, prev, ev: ev && ev.type })); m.start(); m.send('GO'); /* first notification is the start (prev=null, ev=null); then GO */ eq(seen.length, 2); eq(seen[0].state, 'a'); eq(seen[0].prev, null); eq(seen[0].ev, null); eq(seen[1].state, 'b'); eq(seen[1].prev, 'a'); eq(seen[1].ev, 'GO'); }); test('observer error in one does not block others', () => { const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } }); m.observe(() => { throw new Error('boom'); }); let other = 0; m.observe(() => { other++; }); m.start(); m.send('GO'); truthy(other > 0, 'second observer still fires'); }); console.log('publishSpec — happy path:'); test('starts in off', () => { const m = createFSM(publishSpec); eq(m.state, 'off'); }); test('off → acquiring on START', () => { const m = createFSM(publishSpec); m.send('START'); eq(m.state, 'acquiring'); }); test('acquiring → negotiating on ACQUIRED, stream lands in ctx', () => { const m = createFSM(publishSpec); m.send('START'); const stream = { id: 'fake-stream' }; m.send('ACQUIRED', { stream }); eq(m.state, 'negotiating'); eq(m.context.stream, stream); }); test('negotiating → live on NEGOTIATED, pc + peerID land in ctx', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: {} }); const pc = { id: 'fake-pc' }; m.send('NEGOTIATED', { pc, peerID: 'peer-123' }); eq(m.state, 'live'); eq(m.context.pc, pc); eq(m.context.peerID, 'peer-123'); }); test('live → stopping on STOP', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: {} }); m.send('NEGOTIATED', { pc: {}, peerID: 'p' }); m.send('STOP'); eq(m.state, 'stopping'); }); test('live → stopping on LOST (track ended)', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: {} }); m.send('NEGOTIATED', { pc: {}, peerID: 'p' }); m.send('LOST'); eq(m.state, 'stopping'); }); test('stopping → off on DONE clears stream/pc/peerID', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: { id: 's' } }); m.send('NEGOTIATED', { pc: { id: 'p' }, peerID: 'pid' }); m.send('STOP'); m.send('DONE'); eq(m.state, 'off'); eq(m.context.stream, null); eq(m.context.pc, null); eq(m.context.peerID, null); }); console.log('publishSpec — error + cancel paths:'); test('acquiring + FAILED → off, error stored', () => { const m = createFSM(publishSpec); m.send('START'); m.send('FAILED', { error: 'NotAllowedError' }); eq(m.state, 'off'); eq(m.context.lastError, 'NotAllowedError'); }); test('acquiring + STOP → off (user cancelled before media acquired)', () => { const m = createFSM(publishSpec); m.send('START'); m.send('STOP'); eq(m.state, 'off'); }); test('negotiating + FAILED → stopping (so any acquired stream/pc gets torn down)', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: { id: 's' } }); m.send('FAILED', { error: 'sfu 403' }); eq(m.state, 'stopping'); eq(m.context.lastError, 'sfu 403'); }); test('negotiating + STOP → stopping (user cancelled mid-publish)', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: {} }); m.send('STOP'); eq(m.state, 'stopping'); }); console.log('publishSpec — illegal transitions are no-ops:'); test('off + ACQUIRED is a no-op (must START first)', () => { const m = createFSM(publishSpec); eq(m.send('ACQUIRED', { stream: {} }), false); eq(m.state, 'off'); }); test('live + ACQUIRED is a no-op (already past acquire)', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: {} }); m.send('NEGOTIATED', { pc: {}, peerID: 'p' }); eq(m.send('ACQUIRED', { stream: {} }), false); eq(m.state, 'live'); }); test('stopping + STOP is a no-op (already on the way out)', () => { const m = createFSM(publishSpec); m.send('START'); m.send('ACQUIRED', { stream: {} }); m.send('NEGOTIATED', { pc: {}, peerID: 'p' }); m.send('STOP'); eq(m.send('STOP'), false); eq(m.state, 'stopping'); }); console.log('subscribeSpec — connect:'); test('starts in off', () => { const m = createFSM(subscribeSpec); eq(m.state, 'off'); }); test('off → connecting on START', () => { const m = createFSM(subscribeSpec); m.send('START'); eq(m.state, 'connecting'); }); test('connecting → subscribed on CONNECTED, payload lands in ctx', () => { const m = createFSM(subscribeSpec); m.send('START'); const pc = { id: 'pc' }, events = { id: 'es' }; m.send('CONNECTED', { pc, peerID: 'peer-x', events }); eq(m.state, 'subscribed'); eq(m.context.pc, pc); eq(m.context.peerID, 'peer-x'); eq(m.context.events, events); }); test('connecting + FAILED → off with error', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('FAILED', { error: 'sfu 502' }); eq(m.state, 'off'); eq(m.context.lastError, 'sfu 502'); }); console.log('subscribeSpec — renegotiation queue:'); test('subscribed + RENEG → renegotiating, sdp parked on pendingOffers', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('RENEG', { sdp: 'sdp-1' }); eq(m.state, 'renegotiating'); eq(m.context.pendingOffers.length, 1); eq(m.context.pendingOffers[0], 'sdp-1'); }); test('a second RENEG while renegotiating queues the next sdp', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('RENEG', { sdp: 'sdp-1' }); m.send('RENEG', { sdp: 'sdp-2' }); m.send('RENEG', { sdp: 'sdp-3' }); eq(m.state, 'renegotiating'); eq(m.context.pendingOffers.length, 3); }); test('renegotiating + RENEG_DONE → subscribed', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('RENEG', { sdp: 'sdp-1' }); m.send('RENEG_DONE'); eq(m.state, 'subscribed'); }); test('renegotiating + RENEG_FAILED → subscribed, error captured (recoverable)', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('RENEG', { sdp: 'sdp-1' }); m.send('RENEG_FAILED', { error: 'sdp parse' }); eq(m.state, 'subscribed'); eq(m.context.lastError, 'sdp parse'); }); console.log('subscribeSpec — drops + teardown:'); test('subscribed + LOST → reconnecting', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('LOST'); eq(m.state, 'reconnecting'); }); test('reconnecting + CONNECTED → subscribed', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('LOST'); m.send('CONNECTED'); eq(m.state, 'subscribed'); }); test('renegotiating + LOST → reconnecting (drops in-flight reneg)', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('RENEG', { sdp: 'sdp-1' }); m.send('LOST'); eq(m.state, 'reconnecting'); }); test('subscribed + STOP → stopping → off (DONE clears ctx)', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: { id: 'pc' }, peerID: 'p', events: { id: 'es' } }); m.send('STOP'); eq(m.state, 'stopping'); m.send('DONE'); eq(m.state, 'off'); eq(m.context.pc, null); eq(m.context.peerID, null); eq(m.context.events, null); eq(m.context.pendingOffers.length, 0); }); console.log('subscribeSpec — illegal transitions are no-ops:'); test('off + RENEG is a no-op', () => { const m = createFSM(subscribeSpec); eq(m.send('RENEG', { sdp: 's' }), false); eq(m.state, 'off'); }); test('connecting + RENEG is a no-op (handshake not complete)', () => { const m = createFSM(subscribeSpec); m.send('START'); eq(m.send('RENEG', { sdp: 's' }), false); eq(m.state, 'connecting'); }); test('stopping + RENEG / LOST / CONNECTED are all no-ops', () => { const m = createFSM(subscribeSpec); m.send('START'); m.send('CONNECTED', { pc: {} }); m.send('STOP'); eq(m.send('RENEG', { sdp: 's' }), false); eq(m.send('LOST'), false); eq(m.send('CONNECTED'), false); eq(m.state, 'stopping'); }); console.log('remoteTileSpec — happy path:'); test('starts in inactive', () => { const m = createFSM(remoteTileSpec); eq(m.state, 'inactive'); }); test('inactive + TRACK_ARRIVED → receiving, stream stored', () => { const m = createFSM(remoteTileSpec); const stream = { id: 'remote-stream' }; m.send('TRACK_ARRIVED', { stream }); eq(m.state, 'receiving'); eq(m.context.stream, stream); }); test('receiving + MUTED → muted', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('MUTED'); eq(m.state, 'muted'); }); console.log('remoteTileSpec — mute is a debounce, not a kill:'); test('muted + UNMUTED → receiving (transient network blip recovers)', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('MUTED'); m.send('UNMUTED'); eq(m.state, 'receiving'); }); test('muted + PRUNE → removed (debounce window expired, still muted)', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('MUTED'); m.send('PRUNE'); eq(m.state, 'removed'); }); test('after recovery, mute → unmute again keeps the tile alive', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('MUTED'); m.send('UNMUTED'); m.send('MUTED'); m.send('UNMUTED'); eq(m.state, 'receiving'); }); console.log('remoteTileSpec — ENDED skips debounce:'); test('receiving + ENDED → removed directly', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('ENDED'); eq(m.state, 'removed'); }); test('muted + ENDED → removed directly', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('MUTED'); m.send('ENDED'); eq(m.state, 'removed'); }); console.log('remoteTileSpec — LEFT wipes from any live state:'); test('inactive + LEFT → removed', () => { const m = createFSM(remoteTileSpec); m.send('LEFT'); eq(m.state, 'removed'); }); test('receiving + LEFT → removed', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('LEFT'); eq(m.state, 'removed'); }); test('muted + LEFT → removed', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('MUTED'); m.send('LEFT'); eq(m.state, 'removed'); }); console.log('remoteTileSpec — publisher re-share refreshes stream:'); test('receiving + TRACK_ARRIVED swaps to new stream', () => { const m = createFSM(remoteTileSpec); const a = { id: 'a' }, b = { id: 'b' }; m.send('TRACK_ARRIVED', { stream: a }); m.send('TRACK_ARRIVED', { stream: b }); eq(m.state, 'receiving'); eq(m.context.stream, b); }); test('muted + TRACK_ARRIVED → receiving with new stream', () => { const m = createFSM(remoteTileSpec); const a = { id: 'a' }, b = { id: 'b' }; m.send('TRACK_ARRIVED', { stream: a }); m.send('MUTED'); m.send('TRACK_ARRIVED', { stream: b }); eq(m.state, 'receiving'); eq(m.context.stream, b); }); console.log('remoteTileSpec — removed is terminal:'); test('removed + every event is a no-op (need a fresh FSM)', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: {} }); m.send('ENDED'); eq(m.state, 'removed'); eq(m.send('TRACK_ARRIVED', { stream: {} }), false); eq(m.send('MUTED'), false); eq(m.send('UNMUTED'), false); eq(m.send('PRUNE'), false); eq(m.send('LEFT'), false); eq(m.state, 'removed'); }); test('entry into removed nulls the stream so the runtime can drop refs', () => { const m = createFSM(remoteTileSpec); m.send('TRACK_ARRIVED', { stream: { id: 's' } }); m.send('ENDED'); eq(m.context.stream, null); }); console.log('callSpec — join / leave happy path:'); test('starts in idle', () => { const m = createFSM(callSpec); eq(m.state, 'idle'); }); test('idle + ENTER → connecting, code+handle stored', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'test-room', handle: 'fox' }); eq(m.state, 'connecting'); eq(m.context.code, 'test-room'); eq(m.context.handle, 'fox'); }); test('connecting + WELCOME → joined, uuid + role stored', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'test', handle: 'fox' }); m.send('WELCOME', { uuid: 'u-1', role: 'host' }); eq(m.state, 'joined'); eq(m.context.uuid, 'u-1'); eq(m.context.role, 'host'); }); test('joined + LEAVE → leaving → idle on DONE', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'host' }); m.send('LEAVE'); eq(m.state, 'leaving'); m.send('DONE'); eq(m.state, 'idle'); }); test('idle entry clears uuid + role + bootedBy + bootedAction', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'host' }); m.send('LEAVE'); m.send('DONE'); eq(m.context.uuid, ''); eq(m.context.role, ''); eq(m.context.bootedBy, null); eq(m.context.bootedAction, null); }); console.log('callSpec — bootedAction propagates (kicked-vs-banned-vs-blocked UI):'); test('BOOTED with action=kick sets bootedAction=kick', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('BOOTED', { by: 'modA', action: 'kick' }); eq(m.state, 'booted'); eq(m.context.bootedAction, 'kick'); eq(m.context.bootedBy, 'modA'); }); test('BOOTED with action=ban sets bootedAction=ban', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('BOOTED', { by: 'modA', action: 'ban' }); eq(m.state, 'booted'); eq(m.context.bootedAction, 'ban'); }); test('BOOTED with action=blocked sets bootedAction=blocked (signal-server denied)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('BOOTED', { by: 'signal', action: 'blocked' }); eq(m.state, 'booted'); eq(m.context.bootedAction, 'blocked'); }); test('BOOTED with no action defaults bootedAction to kick (back-compat)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('BOOTED', { by: 'modA' }); eq(m.state, 'booted'); eq(m.context.bootedAction, 'kick'); }); test('BOOTED → ACK → idle clears bootedAction', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('BOOTED', { by: 'modA', action: 'ban' }); m.send('ACK'); eq(m.state, 'idle'); eq(m.context.bootedAction, null); }); console.log('callSpec — role transitions stay in joined:'); test('joined + ROLE_CHANGE updates role and stays in joined', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('ROLE_CHANGE', { role: 'speaker' }); eq(m.state, 'joined'); eq(m.context.role, 'speaker'); }); test('observers see ROLE_CHANGE as a transition even though state stays joined', () => { const m = createFSM(callSpec); let seenRole = ''; m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.observe(({ state, ev }) => { if (ev && ev.type === 'ROLE_CHANGE') seenRole = m.context.role; }); m.send('ROLE_CHANGE', { role: 'host' }); eq(seenRole, 'host'); }); console.log('callSpec — reconnect path:'); test('joined + WS_DROPPED → reconnecting', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'speaker' }); m.send('WS_DROPPED'); eq(m.state, 'reconnecting'); }); test('reconnecting + WELCOME → joined (role may have changed during drop)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'host' }); m.send('WS_DROPPED'); m.send('WELCOME', { role: 'cohost' }); eq(m.state, 'joined'); eq(m.context.role, 'cohost'); }); test('reconnecting + LEAVE → leaving (user gives up during outage)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'speaker' }); m.send('WS_DROPPED'); m.send('LEAVE'); eq(m.state, 'leaving'); }); console.log('callSpec — boot path:'); test('joined + BOOTED → booted, bootedBy captured', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('BOOTED', { by: 'host-uuid' }); eq(m.state, 'booted'); eq(m.context.bootedBy, 'host-uuid'); }); test('connecting + BOOTED → booted (block-listed before welcome lands)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('BOOTED', { by: 'host' }); eq(m.state, 'booted'); }); test('booted + ACK → idle (acknowledge the notice, return to entry)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'listener' }); m.send('BOOTED', { by: 'host' }); m.send('ACK'); eq(m.state, 'idle'); }); test('reconnecting + BOOTED → booted (boot can fire during outage)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'speaker' }); m.send('WS_DROPPED'); m.send('BOOTED', { by: 'host' }); eq(m.state, 'booted'); }); console.log('callSpec — connect cancel + failure:'); test('connecting + LEAVE → idle (user backed out before welcome)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('LEAVE'); eq(m.state, 'idle'); }); test('connecting + FAILED → idle, error stored', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('FAILED', { error: 'ws upgrade 502' }); eq(m.state, 'idle'); eq(m.context.lastError, 'ws upgrade 502'); }); console.log('callSpec — illegal transitions are no-ops:'); test('idle + WELCOME is a no-op (must ENTER first)', () => { const m = createFSM(callSpec); eq(m.send('WELCOME', { uuid: 'u', role: 'host' }), false); eq(m.state, 'idle'); }); test('joined + WELCOME is a no-op (already joined)', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'host' }); eq(m.send('WELCOME', { uuid: 'other', role: 'listener' }), false); eq(m.state, 'joined'); eq(m.context.uuid, 'u'); }); test('leaving + every other event is a no-op', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'host' }); m.send('LEAVE'); eq(m.send('ENTER', { code: 'x' }), false); eq(m.send('WELCOME', { uuid: 'y', role: 'speaker' }), false); eq(m.send('BOOTED', { by: 'host' }), false); 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('MuteFSM: transitions, ctx.source tracking, observer correctness:'); test('mute: initial state is on (unmuted UI = mic open)', () => { const mute = createFSM(muteSpec); eq(mute.state, 'on'); eq(mute.context.source, null); }); test('TOGGLE on → off: source = self', () => { const mute = createFSM(muteSpec); mute.send('TOGGLE'); eq(mute.state, 'off'); eq(mute.context.source, 'self'); }); test('TOGGLE off → on: clears ctx.source via on.entry', () => { const mute = createFSM(muteSpec); mute.send('TOGGLE'); /* off, source=self */ mute.send('TOGGLE'); /* back to on */ eq(mute.state, 'on'); eq(mute.context.source, null); }); test('FORCE_MUTE on → off: source = mod', () => { const mute = createFSM(muteSpec); mute.send('FORCE_MUTE'); eq(mute.state, 'off'); eq(mute.context.source, 'mod'); }); test('FORCE_MUTE while already off overwrites source to mod', () => { const mute = createFSM(muteSpec); mute.send('TOGGLE'); /* off, source=self */ mute.send('FORCE_MUTE'); /* still off, mod overrides */ eq(mute.state, 'off'); eq(mute.context.source, 'mod'); }); test('AUTO_MUTE on → off: source = self-listener', () => { const mute = createFSM(muteSpec); mute.send('AUTO_MUTE'); eq(mute.state, 'off'); eq(mute.context.source, 'self-listener'); }); test('AUTO_UNMUTE off → on regardless of source', () => { const mute = createFSM(muteSpec); mute.send('AUTO_MUTE'); /* off, source=self-listener */ mute.send('AUTO_UNMUTE'); /* back to on */ eq(mute.state, 'on'); eq(mute.context.source, null); }); test('RESTORE_MUTED off → off: source pinned to self (sessionStorage restore)', () => { const mute = createFSM(muteSpec); mute.send('RESTORE_MUTED'); eq(mute.state, 'off'); eq(mute.context.source, 'self'); }); test('RESTORE_UNMUTED on → on: idempotent fresh-session', () => { const mute = createFSM(muteSpec); mute.send('RESTORE_UNMUTED'); eq(mute.state, 'on'); eq(mute.context.source, null); }); test('RESTORE_UNMUTED off → on: page reload picks up unmuted', () => { const mute = createFSM(muteSpec); mute.send('FORCE_MUTE'); mute.send('RESTORE_UNMUTED'); eq(mute.state, 'on'); eq(mute.context.source, null); }); test('ROLE_PROMOTED on → off: source = self (listener becomes speaker muted)', () => { const mute = createFSM(muteSpec); mute.send('ROLE_PROMOTED'); eq(mute.state, 'off'); eq(mute.context.source, 'self'); }); test('observer fires on every distinct transition', () => { const mute = createFSM(muteSpec); const seen = []; mute.observe(({ state, prev, ctx, ev }) => { if (prev === null || state === prev) return; seen.push(prev + '→' + state + ':' + (ctx.source || '∅') + ':' + (ev && ev.type)); }); mute.send('TOGGLE'); /* on → off:self */ mute.send('TOGGLE'); /* off → on:∅ */ mute.send('AUTO_MUTE'); /* on → off:self-listener */ mute.send('AUTO_UNMUTE'); /* off → on:∅ */ eq(seen.length, 4); eq(seen[0], 'on→off:self:TOGGLE'); eq(seen[1], 'off→on:∅:TOGGLE'); eq(seen[2], 'on→off:self-listener:AUTO_MUTE'); eq(seen[3], 'off→on:∅:AUTO_UNMUTE'); }); test('mod-forced mute survives an unrelated event', () => { const mute = createFSM(muteSpec); mute.send('FORCE_MUTE'); mute.send('FORCE_MUTE'); /* idempotent — still off, still mod */ eq(mute.state, 'off'); eq(mute.context.source, 'mod'); }); test('user TOGGLE off a mod-forced mute returns to on (no enforcement at FSM level)', () => { /* The mute FSM intentionally does not enforce "mod-mute is sticky" — that * policy lives at the call site (only the mod's signed signed-mute message * applies the FSM event). If a TOGGLE event ever does reach the FSM, it * unmutes. Documented invariant. */ const mute = createFSM(muteSpec); mute.send('FORCE_MUTE'); /* off:mod */ mute.send('TOGGLE'); /* on:∅ */ eq(mute.state, 'on'); eq(mute.context.source, null); }); console.log('\n' + pass + ' passed, ' + fail + ' failed'); process.exit(fail === 0 ? 0 : 1);