zebra-spaces: lay down FSM framework + publishSpec + unit tests

First step of the state-machine refactor. Same self-contained pattern
as the rest of the page — FSMs live inline in web/zebra-spaces.html so
the page-integrity stamp keeps working, and the tests extract them with
the same regex/brace-match technique web-protocol.test.js already uses
(page = source of truth, tests track the page).

Added:
- createFSM(spec): minimal state machine. spec.states[name] has optional
  entry/exit hooks and an .on table mapping events → target (string) or
  { target, action }. Observers fire after each transition with
  { state, prev, ev, ctx }. No async in transitions; effects belong in
  observers (which can call send() to advance the machine).
- publishSpec: pure transition table for the publish flow.
    off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
                       │ FAILED                │ FAILED                │ STOP/LOST
                       ▼                       ▼                       ▼
                      off                    stopping ◀──── stopping ──┘
                                                 │ DONE
                                                 ▼
                                                off
  One instance per kind (mic / screen / camera). FAILED in negotiating
  goes to stopping (not off) so any acquired stream/pc gets torn down.
- test/zebra-fsm.test.js: 23 unit tests covering framework semantics +
  publishSpec happy path + error/cancel paths + illegal-transition
  no-ops. Function-constructor scope handles const-leak; bare eval()
  doesn't expose const declarations to the harness.
- Makefile: test-fsm target + included in test-all.

Next: SubscribeFSM, CallFSM, RemoteTileFSM. Then wire each into the
imperative call sites progressively, replacing the firefighting code.
This commit is contained in:
Russell Ballestrini 2026-06-02 11:01:33 -04:00
parent f71e9e74c4
commit ebda460574
No known key found for this signature in database
3 changed files with 409 additions and 3 deletions

258
test/zebra-fsm.test.js Normal file
View file

@ -0,0 +1,258 @@
#!/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 = /);
/* 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 + '\nreturn { createFSM, publishSpec };'
);
const { createFSM, publishSpec } = 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('\n' + pass + ' passed, ' + fail + ' failed');
process.exit(fail === 0 ? 0 : 1);