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.
1026 lines
33 KiB
JavaScript
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);
|