diff --git a/Makefile b/Makefile index 1d36a5b..b150fd8 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,15 @@ test-web: test-fsm: @node test/zebra-fsm.test.js +# Receive-side stream lifecycle (watchVideoTrackForRemoval) — fake-clock +# state-machine tests covering 'when is it safe to remove a tile'. Catches +# the regressions where transient mutes (NACK gaps, network blips, mobile +# handoffs, hard-refresh renegotiation churn) would otherwise kill live +# tiles. Extracts the function from the page and the shipped mute window +# so the assertions track what's live. +test-video-removal: + @node test/video-track-removal.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 @@ -42,7 +51,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-fsm test-zebra-spaces +test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-zebra-spaces @echo "--- unit ---" @./test/unit @echo "--- integration ---" @@ -53,6 +62,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-zebr @node test/web-protocol.test.js @echo "--- zebra-fsm ---" @node test/zebra-fsm.test.js + @echo "--- video-track-removal ---" + @node test/video-track-removal.test.js @echo "--- zebra-spaces ---" @$(MAKE) -s test-zebra-spaces diff --git a/test/video-track-removal.test.js b/test/video-track-removal.test.js new file mode 100644 index 0000000..adc6855 --- /dev/null +++ b/test/video-track-removal.test.js @@ -0,0 +1,327 @@ +#!/usr/bin/env node +/* watchVideoTrackForRemoval state-machine tests. + * + * node test/video-track-removal.test.js + * + * The function is the receive-side stream lifecycle in zebra-spaces.html + * — it watches a remote MediaStreamTrack and removes the tile when the + * publisher GENUINELY stops sharing (a real unshare or a leave). It must + * NOT remove the tile on transient mutes (NACK retransmission gap, + * network blip, CPU pressure on publisher, mobile network handoff). + * + * The shipped function has three legitimate teardown paths: + * - track 'ended' -> publisher closed PC or SFU renegotiated away + * (peer left, screen-share window closed, etc.) + * - sustained mute -> RTP stopped for >VIDEO_REMOVE_MUTE_WINDOW_MS + * (publisher unshared without a clean close) + * - initial mute (before any unmute) -> NEVER removes (fresh remote + * tracks start muted until first packet arrives) + * + * Tests run on the real shipped code: we extract the function out of + * zebra-spaces.html, inject a fake setTimeout/clearTimeout for time + * control, and drive synthetic event sequences. */ + +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 literal out of zebra-spaces.html by locating + * its head, then brace-matching to the closing }. */ +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; } } + } + return src.slice(m.index, j); +} + +const watchSrc = extract(/function watchVideoTrackForRemoval\(/); + +/* The shipped constant. Read it from source so the test always validates + * what's actually live — if the window changes the assertion below + * catches an accidental shorten. */ +const winM = src.match(/const\s+VIDEO_REMOVE_MUTE_WINDOW_MS\s*=\s*(\d+)\s*;/); +if (!winM) throw new Error('VIDEO_REMOVE_MUTE_WINDOW_MS not found in source'); +const SHIPPED_WINDOW_MS = parseInt(winM[1], 10); + +/* ============================ fake clock ============================ */ +let fakeNow = 0; +let pending = new Map(); // id -> { fireAt, fn } +let nextTimerId = 1; +function fakeSetTimeout(fn, ms){ + const id = nextTimerId++; + pending.set(id, { fireAt: fakeNow + ms, fn }); + return id; +} +function fakeClearTimeout(id){ pending.delete(id); } +function advance(ms){ + fakeNow += ms; + /* fire any timers whose fireAt has elapsed, in deadline order */ + const ready = [...pending.entries()] + .filter(([, t]) => t.fireAt <= fakeNow) + .sort((a, b) => a[1].fireAt - b[1].fireAt); + for (const [id, t] of ready){ + pending.delete(id); + t.fn(); + } +} +function resetClock(){ + fakeNow = 0; + pending = new Map(); + nextTimerId = 1; +} + +/* logLine stub — captures so we can assert on diagnostic output. */ +let logs = []; +function logLine(kind, msg){ logs.push({ kind, msg }); } +function resetLogs(){ logs = []; } + +/* harness — Function-constructor scope so the function's free + * references resolve to our fakes, not the host's real globals. */ +const harness = new Function( + 'setTimeout', 'clearTimeout', 'logLine', 'VIDEO_REMOVE_MUTE_WINDOW_MS', + watchSrc + '\nreturn watchVideoTrackForRemoval;' +); +const watchVideoTrackForRemoval = harness( + fakeSetTimeout, fakeClearTimeout, logLine, SHIPPED_WINDOW_MS, +); + +/* ============================ fake track ============================ */ +function makeTrack(){ + const listeners = {}; + let muted = true; // remote tracks start muted (matches MediaStreamTrack on creation) + return { + get muted(){ return muted; }, + setMuted(v){ muted = v; }, + addEventListener(evt, fn){ + (listeners[evt] = listeners[evt] || []).push(fn); + }, + fire(evt){ + for (const fn of (listeners[evt] || [])) fn(); + }, + }; +} + +/* ============================ test harness ============================ */ +let pass = 0, fail = 0; +function test(name, fn){ + resetClock(); + resetLogs(); + 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'); } +function falsy(v, msg){ if (v) throw new Error(msg || 'expected falsy'); } + +/* ====================== unit: state-machine transitions ====================== */ + +console.log('watchVideoTrackForRemoval unit:'); + +test('shipped mute-window is at least 10s — anything less is too aggressive', () => { + truthy(SHIPPED_WINDOW_MS >= 10000, 'window ' + SHIPPED_WINDOW_MS + ' < 10000'); +}); + +test('initial mute (never flowed) does NOT schedule a removal', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(true); + t.fire('mute'); // fresh remote-track mute + advance(60000); // way past any reasonable timeout + eq(removed, 0, 'initial-mute should never remove'); + eq(pending.size, 0, 'no timer should have been armed'); +}); + +test('flowing + sustained mute past window removes', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); // RTP started + t.setMuted(true); t.fire('mute'); // RTP stopped + advance(SHIPPED_WINDOW_MS - 1); + eq(removed, 0, 'should NOT fire before the window elapses'); + advance(2); + eq(removed, 1, 'should fire exactly once at window'); +}); + +test('flowing + brief mute + unmute cancels the removal', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); + advance(SHIPPED_WINDOW_MS - 1000); // just under window + t.setMuted(false); t.fire('unmute'); // RTP resumed in time + advance(60000); // wait way past — nothing + eq(removed, 0); +}); + +test('ended event removes immediately and cancels any pending timer', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); // timer armed + truthy(pending.size > 0, 'expected a pending timer'); + t.fire('ended'); + eq(removed, 1, 'ended should remove once'); + eq(pending.size, 0, 'ended must cancel the mute timer'); + advance(60000); + eq(removed, 1, 'no further calls after ended'); +}); + +test('removeFn is idempotent — never called twice', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); + advance(SHIPPED_WINDOW_MS + 100); // first removal + eq(removed, 1); + t.fire('ended'); // second teardown signal + eq(removed, 1, 'still 1 — already removed'); + t.fire('mute'); advance(SHIPPED_WINDOW_MS); + eq(removed, 1); +}); + +test('redundant mute events do not start a second timer', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); + t.fire('mute'); + const firstSize = pending.size; + t.fire('mute'); t.fire('mute'); + eq(pending.size, firstSize, 'extra mute events should be no-ops while a timer is pending'); +}); + +test('rescue log line fires when unmute saves the tile', () => { + const t = makeTrack(); + watchVideoTrackForRemoval(t, () => {}); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); // timer armed + t.setMuted(false); t.fire('unmute'); // saved! + const rescue = logs.find(l => /RTP resumed/.test(l.msg)); + truthy(rescue, 'expected an RTP-resumed log line after rescue'); +}); + +test('removal log line fires when the timeout takes the tile', () => { + const t = makeTrack(); + watchVideoTrackForRemoval(t, () => {}); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); + advance(SHIPPED_WINDOW_MS + 100); + const remove = logs.find(l => /muted >/.test(l.msg) && /removing tile/.test(l.msg)); + truthy(remove, 'expected a removal log line after timeout'); +}); + +test('mute-then-unmute-before-mute-fires-after-unmute keeps tile alive', () => { + /* this is the rapid oscillation case: NACK retransmission gap may + * fire mute then unmute many times within a single second. None of + * those should trigger removal as long as unmute lands before the + * window expires. */ + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + for (let i = 0; i < 5; i++){ + t.setMuted(true); t.fire('mute'); + advance(100); + t.setMuted(false); t.fire('unmute'); + advance(100); + } + advance(60000); + eq(removed, 0, 'oscillation under window should never remove'); +}); + +/* ====================== integration: realistic lifecycles ====================== */ + +console.log('watchVideoTrackForRemoval integration:'); + +test('lifecycle: fresh track -> flow -> publisher unshares -> tile removed', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + /* fresh remote track: arrives muted, no immediate removal */ + t.setMuted(true); t.fire('mute'); + advance(2000); + eq(removed, 0); + /* RTP starts */ + t.setMuted(false); t.fire('unmute'); + advance(30000); // happy stream for 30s + eq(removed, 0); + /* publisher unshares — SFU stops the transceiver — RTP halts */ + t.setMuted(true); t.fire('mute'); + advance(SHIPPED_WINDOW_MS - 1); + eq(removed, 0, 'should still be deciding'); + advance(2); + eq(removed, 1, 'cleaned up after window'); +}); + +test('lifecycle: mobile network handoff (long mute) recovers without removal', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); + /* handoff stalls RTP for almost the full window */ + advance(SHIPPED_WINDOW_MS - 500); + /* signal restores just in time */ + t.setMuted(false); t.fire('unmute'); + advance(60000); + eq(removed, 0, 'handoff that recovers under window must not remove'); +}); + +test('lifecycle: peer leaves abruptly -> ended fires -> tile removed once', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + advance(5000); + t.fire('ended'); // SFU renegotiated the track away + eq(removed, 1); +}); + +test('lifecycle: hard refresh of publisher (same pubkey) -> brief gap -> new track restored', () => { + /* this is the cascade fox flagged: on a hard refresh the publisher + * page reconnects, the SFU supplants the stale publisher, the + * SUBSCRIBER's existing track briefly mutes during the SSE + * renegotiation churn. Must not remove. */ + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + advance(2000); + /* renegotiation gap: mute fires while SFU swaps the source */ + t.setMuted(true); t.fire('mute'); + advance(800); // typical renegotiation latency + t.setMuted(false); t.fire('unmute'); + advance(30000); + eq(removed, 0, 'renegotiation blip must not look like an unshare'); +}); + +test('lifecycle: publisher process crashes -> mute holds -> tile removed at window', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++); + t.setMuted(false); t.fire('unmute'); + advance(10000); + /* publisher's PC dies but the SFU hasn't yet detected ICE failure + * — track stays muted from the subscriber's perspective until the + * SFU eventually renegotiates. Window catches it. */ + t.setMuted(true); t.fire('mute'); + advance(SHIPPED_WINDOW_MS + 100); + eq(removed, 1); +}); + +/* ============================== summary ============================== */ + +console.log('\n' + pass + ' passed, ' + fail + ' failed'); +process.exit(fail === 0 ? 0 : 1);