#!/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\(/); /* Shipped constants. Read from source so the tests always validate * what's actually live — if either window changes the assertions below * catch 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); const winScreenM = src.match(/const\s+VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS\s*=\s*(\d+)\s*;/); if (!winScreenM) throw new Error('VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS not found in source'); const SHIPPED_WINDOW_SCREEN_MS = parseInt(winScreenM[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', 'VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS', watchSrc + '\nreturn watchVideoTrackForRemoval;' ); const watchVideoTrackForRemoval = harness( fakeSetTimeout, fakeClearTimeout, logLine, SHIPPED_WINDOW_MS, SHIPPED_WINDOW_SCREEN_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('shipped screen-window is at least 60s — static screens stay quiet', () => { truthy(SHIPPED_WINDOW_SCREEN_MS >= 60000, 'screen window ' + SHIPPED_WINDOW_SCREEN_MS + ' < 60000'); truthy(SHIPPED_WINDOW_SCREEN_MS > SHIPPED_WINDOW_MS, 'screen window must be longer than camera window'); }); test('callers can override the window per-kind (screen window honored)', () => { const t = makeTrack(); let removed = 0; watchVideoTrackForRemoval(t, () => removed++, SHIPPED_WINDOW_SCREEN_MS); t.setMuted(false); t.fire('unmute'); t.setMuted(true); t.fire('mute'); /* the camera window has passed — but we passed the screen window, so * the tile must survive */ advance(SHIPPED_WINDOW_MS + 1000); eq(removed, 0, 'camera-window elapsed but screen-window in effect — must not remove'); /* still inside the screen window */ advance(SHIPPED_WINDOW_SCREEN_MS - SHIPPED_WINDOW_MS - 2000); eq(removed, 0, 'still inside screen window'); /* now we cross the screen window */ advance(2000); eq(removed, 1, 'screen window elapsed — removed'); }); test('invalid window argument falls back to camera default', () => { const t = makeTrack(); let removed = 0; watchVideoTrackForRemoval(t, () => removed++, 'not a number'); t.setMuted(false); t.fire('unmute'); t.setMuted(true); t.fire('mute'); advance(SHIPPED_WINDOW_MS + 100); eq(removed, 1, 'bad windowMs should fall back to default, not disable removal'); }); 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);