Step five — composition layer. wireZebraMachines() returns a coherent room: - one CallFSM - one SubscribeFSM - three PublishFSMs (mic / screen / camera) - lazy Map of RemoteTileFSMs created on first tileFor(kind, pubHex) - tileLeft(pubHex) fans LEFT to every tile keyed by that publisher Observers wire transitions between machines but the orchestrator itself stays pure — no WebRTC, no DOM, no fetch. The page's runtime layers its OWN observers on top to drive real side effects, and the test extracts the orchestrator directly. Cascades modelled: - CallFSM joined (from anything except reconnecting) ── starts the sub - CallFSM reconnecting → joined does NOT re-START (sub stayed alive) - CallFSM leaving / booted ── stops sub AND every live publish - RemoteTileFSMs lazy: tileFor returns the same instance per key - tileLeft sends LEFT to every kind for that pubHex + 11 integration tests + 1 full end-to-end scenario walking through host publishes mic+screen / listener joins late / listener sees the screen / host unshares / mute+prune cycle removes the tile / listener leaves and sub stops. Total: 83 tests passing. The pure-FSM layer + orchestrator are now ready to be wired into the imperative call sites in the live runtime. That's the next step — gradually replace the firefighting code paths (sfuPublishCamera, sfuSubscribe, role transitions) by feeding events into these machines from the existing handlers, then observing state changes to invoke the side effects. Tests catch regressions on the pure layer while the QA loop catches what touches the wire.
854 lines
27 KiB
JavaScript
854 lines
27 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 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' + wireMachinesSrc +
|
|
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines };'
|
|
);
|
|
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, 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', () => {
|
|
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);
|
|
});
|
|
|
|
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('\n' + pass + ' passed, ' + fail + ' failed');
|
|
process.exit(fail === 0 ? 0 : 1);
|