test: state-machine tests for watchVideoTrackForRemoval
15 unit + integration tests that extract the function and the shipped VIDEO_REMOVE_MUTE_WINDOW_MS constant from web/zebra-spaces.html so the assertions track exactly what's deployed. Drives synthetic mute/ unmute/ended event sequences against a fake EventTarget track with a fake clock injected through a Function-constructor harness. Unit coverage: - shipped window must be >= 10s (catches accidental shorten) - initial mute (never flowed) never removes - flowing + sustained mute past window removes - mute + unmute within window cancels removal - ended event removes immediately + cancels pending timer - removeFn is idempotent (no double-call across mute, ended, or later events) - redundant mute events do not stack timers - rescue and removal both emit logLine telemetry - rapid mute/unmute oscillation never removes while unmute lands in time Integration coverage (realistic lifecycles): - fresh track -> flow -> publisher unshares -> tile removed - mobile network handoff (long mute) recovers without removal - peer leaves abruptly (ended fires) -> tile removed once - hard refresh of publisher (SFU supplant renegotiation gap) -> tile survives — this is the cascade fox flagged where a phone reconnect was killing the host's view of its camera - publisher process crashes (mute holds indefinitely) -> removed at window Wired into Makefile as test-video-removal + added to test-all. Pure Node, no browser or proxy server needed.
This commit is contained in:
parent
bb1bf56527
commit
9419a415bf
2 changed files with 339 additions and 1 deletions
13
Makefile
13
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
|
||||
|
||||
|
|
|
|||
327
test/video-track-removal.test.js
Normal file
327
test/video-track-removal.test.js
Normal file
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue