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:
parent
f71e9e74c4
commit
ebda460574
3 changed files with 409 additions and 3 deletions
10
Makefile
10
Makefile
|
|
@ -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
258
test/zebra-fsm.test.js
Normal 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);
|
||||
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue