diff --git a/Makefile b/Makefile index f1618eb..1d36a5b 100644 --- a/Makefile +++ b/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 diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js new file mode 100644 index 0000000..28092f8 --- /dev/null +++ b/test/zebra-fsm.test.js @@ -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); diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index c8735e0..d662815 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -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');