mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 09:05:01 +00:00
453 lines
14 KiB
JavaScript
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);
|
|
});
|