zebra-report/test/zebra-fsm.test.js
Russell Ballestrini 6972424052
test/zebra-fsm: MuteFSM transition tests — 14 new cases (102 total)
Covers: initial state, TOGGLE, FORCE_MUTE (incl. override-while-off),
AUTO_MUTE, AUTO_UNMUTE, RESTORE_MUTED, RESTORE_UNMUTED, ROLE_PROMOTED,
ctx.source pinning per event, observer notification on every transition,
mod-mute idempotency, and the documented invariant that the FSM itself
does NOT enforce "mod-mute is sticky" — policy lives at the call site.

102/0 passing.
2026-06-04 14:14:52 -04:00

1026 lines
33 KiB
JavaScript

#!/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);