zebra-spaces: refactor sub PC ontrack into handleRemoteSfuTrack + multi-peer mesh tests
Receive-side mesh state machine (the bit that decides which peer gets
which tile) was buried inside an anonymous pc.ontrack callback inside
sfuSubscribe(). Extracted into a named top-level function
handleRemoteSfuTrack so tests can drive it directly with synthetic
RTCTrackEvents — no real RTCPeerConnection, no real SFU.
test/multi-peer-mesh.test.js pins fox's stated invariant:
'whatever one device shares all should see, and when unshared none
should see.'
Eight scenarios across 2-3 fake browser sandboxes, each holding the
shipped handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
watchVideoTrackForRemoval + the maps they own:
- one peer publishes camera -> every other peer ends with that pubHex
in cameraStreams + a tile entry
- one peer unshares (track ended) -> every other peer drops that pubHex
- one peer unshares mid-flow (mute past window) -> drops correctly
- hiccup supplant (same pubkey, new track) -> tile preserved AND
pointed at the new stream object (this is the MSID-supplant fix from
d5e9e4e — fresh MediaStream per track means the video element binds
to the new RTP cleanly)
- hiccup supplant + the OLD track's stream-identity guard prevents the
NEW tile from being reaped
- supplant + sustained mute past window on the NEW track -> reaped
correctly
- echo guard: a peer's own publish never enters their own cameraStreams
- three publishers fan-out: A B C all publish, every peer ends with
exactly the other two
Wired into Makefile as test-mesh + added to test-all. Pure Node, no
browser or proxy server needed. Will catch the regressions where one
peer's publish/unpublish silently desyncs another peer's view.
This commit is contained in:
parent
27fea30680
commit
07fcec7775
3 changed files with 595 additions and 97 deletions
477
test/multi-peer-mesh.test.js
Normal file
477
test/multi-peer-mesh.test.js
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
#!/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 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' +
|
||||
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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue