Files
Haven/test/screenBitrate.test.js

940 lines
39 KiB
JavaScript

/**
* User-configurable screen share bitrate (300 to 10000 Kbps + unlimited).
*
* node --test test/screenBitrate.test.js
*/
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const VOICE_SOURCE = fs.readFileSync(path.join(__dirname, '..', 'public/js/voice.js'), 'utf8');
function loadVoiceManager(storage = {}) {
const context = vm.createContext({
module: { exports: {} },
navigator: { userAgent: '', platform: '', maxTouchPoints: 0 },
localStorage: {
getItem(k) { return Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; }
},
console: { log() {}, warn() {}, error() {} },
RTCSessionDescription: function RTCSessionDescription(d) { return d; },
setTimeout,
clearTimeout,
Date
});
vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' });
return { VoiceManager: context.module.exports, storage };
}
function fakeSender(videoTrack) {
return {
track: videoTrack,
applied: null,
getParameters() { return { encodings: [{}] }; },
setParameters(p) { this.applied = p; return Promise.resolve(); }
};
}
function makeSharer(VoiceManager, { bitrate, resolution = 0 } = {}) {
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = resolution;
voice.screenFrameRate = 60;
if (bitrate !== undefined) voice.screenBitrate = bitrate;
voice._screenBitrates = { 0: 8_000_000, 720: 4_000_000, 1080: 8_000_000, 1440: 14_000_000 };
const sender = fakeSender(track);
const connection = { sender, getSenders: () => [sender] };
voice.peers.set(2, { connection, username: 'viewer' });
return { voice, connection, sender };
}
test('bitrate normalizes to 300 to 10000 Kbps, 0 for unlimited, 8000 default', () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
assert.equal(voice._normalizeScreenBitrate(null), 8000);
assert.equal(voice._normalizeScreenBitrate(''), 8000);
assert.equal(voice._normalizeScreenBitrate('abc'), 8000);
assert.equal(voice._normalizeScreenBitrate('0'), 0);
assert.equal(voice._normalizeScreenBitrate(0), 0);
assert.equal(voice._normalizeScreenBitrate(299), 300);
assert.equal(voice._normalizeScreenBitrate(300), 300);
assert.equal(voice._normalizeScreenBitrate(4700), 4700);
assert.equal(voice._normalizeScreenBitrate(10000), 10000);
assert.equal(voice._normalizeScreenBitrate(10001), 0);
assert.equal(voice._normalizeScreenBitrate(-1), 300, 'hold-repeat below range clamps to 300, never 8000');
assert.equal(voice._normalizeScreenBitrate(-100), 300);
});
test('user cap overrides the resolution table; unlimited returns null', () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
voice.screenBitrate = 8000;
assert.equal(voice._screenBitrateFor(1080), 8_000_000);
assert.equal(voice._screenBitrateFor(0), 8_000_000);
voice.screenBitrate = 0;
assert.equal(voice._screenBitrateFor(1080), null);
voice.screenBitrate = undefined;
assert.equal(voice._screenBitrateFor(1080), 8_000_000);
});
test('relay profile still wins on relayed paths', () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
voice.screenBitrate = 10000;
assert.equal(voice._screenBitrateFor(1080, true), 3_000_000);
});
test('setScreenBitrate persists and re-applies live', () => {
const { VoiceManager, storage } = loadVoiceManager();
const { voice, sender } = makeSharer(VoiceManager, { bitrate: 4000 });
voice.setScreenBitrate(2500);
assert.equal(voice.screenBitrate, 2500);
assert.equal(storage.haven_screen_bitrate, '2500');
assert.equal(sender.applied.encodings[0].maxBitrate, 2_500_000);
});
test('unlimited removes the sender cap instead of writing zero', () => {
const { VoiceManager } = loadVoiceManager();
const { voice, sender } = makeSharer(VoiceManager, { bitrate: 8000 });
voice._applyScreenBitrate(
voice.peers.get(2).connection,
voice._screenBitrateFor(1080),
2
);
assert.equal(sender.applied.encodings[0].maxBitrate, 8_000_000);
voice.setScreenBitrate(0);
assert.ok(!('maxBitrate' in sender.applied.encodings[0]), 'cap removed for unlimited');
assert.equal(sender.applied.encodings[0].maxFramerate, 60);
assert.equal(sender.applied.degradationPreference, 'maintain-framerate');
});
test('bitrate changes re-produce the relayed screen source live', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 0; // immediate for deterministic tests
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
const calls = [];
voice._relay = {
hasPublished: (source) => source === 'screen',
unpublish: async (source) => { calls.push(['unpublish', source]); },
publish: async (source, t, opts) => { calls.push(['publish', source, opts]); }
};
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000; // as recorded by the initial publish
voice.setScreenBitrate(8000); // same as current → direct peers only, no relay churn
await new Promise(resolve => setTimeout(resolve, 10));
assert.deepEqual(calls, [], 'unchanged cap must not re-produce the relay source');
voice.setScreenBitrate(6000);
await new Promise(resolve => setTimeout(resolve, 10));
// Cross-realm objects from the vm harness: compare structurally via JSON.
assert.equal(JSON.stringify(calls), JSON.stringify([
['unpublish', 'screen'],
['publish', 'screen', { simulcast: true, maxBitrate: 6_000_000 }]
]));
voice.setScreenBitrate(0); // unlimited carries an explicit high cap (never 2.5 Mbps)
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(JSON.stringify(calls.slice(2)), JSON.stringify([
['unpublish', 'screen'],
['publish', 'screen', { simulcast: true, maxBitrate: 14_000_000 }]
]));
});
test('relay republish is debounced: rapid stepper holds collapse to one re-produce', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 50;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
const calls = [];
voice._relay = {
hasPublished: () => true,
unpublish: async (source) => { calls.push(['unpublish', source, voice.screenBitrate]); },
publish: async (source, t, opts) => { calls.push(['publish', source, opts]); }
};
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
// Simulate holding the + stepper: a change every 80 ms used to tear down
// the relay producer every time. With the 50 ms debounce only the last
// value may re-produce.
voice.setScreenBitrate(8100);
await new Promise(resolve => setTimeout(resolve, 10));
voice.setScreenBitrate(8200);
await new Promise(resolve => setTimeout(resolve, 10));
voice.setScreenBitrate(8300);
await new Promise(resolve => setTimeout(resolve, 80));
assert.equal(calls.filter(c => c[0] === 'unpublish').length, 1, 'one teardown for the whole hold');
assert.equal(JSON.stringify(calls[calls.length - 1][2]), JSON.stringify({ simulcast: true, maxBitrate: 8_300_000 }));
});
test('a change landing mid-republish is not dropped (latest wins)', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
let unpublished = false;
const publishedCaps = [];
voice._relay = {
hasPublished: () => !unpublished,
unpublish: async () => {
unpublished = true;
// Change arrives while the first republish is mid-flight (after
// unpublish, before publish). The old code dropped it via the
// hasPublished early-return and left the relay on the middle value.
voice.screenBitrate = 9000;
voice._republishRelayScreenBitrate().catch(() => {});
await new Promise(resolve => setTimeout(resolve, 10));
},
publish: async (source, t, opts) => {
unpublished = false;
publishedCaps.push(opts.maxBitrate);
}
};
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
voice.screenBitrate = 8500;
await voice._republishRelayScreenBitrate();
// The republish re-reads the cap after unpublish, so the single publish
// already carries the 9 Mbps latest, never the 8.5 Mbps in-between value,
// and the overlapping change is not dropped.
assert.deepEqual(publishedCaps, [9_000_000]);
assert.equal(voice._lastRelayScreenBitrateKey, 9_000_000);
});
test('a failed publish recovers on retry instead of stranding the share', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 6000;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 5;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
let live = true;
let publishes = 0;
const publishedCaps = [];
voice._relay = {
hasPublished: () => live,
unpublish: async () => { live = false; },
publish: async (source, t, opts) => {
publishes += 1;
if (publishes === 1) throw new Error('transport restarting');
live = true;
publishedCaps.push(opts.maxBitrate);
return { id: 'p2' };
}
};
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 2, 'one failed attempt plus one retry');
assert.deepEqual(publishedCaps, [6_000_000]);
assert.equal(voice._lastRelayScreenBitrateKey, 6_000_000);
assert.equal(live, true);
});
test('a persistently failing publish gives up after 3 retries', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 6000;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 5;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
let publishes = 0;
voice._relay = {
hasPublished: () => false,
unpublish: async () => {},
publish: async () => { publishes += 1; throw new Error('down'); }
};
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 4, 'initial attempt plus 3 bounded retries');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'key untouched so a later change recovers');
});
test('a stop during retry backoff aborts instead of publishing into teardown', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 6000;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 30;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
voice._relayScreenNeedsPublish = true; // recovery path: publish despite no live producer
let publishes = 0;
const actions = [];
voice._relay = {
hasPublished: () => false,
unpublish: async () => {},
publish: async (source, t, opts) => {
publishes += 1;
actions.push(['publish', source, opts.maxBitrate]);
if (publishes === 1) throw new Error('transport restarting');
return { id: `p${publishes}`, closed: false, close() {} };
}
};
try {
const republish = voice._republishRelayScreenBitrate();
// Let the first attempt fail so the retry is sleeping in backoff, then
// simulate stopScreenShare's synchronous prefix (op bumps while
// isScreenSharing is still true; stop awaits its unpublish first).
await new Promise(resolve => setTimeout(resolve, 10));
voice._screenStartOperation = 1;
await republish;
assert.equal(publishes, 1, 'retry woke up under the teardown generation and aborted');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'stale key not committed');
// The next share starts clean: its initial publish produces instead of
// landing on a producer the aborted retry created mid-teardown.
const track2 = { kind: 'video', readyState: 'live' };
voice.screenStream = { getVideoTracks: () => [track2], getTracks: () => [track2], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice._screenStartOperation = 2;
voice._relayScreenNeedsPublish = false;
voice._lastRelayScreenBitrateKey = undefined;
voice.screenBitrate = 8000;
await voice._publishInitialRelayScreen();
assert.equal(publishes, 2, 'new share publishes once');
assert.deepEqual(actions[1], ['publish', 'screen', 8_000_000]);
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000);
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('a republish queued behind a slow start aborts if stop lands first', async () => {
// The identity token is bound at schedule time, not when the queued inner
// starts: a stop landing in the serialization queue must invalidate the
// wait, otherwise the inner adopts the teardown generation (isScreenSharing
// still true while stop awaits its unpublish) and re-produces mid-teardown.
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 5;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
const liveProducer = { id: 'p1', closed: false, close() {} };
const producers = new Map([['screen', liveProducer]]);
const actions = [];
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); actions.push(['unpublish', s]); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
actions.push(['unpublishIf', s, producer.id]);
return true;
},
publish: async (s, t, opts) => {
actions.push(['publish', s, opts.maxBitrate]);
const p = { id: `p${actions.length}`, closed: false, close() {} };
producers.set(s, p);
return p;
}
};
voice._lastRelayScreenBitrateKey = 8_000_000;
try {
// Block the serialization queue behind a slow op, then schedule the
// republish for the live share (token bound to generation 0)...
let releaseQueue;
const gate = new Promise(r => { releaseQueue = r; });
voice._relayScreenEnqueue(() => gate);
voice.screenBitrate = 7500; // genuine pending change: key mismatch is real
voice._scheduleRelayScreenRepublish(); // debounce 0 → enqueues behind the gate
// ...then stop starts while the republish is still queued (stuck before
// its unpublish: op bumped, everything else intact, producer live).
voice._screenStartOperation = 1;
releaseQueue();
await voice._relayScreenEnqueue(() => {});
await new Promise(resolve => setTimeout(resolve, 10));
assert.deepEqual(actions, [], 'queued republish adopted nothing, published nothing');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'stale key not committed');
assert.equal(producers.get('screen'), liveProducer, 'live producer untouched');
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('a queued initial publish aborts if stop lands first', async () => {
// Same binding rule as the republish path, for the initial publish: the
// token is bound before the enqueue, so a stop landing while the publish
// waits behind a slow producer invalidates it instead of being adopted at
// execution time (isScreenSharing and screenStream are still present while
// stop awaits its unpublish).
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
const producers = new Map();
const actions = [];
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); actions.push(['unpublish', s]); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
actions.push(['unpublishIf', s, producer.id]);
return true;
},
publish: async (s, t, opts) => {
actions.push(['publish', s, opts.maxBitrate]);
const p = { id: `p${actions.length}`, closed: false, close() {} };
producers.set(s, p);
return p;
}
};
try {
// Block the queue, then enqueue the initial publish exactly like
// shareScreen does (token bound before the enqueue)...
let releaseQueue;
const gate = new Promise(r => { releaseQueue = r; });
voice._relayScreenEnqueue(() => gate);
const token = voice._relayScreenToken();
let innerRan = false;
const queued = voice._relayScreenEnqueue(() => {
innerRan = true;
return voice._publishInitialRelayScreen(token);
});
// ...then stop starts while the publish is still queued.
voice._screenStartOperation = 1;
releaseQueue();
await queued;
await voice._relayScreenEnqueue(() => {});
assert.equal(innerRan, true, 'queued publish ran and decided');
assert.deepEqual(actions, [], 'stopped share published nothing');
assert.equal(voice._lastRelayScreenBitrateKey, undefined, 'no key committed');
assert.equal(producers.size, 0, 'no late producer left live');
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('stopping mid-publish removes the orphan instead of leaving it live', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8500;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
const oldProducer = { id: 'old', closed: false, close() {} };
const producers = new Map([['screen', oldProducer]]);
const calls = [];
let resolveProduce;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); calls.push(['unpublish', s]); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
calls.push(['unpublishIf', s, producer.id]);
return true;
},
publish: (s, t, opts) => new Promise((resolve) => {
calls.push(['publish-start', s, opts.maxBitrate]);
resolveProduce = () => {
const p = { id: 'new', closed: false, close() {} };
producers.set(s, p);
calls.push(['publish-resolve', s]);
resolve(p);
};
})
};
const republish = voice._republishRelayScreenBitrate();
const deadline = Date.now() + 1000;
while (typeof resolveProduce !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveProduce, 'function', 'republish reached the publish await');
// Simulate stopScreenShare's synchronous prefix while produce is pending.
voice._screenStartOperation = 1;
voice.isScreenSharing = false;
resolveProduce();
await republish;
assert.deepEqual([...producers.keys()], [], 'orphan producer removed');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'stale key not committed');
assert.ok(calls.some(c => c[0] === 'unpublishIf' && c[2] === 'new'), 'exactly our producer removed');
});
test('the initial relay publish reads the latest cap, not the pre-renegotiation one', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 9000; // moved while renegotiations were in flight
voice._relayBitrateDebounceMs = 50;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
const calls = [];
voice._relay = {
hasPublished: (s) => calls.some(c => c[0] === 'publish' && c[1] === s),
unpublish: async (s) => { calls.push(['unpublish', s]); },
publish: async (s, t, opts) => { calls.push(['publish', s, opts.maxBitrate]); return { id: 'p' }; }
};
// A change during renegotiations arms the debounce timer...
voice._scheduleRelayScreenRepublish();
// ...but the initial publish (which runs after renegotiations settle)
// already carries latest, and drops the now-pointless timer.
await voice._publishInitialRelayScreen();
assert.deepEqual(calls, [['publish', 'screen', 9_000_000]], 'single fresh publish, no churn');
assert.equal(voice._lastRelayScreenBitrateKey, 9_000_000);
await new Promise(resolve => setTimeout(resolve, 80));
assert.deepEqual(calls, [['publish', 'screen', 9_000_000]], 'cancelled timer never re-produces');
});
test('relay unpublishIf only removes the exact producer', async () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/voice-relay.js'), 'utf8');
const sandbox = {
window: {},
document: { createElement: () => ({}), head: { appendChild() {} } },
console: { log() {}, warn() {}, error() {} },
setTimeout,
clearTimeout
};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, { filename: 'voice-relay.js' });
const Session = sandbox.window.HavenRelaySession;
const emitted = [];
const socket = { on() {}, off() {}, emit(e, d, cb) { emitted.push(e); cb({}); } };
const session = new Session(socket, 'CODE', {});
const p1 = { id: 'p1', closed: false, close() {} };
session.producers.set('screen', p1);
assert.equal(await session.unpublishIf('screen', { id: 'p1' }), false, 'different identity is not removed');
assert.equal(session.producers.get('screen'), p1);
assert.deepEqual(emitted, []);
assert.equal(await session.unpublishIf('screen', p1), true);
assert.equal(session.producers.has('screen'), false);
assert.deepEqual(emitted, ['relay:close-producer']);
});
test('a failed initial publish retries automatically', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
let live = false;
let publishes = 0;
voice._relay = {
hasPublished: () => live,
unpublish: async () => { live = false; },
publish: async (source, t, opts) => {
publishes += 1;
if (publishes === 1) throw new Error('no transport yet');
live = true;
return { id: 'p2' };
}
};
try {
await voice._publishInitialRelayScreen();
// The failed start flags recovery AND schedules it: no next bitrate
// change is needed (#5672 review). Wait for the debounced republish to
// finish (publishes first, key commit lands just after).
const deadline = Date.now() + 1000;
while ((voice._lastRelayScreenBitrateKey !== 8_000_000 || voice._relayScreenNeedsPublish) && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.equal(publishes, 2, 'failed start retries without waiting for the next change');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000);
assert.equal(voice._relayScreenNeedsPublish, false);
assert.equal(live, true);
// A later change still re-produces with the new cap.
voice.screenBitrate = 6000;
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 3, 'a later change re-produces with the new cap');
assert.equal(voice._lastRelayScreenBitrateKey, 6_000_000);
assert.equal(voice._relayScreenNeedsPublish, false);
assert.equal(live, true);
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('stopping during the initial publish leaves no orphan', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
const producers = new Map();
let resolveProduce;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
return true;
},
publish: (s, t, opts) => new Promise((resolve) => {
resolveProduce = () => {
const p = { id: 'p1', closed: false, close() {} };
producers.set(s, p);
resolve(p);
};
})
};
const initial = voice._publishInitialRelayScreen();
const deadline = Date.now() + 1000;
while (typeof resolveProduce !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveProduce, 'function', 'initial publish reached produce');
// Simulate stopScreenShare's synchronous prefix while produce is pending.
voice._screenStartOperation = 1;
voice.isScreenSharing = false;
resolveProduce();
await initial;
assert.deepEqual([...producers.keys()], [], 'late producer cleaned up by identity');
assert.equal(voice._lastRelayScreenBitrateKey, undefined, 'stale key not committed');
});
test('a change during the initial produce is applied after, never lost', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
const producers = new Map();
const publishedCaps = [];
let resolveProduce;
let n = 0;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
return true;
},
publish: (s, t, opts) => {
publishedCaps.push(opts.maxBitrate);
if (resolveProduce === undefined && s === 'screen' && publishedCaps.length === 1) {
return new Promise((resolve) => {
resolveProduce = () => {
n += 1;
const p = { id: `p${n}`, closed: false, close() {} };
producers.set(s, p);
resolve(p);
};
});
}
n += 1;
const p = { id: `p${n}`, closed: false, close() {} };
producers.set(s, p);
return Promise.resolve(p);
}
};
// Serialized: the initial publish runs first...
const initial = voice._relayScreenEnqueue(() => voice._publishInitialRelayScreen());
const deadline = Date.now() + 1000;
while (typeof resolveProduce !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveProduce, 'function', 'initial publish reached produce');
// ...a change lands while produce is pending: its republish queues behind
// the initial instead of racing it and getting dropped.
voice.screenBitrate = 9000;
voice.reapplyScreenBitrate();
resolveProduce();
await initial;
const doneBy = Date.now() + 1000;
while (voice._lastRelayScreenBitrateKey !== 9_000_000 && Date.now() < doneBy) {
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.deepEqual(publishedCaps, [8_000_000, 9_000_000], 'initial cap then the newer one, nothing lost');
assert.equal(voice._lastRelayScreenBitrateKey, 9_000_000);
assert.equal(producers.size, 1, 'exactly one live producer');
});
test('relay screen mutations never interleave', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const order = [];
let release;
const gate = new Promise(r => { release = r; });
const first = voice._relayScreenEnqueue(async () => { order.push('first-start'); await gate; order.push('first-end'); return 'a'; });
const second = voice._relayScreenEnqueue(async () => { order.push('second'); return 'b'; });
await new Promise(r => setTimeout(r, 10));
assert.deepEqual(order, ['first-start'], 'second waits for first');
release();
assert.deepEqual(await Promise.all([first, second]), ['a', 'b']);
assert.deepEqual(order, ['first-start', 'first-end', 'second']);
});
test('a failed relay-restart publish recovers on the next change', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
let live = false;
let publishes = 0;
voice._relay = {
hasPublished: () => live,
unpublish: async () => { live = false; },
publish: async (source, t, opts) => {
publishes += 1;
if (publishes === 1) throw new Error('restart racing');
live = true;
return { id: 'p2' };
}
};
try {
await voice._publishRelayTracks();
assert.equal(publishes, 1);
assert.equal(voice._lastRelayScreenBitrateKey, undefined, 'failed restart records no key');
assert.equal(voice._relayScreenNeedsPublish, true, 'failed restart flags recovery');
voice.screenBitrate = 6000;
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 2, 'the next change recovers the missing producer');
assert.equal(voice._lastRelayScreenBitrateKey, 6_000_000);
assert.equal(voice._relayScreenNeedsPublish, false);
assert.equal(live, true);
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('stopping during the initial audio publish leaves no audio orphan', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const videoTrack = { kind: 'video', readyState: 'live' };
const audioTrack = { kind: 'audio', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [videoTrack],
getTracks: () => [videoTrack, audioTrack],
getAudioTracks: () => [audioTrack]
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
const producers = new Map();
let n = 0;
let resolveAudio;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
return true;
},
publish: (s, t, opts) => {
if (s === 'screen-audio' && typeof resolveAudio !== 'function') {
return new Promise((resolve) => {
resolveAudio = () => {
n += 1;
const p = { id: `pa${n}`, closed: false, close() {} };
producers.set(s, p);
resolve(p);
};
});
}
n += 1;
const p = { id: `p${n}`, closed: false, close() {} };
producers.set(s, p);
return Promise.resolve(p);
}
};
const initial = voice._publishInitialRelayScreen();
const deadline = Date.now() + 1000;
while (typeof resolveAudio !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveAudio, 'function', 'initial publish reached audio produce');
// Simulate stopScreenShare's synchronous prefix while audio produce pends.
// (The video producer stays: removing it is stop's own unpublish job.)
voice._screenStartOperation = 1;
voice.isScreenSharing = false;
resolveAudio();
await initial;
assert.equal(producers.has('screen-audio'), false, 'late audio producer cleaned up');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'video key committed while valid');
});
test('keyframe requests only touch screen video senders', () => {
const { VoiceManager } = loadVoiceManager();
const { voice, connection } = makeSharer(VoiceManager, { bitrate: 4000 });
const screenTrack = voice.screenStream.getVideoTracks()[0];
const otherTrack = { kind: 'video', readyState: 'live' };
let keyframes = 0;
connection.getSenders = () => [
{ track: screenTrack, generateKeyFrame() { keyframes++; return Promise.resolve(); } },
{ track: otherTrack, generateKeyFrame() { keyframes += 100; return Promise.resolve(); } },
{ track: { kind: 'audio' } }
];
voice._requestScreenKeyframe();
assert.equal(keyframes, 1);
voice.isScreenSharing = false;
voice._requestScreenKeyframe();
assert.equal(keyframes, 1, 'no-op when not sharing');
});