zebra-report/test/multi-peer-mesh.test.js
Russell Ballestrini fb7228b289
zebra-spaces: rebuild <video> on MSID supplant — autoplay grant resets
Live-room defect (2026-06-03): Will refreshed his browser; the
supplant fired a SECOND ontrack for kind=camera pub=Will at +16s.
The page swapped srcObject on the existing <video>, called play(),
got 'fetching process for the media resource was aborted by the
user agent at the user's request' — browser refused to start a new
playback session on the same element after its autoplay grant had
already been consumed. Tile sat black on the moderator's screen.

Fix: swapFreshVideoElement() — on supplant, replace the <video>
with a freshly-built one carrying the same attrs. A brand-new
<video> is eligible for muted-autoplay even when the prior one had
its play() rejected, so the supplant lands cleanly without needing
a tap. Applied to both the thumbnail and the spotlight tile.

Mesh test harness extracts the helper so renderVideoTile still
runs end-to-end under the sandbox.
2026-06-03 16:08:01 -04:00

481 lines
19 KiB
JavaScript

#!/usr/bin/env node
/* multi-peer mesh state-sync tests.
*
* node test/multi-peer-mesh.test.js
*
* Pins the invariant fox stated bluntly:
* "whatever one device shares all should see, and when unshared none
* should see."
*
* Each test instantiates 2-3 fake "browser" sandboxes containing the
* actual shipped receive-side state machine from web/zebra-spaces.html
* (handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
* watchVideoTrackForRemoval + the streams/maps they own). We then
* synthesize ontrack/mute/unmute/ended events that mirror what the
* SFU would push to each subscriber's PC, and assert on the resulting
* state of every browser's cameraStreams / screenStreams / gameStreams
* maps + tile DOM.
*
* No browser, no real WebRTC stack, no proxy server — pure Node. The
* page is the source of truth: extract the functions from HTML and
* sandbox them so the tests can never drift from the shipped code. */
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 / const literal out of zebra-spaces.html by
* locating its head, then brace-matching. */
function extractFn(re){
const m = src.match(re);
if (!m) throw new Error('could not find ' + re);
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);
}
function extractConst(name){
const re = new RegExp('const\\s+' + name + '\\s*=\\s*\\{');
const m = src.match(re);
if (!m) throw new Error('could not find const ' + name);
let i = src.indexOf('{', m.index), depth = 0, j = i;
for (; j < src.length; j++){
if (src[j] === '{') depth++;
else if (src[j] === '}') { if (--depth === 0) { j++; break; } }
}
if (src[j] === ';') j++;
return src.slice(m.index, j + 1);
}
const watchSrc = extractFn(/function watchVideoTrackForRemoval\(/);
const firstFrameSrc = extractFn(/function watchFirstFrame\(/);
const swapVideoSrc = extractFn(/function swapFreshVideoElement\(/);
const renderTileSrc = extractFn(/function renderVideoTile\(/);
const removeTileSrc = extractFn(/function removeVideoTile\(/);
const renderScreenShim = extractFn(/function renderScreenTile\(/);
const removeScreenShim = extractFn(/function removeScreenTile\(/);
const renderCameraShim = extractFn(/function renderCameraTile\(/);
const removeCameraShim = extractFn(/function removeCameraTile\(/);
const handleTrackSrc = extractFn(/function handleRemoteSfuTrack\(/);
const tileKindsSrc = extractConst('TILE_KINDS');
/* shipped constants */
const camWinMS = parseInt(src.match(/VIDEO_REMOVE_MUTE_WINDOW_MS\s*=\s*(\d+)/)[1], 10);
const screenWinMS = parseInt(src.match(/VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS\s*=\s*(\d+)/)[1], 10);
/* ============================ fake DOM ============================ */
function makeDom(){
let nextId = 0;
function mkEl(tag){
const el = {
tagName: tag.toUpperCase(),
id: '',
className: '',
_classes: new Set(),
children: [],
parent: null,
_listeners: {},
addEventListener(ev, fn){ (this._listeners[ev] = this._listeners[ev] || []).push(fn); },
appendChild(c){ c.parent = this; this.children.push(c); return c; },
remove(){
if (!this.parent) return;
const i = this.parent.children.indexOf(this);
if (i >= 0) this.parent.children.splice(i, 1);
this.parent = null;
},
classList: null,
_attrs: {},
setAttribute(k, v){ this._attrs[k] = v; },
_gen: nextId++,
srcObject: null,
play(){ return Promise.resolve(); },
};
el.classList = {
add(c){ el._classes.add(c); },
remove(c){ el._classes.delete(c); },
contains(c){ return el._classes.has(c); },
toggle(c, on){
if (on === undefined) on = !el._classes.has(c);
if (on) el._classes.add(c); else el._classes.delete(c);
},
};
Object.defineProperty(el, 'innerHTML', { get(){ return ''; }, set(){} });
Object.defineProperty(el, 'textContent', { get(){ return ''; }, set(){} });
return el;
}
const containers = {};
function ensureContainer(id){
if (!containers[id]) containers[id] = mkEl('div');
return containers[id];
}
return {
$(id){ return ensureContainer(id); },
createElement(tag){ return mkEl(tag); },
containers,
};
}
/* ============================ fake MediaStream / MediaStreamTrack ============================ */
let nextStreamId = 0;
function makeTrack(){
const listeners = {};
let muted = true;
let readyState = 'live';
return {
kind: 'video',
muted,
readyState,
get muted(){ return muted; },
set muted(v){ muted = v; },
setMuted(v){ muted = v; },
setReadyState(s){ readyState = s; },
contentHint: '',
addEventListener(ev, fn){ (listeners[ev] = listeners[ev] || []).push(fn); },
fire(ev){ for (const fn of (listeners[ev] || [])) fn(); },
stop(){
readyState = 'ended';
muted = true;
// 'ended' fires when the track stops
for (const fn of (listeners.ended || [])) fn();
},
};
}
class FakeMediaStream {
constructor(tracks){
this.id = 's' + (nextStreamId++);
this.tracks = tracks ? [...tracks] : [];
}
getTracks(){ return this.tracks.slice(); }
getVideoTracks(){ return this.tracks.filter(t => t.kind === 'video'); }
addTrack(t){ this.tracks.push(t); }
removeTrack(t){
const i = this.tracks.indexOf(t);
if (i >= 0) this.tracks.splice(i, 1);
}
}
/* ============================ helpers (mirror the page) ============================ */
function unb64(s){ return Buffer.from(s, 'base64').toString('binary'); }
function hexFromStr(s){
let h = '';
for (let i = 0; i < s.length; i++) h += s.charCodeAt(i).toString(16).padStart(2, '0');
return h;
}
function shortHexFn(h){ return h.slice(0, 4) + '…' + h.slice(-4); }
/* ============================ fake clock ============================ */
function makeClock(){
let now = 0;
let nextId = 1;
let pending = new Map();
return {
setTimeout(fn, ms){
const id = nextId++;
pending.set(id, { fireAt: now + ms, fn });
return id;
},
clearTimeout(id){ pending.delete(id); },
advance(ms){
now += ms;
const ready = [...pending.entries()]
.filter(([, t]) => t.fireAt <= now)
.sort((a, b) => a[1].fireAt - b[1].fireAt);
for (const [id, t] of ready){
pending.delete(id);
t.fn();
}
},
pending(){ return pending.size; },
};
}
/* ============================ Browser sandbox ============================ */
function makeBrowser(name, pubKeyHex){
const dom = makeDom();
const clock = makeClock();
/* Build the function scope. Inject:
* - shipped constants
* - storage maps the receive-side touches
* - DOM helpers ($)
* - the MediaStream + hex/unb64 helpers
* - logLine stub
* - spotlight + setSpotlight stubs (we don't assert on them here) */
const fnSrc =
'const cameraVideos = new Map();\n' +
'const cameraStreams = new Map();\n' +
'const screenVideos = new Map();\n' +
'const screenStreams = new Map();\n' +
'const gameVideos = new Map();\n' +
'const gameStreams = new Map();\n' +
'const sfuStreamsByPubHex = new Map();\n' +
'const remoteAudio = new Map();\n' +
'const peers = new Map();\n' +
'let spotlight = null;\n' +
'function setSpotlight(){}\n' +
'function clearSpotlightDOM(){}\n' +
'function pickNextSpotlight(){}\n' +
'function updateContainerVisibility(){}\n' +
'function buildTile(kind, pubHex, label, opts){\n' +
' const tile = document.createElement("div");\n' +
' const video = document.createElement("video");\n' +
' tile.appendChild(video);\n' +
' return { tile, video };\n' +
'}\n' +
'function attachSfuTrack(){}\n' +
'function canSpeak(role){ return role === "host" || role === "cohost" || role === "speaker"; }\n' +
'function shortHex(h){ return shortHexFn(h); }\n' +
'const myKeys = { pubHex: "' + pubKeyHex + '" };\n' +
'let myRole = "speaker";\n' +
'const members = new Map();\n' +
tileKindsSrc + '\n' +
watchSrc + '\n' +
firstFrameSrc + '\n' +
swapVideoSrc + '\n' +
renderTileSrc + '\n' +
removeTileSrc + '\n' +
renderScreenShim + '\n' +
removeScreenShim + '\n' +
renderCameraShim + '\n' +
removeCameraShim + '\n' +
handleTrackSrc + '\n' +
'return {\n' +
' handleRemoteSfuTrack,\n' +
' members,\n' +
' cameraStreams, screenStreams, gameStreams,\n' +
' cameraVideos, screenVideos, gameVideos,\n' +
' myKeys,\n' +
'};';
const factory = new Function(
'$', 'document', 'logLine', 'unb64', 'hex', 'shortHexFn',
'MediaStream', 'setTimeout', 'clearTimeout',
'VIDEO_REMOVE_MUTE_WINDOW_MS', 'VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS',
fnSrc,
);
const ctx = factory(
dom.$.bind(dom), // $ — returns the container by id
{ createElement: tag => dom.createElement(tag) }, // document.createElement
() => {}, // logLine stub (silent for tests)
unb64, hexFromStr, shortHexFn,
FakeMediaStream,
clock.setTimeout.bind(clock), clock.clearTimeout.bind(clock),
camWinMS, screenWinMS,
);
return { name, pubHex: pubKeyHex, ctx, clock, dom };
}
/* short prefix of pubHex for streamID building */
function shortPub(h){ return h.slice(0, 16); }
/* register a member roster in browser b so handleRemoteSfuTrack can
* resolve a 16-char prefix back to a full pubHex. */
function addMember(b, uuid, pubHex){
/* members map uses pubkey in base64 form (the wire format). Encode
* our hex back to base64 for the resolver to find. */
const pubB64 = Buffer.from(pubHex, 'hex').toString('base64');
b.ctx.members.set(uuid, { uuid, pubkey: pubB64, handle: uuid, role: 'speaker' });
}
/* simulate an ontrack delivery from the SFU to one subscriber browser. */
function deliverTrack(toBrowser, fromPubHex, kind, track){
const sid = (kind === 'mic')
? shortPub(fromPubHex)
: shortPub(fromPubHex) + '-' + kind;
const stream = new FakeMediaStream([track]);
stream.id = sid;
toBrowser.ctx.handleRemoteSfuTrack({
streams: [stream],
track,
receiver: null,
});
}
/* ============================ test 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'); }
function falsy(v, msg){ if (v) throw new Error(msg || 'expected falsy'); }
/* fixed pubkeys for the three peers */
const PUB_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const PUB_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const PUB_C = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
/* ============================ tests ============================ */
console.log('multi-peer mesh:');
test('invariant: when one peer shares camera, every other peer ends up with their pubHex in cameraStreams', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
const C = makeBrowser('C', PUB_C);
for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
addMember(self, 'uuid-' + peer2.name, peer2.pubHex);
}
/* A publishes camera. Simulate: SFU delivers ontrack to B and C. */
const trackToB = makeTrack(); trackToB.setMuted(false);
const trackToC = makeTrack(); trackToC.setMuted(false);
deliverTrack(B, PUB_A, 'camera', trackToB);
deliverTrack(C, PUB_A, 'camera', trackToC);
/* Every OTHER peer has A's pubHex in cameraStreams. A doesn't (own publish). */
truthy(B.ctx.cameraStreams.has(PUB_A), "B should have A's camera");
truthy(C.ctx.cameraStreams.has(PUB_A), "C should have A's camera");
falsy(A.ctx.cameraStreams.has(PUB_A), "A should NOT have its own camera through SFU sub");
/* Tile rendered on B and C */
truthy(B.ctx.cameraVideos.has(PUB_A), "B should have a camera tile entry for A");
truthy(C.ctx.cameraVideos.has(PUB_A), "C should have a camera tile entry for A");
});
test('invariant: when A unshares (track ends), every other peer drops A from cameraStreams', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
const C = makeBrowser('C', PUB_C);
for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
addMember(self, 'uuid-' + peer2.name, peer2.pubHex);
}
const trackToB = makeTrack(); trackToB.setMuted(false);
const trackToC = makeTrack(); trackToC.setMuted(false);
deliverTrack(B, PUB_A, 'camera', trackToB);
deliverTrack(C, PUB_A, 'camera', trackToC);
/* A unpublishes — SFU stops the transceiver, browsers see 'ended' */
trackToB.fire('ended');
trackToC.fire('ended');
falsy(B.ctx.cameraStreams.has(PUB_A), "B should NOT have A's camera after unshare");
falsy(C.ctx.cameraStreams.has(PUB_A), "C should NOT have A's camera after unshare");
falsy(B.ctx.cameraVideos.has(PUB_A), "B should have no camera tile entry for A");
falsy(C.ctx.cameraVideos.has(PUB_A), "C should have no camera tile entry for A");
});
test('invariant: A unshares mid-flow (mute timeout) — every other peer drops A from cameraStreams', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
for (const [self, peer1] of [[A, B], [B, A]]){
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
}
const track = makeTrack();
track.setMuted(false); /* flowing */
deliverTrack(B, PUB_A, 'camera', track);
track.fire('unmute');
truthy(B.ctx.cameraStreams.has(PUB_A));
/* RTP stops (publisher killed, ICE failure on SFU side, etc.) */
track.setMuted(true); track.fire('mute');
B.clock.advance(camWinMS + 1000);
falsy(B.ctx.cameraStreams.has(PUB_A), "B should drop A's camera after mute timeout");
});
test('hiccup supplant: when A republishes (same pubkey, new stream), B keeps the camera tile alive and points it at the new stream', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
for (const [self, peer1] of [[A, B], [B, A]]){
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
}
/* first publish */
const t1 = makeTrack(); t1.setMuted(false);
deliverTrack(B, PUB_A, 'camera', t1);
const stream1 = B.ctx.cameraStreams.get(PUB_A);
truthy(stream1, "B got A's first camera");
/* second publish (hiccup rejoin) — same pubkey, new track. Per MSID-
* supplant safety the page builds a fresh MediaStream rather than
* trusting ev.streams[0]. */
const t2 = makeTrack(); t2.setMuted(false);
deliverTrack(B, PUB_A, 'camera', t2);
const stream2 = B.ctx.cameraStreams.get(PUB_A);
truthy(stream2, "B should still have A's camera entry");
truthy(stream1 !== stream2, "stream object should have been REPLACED with the new track's stream");
/* now the OLD track fires ended (SFU stopped the old transceiver
* during the supplant). The stream-identity guard on the OLD
* watcher must NOT reap the tile that the NEW track installed. */
t1.fire('ended');
truthy(B.ctx.cameraStreams.has(PUB_A), "old-track 'ended' must NOT reap the new tile");
truthy(B.ctx.cameraStreams.get(PUB_A) === stream2, "stream is still the new one after old ended");
});
test('hiccup supplant followed by new track ALSO muting past window removes correctly', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
for (const [self, peer1] of [[A, B], [B, A]]){
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
}
const t1 = makeTrack(); t1.setMuted(false);
deliverTrack(B, PUB_A, 'camera', t1);
const t2 = makeTrack(); t2.setMuted(false);
deliverTrack(B, PUB_A, 'camera', t2);
/* old goes ended — guard saves the new tile */
t1.fire('ended');
truthy(B.ctx.cameraStreams.has(PUB_A));
/* new track flowed (unmute already implicit). Then RTP stops for
* real — should reap at window. */
t2.fire('unmute');
t2.setMuted(true); t2.fire('mute');
B.clock.advance(camWinMS + 1000);
falsy(B.ctx.cameraStreams.has(PUB_A), "new tile reaped after sustained mute past window");
});
test('echo guard: A does NOT add their OWN ontrack to cameraStreams', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
addMember(A, 'uuid-B', PUB_B);
const t = makeTrack(); t.setMuted(false);
/* SFU shouldn't deliver A's own publish to A, but defend regardless */
deliverTrack(A, PUB_A, 'camera', t);
falsy(A.ctx.cameraStreams.has(PUB_A), "self-publish must be ignored by ontrack");
});
test('screen and camera are independent: A publishes BOTH, B sees both, A unshares ONE, the other stays', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
addMember(A, 'uuid-B', PUB_B);
addMember(B, 'uuid-A', PUB_A);
const cam = makeTrack(); cam.setMuted(false);
const scr = makeTrack(); scr.setMuted(false);
deliverTrack(B, PUB_A, 'camera', cam);
deliverTrack(B, PUB_A, 'screen', scr);
truthy(B.ctx.cameraStreams.has(PUB_A));
truthy(B.ctx.screenStreams.has(PUB_A));
cam.fire('ended');
falsy(B.ctx.cameraStreams.has(PUB_A), "camera should be gone");
truthy(B.ctx.screenStreams.has(PUB_A), "screen should still be there");
scr.fire('ended');
falsy(B.ctx.screenStreams.has(PUB_A));
});
test('three publishers fan-out: A B C all publish camera, every peer ends with exactly the other two', () => {
const A = makeBrowser('A', PUB_A);
const B = makeBrowser('B', PUB_B);
const C = makeBrowser('C', PUB_C);
for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
addMember(self, 'uuid-' + peer2.name, peer2.pubHex);
}
/* every peer publishes; every other peer receives */
for (const [pub, others] of [[A, [B, C]], [B, [A, C]], [C, [A, B]]]){
for (const o of others){
const t = makeTrack(); t.setMuted(false);
deliverTrack(o, pub.pubHex, 'camera', t);
}
}
/* invariants */
eq(A.ctx.cameraStreams.size, 2, "A should see exactly 2 cameras");
truthy(A.ctx.cameraStreams.has(PUB_B));
truthy(A.ctx.cameraStreams.has(PUB_C));
eq(B.ctx.cameraStreams.size, 2);
truthy(B.ctx.cameraStreams.has(PUB_A));
truthy(B.ctx.cameraStreams.has(PUB_C));
eq(C.ctx.cameraStreams.size, 2);
truthy(C.ctx.cameraStreams.has(PUB_A));
truthy(C.ctx.cameraStreams.has(PUB_B));
});
/* ============================== summary ============================== */
console.log('\n' + pass + ' passed, ' + fail + ' failed');
process.exit(fail === 0 ? 0 : 1);