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

View file

@ -19,6 +19,12 @@ test: test/unit
test-web:
@node test/web-protocol.test.js
# zebra-spaces state-machine tests — pure unit tests for the FSM framework
# + each machine spec (publish / subscribe / call / remote-tile). Extracts
# the live code from web/zebra-spaces.html so the tests track the page.
test-fsm:
@node test/zebra-fsm.test.js
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
# server flow. The live-server tier auto-runs when proxy.unturf.com sits
# alongside this checkout AND has a Go toolchain — we build the relay binary
@ -36,7 +42,7 @@ test-zebra-spaces:
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc
test-all: test/unit test/integration test/functional test-web test-zebra-spaces
test-all: test/unit test/integration test/functional test-web test-fsm test-zebra-spaces
@echo "--- unit ---"
@./test/unit
@echo "--- integration ---"
@ -45,6 +51,8 @@ test-all: test/unit test/integration test/functional test-web test-zebra-spaces
@./test/functional
@echo "--- web protocol ---"
@node test/web-protocol.test.js
@echo "--- zebra-fsm ---"
@node test/zebra-fsm.test.js
@echo "--- zebra-spaces ---"
@$(MAKE) -s test-zebra-spaces

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);

View file

@ -681,6 +681,146 @@ function logLine(kind, msg){
logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight;
}
/* ==================================================================
* state-machine framework — pure transitions, testable in isolation.
*
* spec = {
* initial: 'off',
* context: { ... }, // shared mutable state
* states: {
* off: { entry?, exit?, on: { START: 'acquiring' } },
* acquiring: { on: { ACQUIRED: { target: 'live', action(ctx, ev) } } },
* ...
* }
* }
*
* Transition table values are either a target state string OR an object
* with { target, action }. Actions and entry/exit hooks are SYNCHRONOUS
* and may NOT call send() during their own entry (queue would race).
* Async work belongs in observers — observers fire after each transition
* and may call send() to advance the machine.
*
* No external deps: runs in browser AND Node so unit tests can drive
* any FSM with synthetic events and assert transition tables. */
function createFSM(spec){
let state = spec.initial;
const ctx = Object.assign({}, spec.context || {});
const observers = new Set();
let started = false;
function notify(prev, ev){
for (const fn of observers) try { fn({ state, prev, ev, ctx }); } catch(_){}
}
function runEntry(ev){
const def = spec.states[state];
if (def && def.entry) try { def.entry(ctx, ev); } catch(_){}
}
function runExit(ev){
const def = spec.states[state];
if (def && def.exit) try { def.exit(ctx, ev); } catch(_){}
}
function send(type, payload){
if (!started) start();
const def = spec.states[state];
if (!def || !def.on) return false;
const t = def.on[type];
if (!t) return false;
const target = (typeof t === 'string') ? t : t.target;
if (!target || !spec.states[target]) return false;
const action = (typeof t === 'string') ? null : t.action;
const prev = state;
const ev = { type, payload };
runExit(ev);
state = target;
if (action) try { action(ctx, ev); } catch(_){}
runEntry(ev);
notify(prev, ev);
return true;
}
function start(){
if (started) return api;
started = true;
runEntry(null);
notify(null, null);
return api;
}
const api = {
get state(){ return state; },
get context(){ return ctx; },
send,
observe(fn){ observers.add(fn); return () => observers.delete(fn); },
start,
};
return api;
}
/* ==================================================================
* PublishFSM — one instance per kind (mic / screen / camera).
*
* off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
* ▲ │ │ │
* │ FAILED FAILED STOP / LOST
* │ ▼ ▼ ▼
* └──────────── (off) (off) stopping
* │
* DONE
* ▼
* off
*
* Pure transition spec. The runtime wires up actual getUserMedia /
* getDisplayMedia / fetch / PeerConnection via observers and feeds
* results back as events. */
const publishSpec = {
initial: 'off',
context: { kind: '', stream: null, pc: null, peerID: null, lastError: null },
states: {
off: {
entry: (ctx) => { ctx.stream = null; ctx.pc = null; ctx.peerID = null; },
on: {
START: 'acquiring',
},
},
acquiring: {
on: {
ACQUIRED: {
target: 'negotiating',
action: (ctx, ev) => { ctx.stream = ev.payload && ev.payload.stream; },
},
FAILED: {
target: 'off',
action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; },
},
STOP: 'off', /* cancelled before media acquired */
},
},
negotiating: {
on: {
NEGOTIATED: {
target: 'live',
action: (ctx, ev) => {
if (ev.payload){ ctx.pc = ev.payload.pc || ctx.pc; ctx.peerID = ev.payload.peerID || ctx.peerID; }
},
},
FAILED: {
target: 'stopping',
action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; },
},
STOP: 'stopping',
},
},
live: {
on: {
STOP: 'stopping',
LOST: 'stopping', /* track ended via device disappearance */
},
},
stopping: {
on: {
DONE: 'off',
},
},
},
};
/* ==================================================================
* identity — ed25519 keypair, persisted in localStorage as JWK.
*
@ -2749,8 +2889,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-02</span><br>
md5 <span class="stamp-md5">2f8a14e9605749db0743c3345d46afab</span><br>
sha256 <span class="stamp-sha">124f3e65711264a479b6fa8573b89928825a830c9aeca59649b59e9549e836b3</span><br>
md5 <span class="stamp-md5">41b1bef910d4b758a3c3170540b2c4ec</span><br>
sha256 <span class="stamp-sha">c89827a1cf4ddbc122642de2365f0fcf2dc60cae7725a10c64548b4e40c055ff</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>