Screen shares and game shares can sit static for long stretches — a still desktop, a paused video, a code editor with no caret movement. The encoder genuinely stops emitting RTP, the subscriber's track goes muted, and the 15s camera window would falsely reap the live tile. watchVideoTrackForRemoval now takes a per-call windowMs; the sub-PC ontrack handler passes VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS (120s) for screen + game and VIDEO_REMOVE_MUTE_WINDOW_MS (15s) for camera. A genuine unshare still resolves through the 'ended' path within a frame, so the longer window only affects the slow-failure case. Tests bumped to 18: new screen-window assertions + invalid-windowMs fallback to the default rather than disabling removal entirely.
368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
#!/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);
|