Files
Haven/test/voiceScreenSignaling.test.js

453 lines
14 KiB
JavaScript

'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 registerVoice = require('../src/socketHandlers/voice');
const ROOT = path.join(__dirname, '..');
const VOICE_SOURCE = fs.readFileSync(path.join(ROOT, 'public/js/voice.js'), 'utf8');
function createStorage() {
return {
getItem() { return null; },
setItem() {},
removeItem() {}
};
}
function loadVoiceManager(globals = {}) {
const context = vm.createContext({
module: { exports: {} },
navigator: { userAgent: '', platform: '', maxTouchPoints: 0 },
localStorage: createStorage(),
console: { log() {}, warn() {}, error() {} },
RTCSessionDescription: function RTCSessionDescription(description) { return description; },
setTimeout,
clearTimeout,
Date,
...globals
});
vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' });
return context.module.exports;
}
function createRelayHarness() {
const handlers = new Map();
const outgoing = [];
const socket = {
id: 'sender-socket',
user: { id: 1, username: 'sender', displayName: 'Sender', isAdmin: false },
on(event, handler) { handlers.set(event, handler); },
emit() {}
};
const voiceUsers = new Map([['11111111', new Map([
[1, { id: 1, username: 'Sender', socketId: socket.id }],
[2, { id: 2, username: 'Viewer', socketId: 'viewer-socket' }]
])]]);
const io = {
sockets: { sockets: new Map() },
to(target) {
return {
emit(event, payload) { outgoing.push({ target, event, payload }); },
to() { return this; }
};
}
};
const state = {
channelUsers: new Map(),
voiceUsers,
voiceLastActivity: new Map(),
activeMusic: new Map(),
activeScreenSharers: new Map(),
activeWebcamUsers: new Map(),
streamViewers: new Map(),
pendingTempDelete: new Map(),
pendingVoiceLeave: new Map()
};
registerVoice(socket, {
io,
db: {},
state,
userHasPermission: () => true,
getUserEffectiveLevel: () => 0,
getUserHighestRole: () => null,
broadcastVoiceUsers() {},
emitOnlineUsers() {},
handleVoiceLeave() {},
touchVoiceActivity() {},
pruneStaleVoiceUsers: () => [],
getMentionableChannelMembers: () => [],
getActiveMusicSyncState: () => null,
getMusicQueuePayload: () => ({})
});
return { handlers, outgoing };
}
test('voice relay accepts large gathered SDP and preserves offer correlation', () => {
const { handlers, outgoing } = createRelayHarness();
const largeSdp = `v=0\r\n${'a=candidate:screen-share-candidate\r\n'.repeat(1000)}`;
handlers.get('voice-offer')({
code: '11111111',
targetUserId: 2,
offerId: '1-screen-offer-1',
offer: { type: 'offer', sdp: largeSdp }
});
assert.equal(outgoing.length, 1);
assert.equal(outgoing[0].target, 'viewer-socket');
assert.equal(outgoing[0].event, 'voice-offer');
assert.equal(outgoing[0].payload.offerId, '1-screen-offer-1');
assert.equal(outgoing[0].payload.offer.sdp, largeSdp);
handlers.get('voice-answer')({
code: '11111111',
targetUserId: 2,
offerId: '1-screen-offer-1',
answer: { type: 'answer', sdp: largeSdp }
});
assert.equal(outgoing.length, 2);
assert.equal(outgoing[1].payload.offerId, '1-screen-offer-1');
handlers.get('voice-offer')({
code: '11111111',
targetUserId: 2,
offerId: 'too-large',
offer: { type: 'offer', sdp: 'x'.repeat(66000) }
});
assert.equal(outgoing.length, 2);
});
test('an unanswered offer is rolled back and queued for retry', async () => {
const VoiceManager = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
let rollback = null;
let drainCalls = 0;
const connection = {
signalingState: 'have-local-offer',
async setLocalDescription(description) {
rollback = description;
this.signalingState = 'stable';
}
};
const peer = {
connection,
_awaitingAnswer: true,
_activeOfferId: 'offer-current',
_offerAnswerTimer: null,
_offerTimeoutCount: 0,
_offerIsIceRestart: false,
_offerChannelCode: '11111111',
_renegotiateQueued: false
};
voice.peers = new Map([[2, peer]]);
voice._drainQueuedRenegotiation = userId => {
assert.equal(userId, 2);
drainCalls++;
};
assert.equal(await voice._recoverTimedOutOffer(2, connection, 'offer-stale'), false);
assert.equal(rollback, null);
assert.equal(await voice._recoverTimedOutOffer(2, connection, 'offer-current'), true);
assert.deepEqual(JSON.parse(JSON.stringify(rollback)), { type: 'rollback' });
assert.equal(peer._awaitingAnswer, false);
assert.equal(peer._activeOfferId, null);
assert.equal(peer._renegotiateQueued, true);
assert.equal(peer._offerTimeoutCount, 1);
assert.equal(drainCalls, 1);
});
test('a late answer cannot satisfy a newer correlated offer', async () => {
const VoiceManager = loadVoiceManager();
const handlers = {};
const voice = Object.create(VoiceManager.prototype);
voice.socket = { on: (event, handler) => { handlers[event] = handler; } };
voice.peers = new Map();
voice._setupSocketListeners();
let applied = 0;
const connection = {
signalingState: 'have-local-offer',
async setRemoteDescription() {
applied++;
this.signalingState = 'stable';
}
};
const peer = {
connection,
_awaitingAnswer: true,
_activeOfferId: 'offer-new',
_offerAnswerTimer: null,
_offerTimeoutCount: 2,
_renegotiateQueued: false,
_pendingCandidates: []
};
voice.peers.set(2, peer);
await handlers['voice-answer']({
from: { id: 2 },
offerId: 'offer-old',
answer: { type: 'answer', sdp: 'stale' }
});
assert.equal(applied, 0);
assert.equal(peer._activeOfferId, 'offer-new');
await handlers['voice-answer']({
from: { id: 2 },
answer: { type: 'answer', sdp: 'legacy-stale' }
});
assert.equal(applied, 0);
assert.equal(peer._activeOfferId, 'offer-new');
await handlers['voice-answer']({
from: { id: 2 },
offerId: 'offer-new',
answer: { type: 'answer', sdp: 'current' }
});
assert.equal(applied, 1);
assert.equal(peer._awaitingAnswer, false);
assert.equal(peer._activeOfferId, null);
assert.equal(peer._offerTimeoutCount, 0);
});
test('rollback failure preserves the pending offer and rearms recovery', async () => {
const VoiceManager = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
let scheduled = null;
const connection = {
signalingState: 'have-local-offer',
async setLocalDescription() { throw new Error('rollback failed'); }
};
const peer = {
connection,
_awaitingAnswer: true,
_activeOfferId: 'offer-current',
_offerAnswerTimer: null,
_offerTimeoutCount: 0,
_renegotiateQueued: false
};
voice.peers = new Map([[2, peer]]);
voice._scheduleOfferAnswerTimeout = (userId, conn, offerId) => {
scheduled = { userId, conn, offerId };
};
assert.equal(await voice._recoverTimedOutOffer(2, connection, 'offer-current'), false);
assert.equal(peer._awaitingAnswer, true);
assert.equal(peer._activeOfferId, 'offer-current');
assert.equal(peer._recoveringTimedOutOffer, false);
assert.deepEqual(scheduled, { userId: 2, conn: connection, offerId: 'offer-current' });
});
test('automatic offer recovery stops after its bounded retry budget', async () => {
const VoiceManager = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
let drainCalls = 0;
const connection = {
signalingState: 'have-local-offer',
async setLocalDescription() { this.signalingState = 'stable'; }
};
const peer = {
connection,
_awaitingAnswer: true,
_activeOfferId: 'offer-four',
_offerAnswerTimer: null,
_offerTimeoutCount: 3,
_renegotiateQueued: false
};
voice.peers = new Map([[2, peer]]);
voice._drainQueuedRenegotiation = () => { drainCalls++; };
assert.equal(await voice._recoverTimedOutOffer(2, connection, 'offer-four'), true);
assert.equal(peer._offerTimeoutCount, 4);
assert.equal(peer._renegotiateQueued, false);
assert.equal(peer._offerRecoveryExhausted, true);
assert.equal(drainCalls, 0);
});
test('a peer that advertised offer IDs cannot answer without correlation', async () => {
const VoiceManager = loadVoiceManager();
const handlers = {};
const voice = Object.create(VoiceManager.prototype);
voice.socket = { on: (event, handler) => { handlers[event] = handler; } };
voice.peers = new Map();
voice._setupSocketListeners();
let applied = 0;
const peer = {
connection: {
signalingState: 'have-local-offer',
async setRemoteDescription() { applied++; }
},
_awaitingAnswer: true,
_activeOfferId: 'offer-current',
_offerTimeoutCount: 0,
_supportsOfferIds: true
};
voice.peers.set(2, peer);
await handlers['voice-answer']({
from: { id: 2 },
answer: { type: 'answer', sdp: 'uncorrelated' }
});
assert.equal(applied, 0);
assert.equal(peer._awaitingAnswer, true);
});
test('a new media event restarts an exhausted offer recovery budget', async () => {
const VoiceManager = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const emissions = [];
const connection = {
signalingState: 'stable',
async createOffer() { return { type: 'offer', sdp: 'fresh' }; },
async setLocalDescription() { this.signalingState = 'have-local-offer'; }
};
const peer = {
connection,
_makingOffer: false,
_awaitingAnswer: false,
_offerTimeoutCount: 4,
_offerRecoveryExhausted: true,
_renegotiateQueued: false,
_queuedIceRestart: false
};
voice.peers = new Map([[2, peer]]);
voice.currentChannel = '11111111';
voice.localUserId = 1;
voice.socket = { emit: (event, payload) => emissions.push({ event, payload }) };
voice._scheduleOfferAnswerTimeout = () => {};
assert.equal(await voice._renegotiate(2, connection), true);
assert.equal(peer._offerRecoveryExhausted, false);
assert.equal(peer._offerTimeoutCount, 0);
assert.equal(emissions.length, 1);
assert.equal(emissions[0].event, 'voice-offer');
assert.equal(emissions[0].payload.offerId, peer._activeOfferId);
});
test('signaling state guards defer cleanup and drain during recovery', async () => {
class FakeMediaStream {
constructor(tracks = []) { this.tracks = tracks; }
getTracks() { return this.tracks; }
}
class FakePeerConnection {
constructor() {
this.signalingState = 'stable';
this.connectionState = 'new';
this.iceConnectionState = 'new';
this.listeners = {};
}
addEventListener(event, handler) { this.listeners[event] = handler; }
getReceivers() { return []; }
close() {}
}
const VoiceManager = loadVoiceManager({
MediaStream: FakeMediaStream,
RTCPeerConnection: FakePeerConnection
});
const voice = Object.create(VoiceManager.prototype);
voice.peers = new Map();
voice.rtcConfig = {};
voice.localStream = null;
voice.audioBitrate = 0;
voice.screenStream = null;
voice.isScreenSharing = false;
voice.webcamStream = null;
voice.isWebcamActive = false;
voice.screenSharers = new Set();
voice.webcamUsers = new Set();
voice._screenDelivered = new Set();
let drainCalls = 0;
voice._drainQueuedRenegotiation = () => { drainCalls++; };
await voice._createPeer(2, 'Viewer', false);
const peer = voice.peers.get(2);
peer._awaitingAnswer = true;
peer._activeOfferId = 'offer-current';
peer._renegotiateQueued = true;
peer._recoveringTimedOutOffer = true;
peer.connection.listeners.signalingstatechange();
assert.equal(peer._awaitingAnswer, true);
assert.equal(peer._activeOfferId, 'offer-current');
assert.equal(drainCalls, 0);
peer._recoveringTimedOutOffer = false;
peer._awaitingAnswer = false;
peer._handlingRemoteOffer = true;
peer.connection.listeners.signalingstatechange();
assert.equal(drainCalls, 0);
peer._handlingRemoteOffer = false;
peer.connection.listeners.signalingstatechange();
assert.equal(drainCalls, 1);
});
test('glare rollback failure preserves the outgoing offer and rearms its timer', async () => {
const VoiceManager = loadVoiceManager();
const handlers = {};
const voice = Object.create(VoiceManager.prototype);
voice.socket = { on: (event, handler) => { handlers[event] = handler; } };
voice.localUserId = 1;
voice.peers = new Map();
voice._setupSocketListeners();
let scheduled = null;
const connection = {
signalingState: 'have-local-offer',
connectionState: 'connected',
iceConnectionState: 'connected',
async setLocalDescription() { throw new Error('rollback failed'); }
};
const peer = {
connection,
_makingOffer: false,
_awaitingAnswer: true,
_activeOfferId: 'offer-current',
_offerAnswerTimer: null,
_offerIsIceRestart: false,
_renegotiateQueued: false
};
voice.peers.set(2, peer);
voice._scheduleOfferAnswerTimeout = (userId, conn, offerId) => {
scheduled = { userId, conn, offerId };
};
await handlers['voice-offer']({
from: { id: 2, username: 'Viewer' },
offerId: 'remote-offer',
offer: { type: 'offer', sdp: 'remote' }
});
assert.equal(peer._awaitingAnswer, true);
assert.equal(peer._activeOfferId, 'offer-current');
assert.equal(peer._handlingRemoteOffer, false);
assert.deepEqual(scheduled, { userId: 2, conn: connection, offerId: 'offer-current' });
});
test('removing a peer cancels its pending offer timer', () => {
const cleared = [];
const VoiceManager = loadVoiceManager({
clearTimeout: handle => { cleared.push(handle); },
document: { getElementById: () => null }
});
const voice = Object.create(VoiceManager.prototype);
let closed = false;
voice.peers = new Map([[2, {
connection: { close() { closed = true; } },
_offerAnswerTimer: 42
}]]);
voice.screenGainNodes = new Map();
voice.gainNodes = new Map();
voice._screenDelivered = new Set();
voice._stopAnalyser = () => {};
voice._removePeer(2);
assert.deepEqual(cleared, [42]);
assert.equal(closed, true);
assert.equal(voice.peers.has(2), false);
});