'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 ROOT = path.join(__dirname, '..'); // The socket listeners are spread over three modules that the app merges into // one object; the test loads and merges them the same way. const SOCKET_FILES = ['app-socket.js', 'app-socket-channels.js', 'app-socket-events.js']; const SOCKET_SOURCES = SOCKET_FILES.map(name => fs.readFileSync(path.join(ROOT, 'public/js/modules', name), 'utf8')); const SOCKET_SOURCE = SOCKET_SOURCES.join('\n'); const VOICE_SOURCE = fs.readFileSync(path.join(ROOT, 'public/js/voice.js'), 'utf8'); function loadSocketMethods(globals = {}) { const methods = {}; SOCKET_SOURCES.forEach((source, i) => { const context = vm.createContext({ module: { exports: {} }, exports: {}, ...globals }); vm.runInContext(source.replace(/^export default/m, 'module.exports ='), context, { filename: SOCKET_FILES[i] }); Object.assign(methods, context.module.exports); }); return methods; } function createStorage(values = {}) { const data = new Map(Object.entries(values)); return { get length() { return data.size; }, getItem: key => data.has(key) ? data.get(key) : null, setItem: (key, value) => data.set(key, String(value)), removeItem: key => data.delete(key), key: index => [...data.keys()][index] || null }; } test('channel code migration updates runtime caches and persisted state', () => { const oldCode = '11111111'; const newCode = '22222222'; const storage = createStorage({ haven_voice_channel: oldCode, haven_muted_channels: JSON.stringify([oldCode, 'aaaaaaaa']), haven_hidden_channels: JSON.stringify([oldCode]), [`haven_seen_pin_max_${oldCode}`]: '42', [`haven_subtag_collapsed_${oldCode}_news`]: '1' }); const methods = loadSocketMethods({ localStorage: storage, document: { getElementById: () => null } }); const app = { unreadCounts: { [oldCode]: 3 }, voiceCounts: { [oldCode]: 2 }, voiceChannelUsers: { [oldCode]: [{ id: 1 }] }, _pinnedCountByChannel: { [oldCode]: 1 }, _unreadPinIdByChannel: { [oldCode]: 9 }, _threadMentions: { [oldCode]: [7] }, _persistThreadMentions() { this.persistedMentions = true; }, currentChannel: oldCode, _lastVoiceUsersChannel: oldCode, _pendingChannelHistoryCode: oldCode, _pinsPipChannelCode: oldCode, _organizeParentCode: oldCode, voice: { currentChannel: oldCode, _softLeftChannel: oldCode, _joiningChannelCode: oldCode }, channels: [{ afk_sub_code: oldCode }] }; methods._migrateChannelCodeState.call(app, oldCode, newCode); for (const store of [ app.unreadCounts, app.voiceCounts, app.voiceChannelUsers, app._pinnedCountByChannel, app._unreadPinIdByChannel, app._threadMentions ]) { assert.equal(Object.hasOwn(store, oldCode), false); assert.equal(Object.hasOwn(store, newCode), true); } assert.equal(app.currentChannel, newCode); assert.equal(app.voice.currentChannel, newCode); assert.equal(app.voice._softLeftChannel, newCode); assert.equal(app.voice._joiningChannelCode, newCode); assert.equal(app.channels[0].afk_sub_code, newCode); assert.equal(app.persistedMentions, true); assert.equal(storage.getItem('haven_voice_channel'), newCode); assert.deepEqual(JSON.parse(storage.getItem('haven_muted_channels')), [newCode, 'aaaaaaaa']); assert.equal(storage.getItem(`haven_seen_pin_max_${oldCode}`), null); assert.equal(storage.getItem(`haven_seen_pin_max_${newCode}`), '42'); assert.equal(storage.getItem(`haven_subtag_collapsed_${newCode}_news`), '1'); }); test('channel lists detect rotations by stable id across memory and cold starts', () => { const storage = createStorage({ haven_channel_codes_by_id: JSON.stringify({ ownerId: '7', codes: { 1: '11111111', 2: 'aaaaaaaa' } }) }); const methods = loadSocketMethods({ localStorage: storage }); const app = Object.assign({ user: { id: 7 }, channels: [{ id: 2, code: 'bbbbbbbb' }] }, methods); const channels = [ { id: 1, code: '22222222' }, { id: 2, code: 'cccccccc' } ]; const rotations = methods._collectChannelCodeRotations.call(app, channels); assert.deepEqual(JSON.parse(JSON.stringify(rotations)), [ { channelId: 1, oldCode: '11111111', newCode: '22222222' }, { channelId: 2, oldCode: 'aaaaaaaa', newCode: 'cccccccc' }, { channelId: 2, oldCode: 'bbbbbbbb', newCode: 'cccccccc' } ]); methods._persistChannelCodeMap.call(app, channels); assert.deepEqual(JSON.parse(storage.getItem('haven_channel_codes_by_id')), { ownerId: '7', codes: { 1: '22222222', 2: 'cccccccc' } }); methods._updatePersistedChannelCode.call(app, 2, 'dddddddd'); assert.equal(methods._readChannelCodeMap.call(app)[2], 'dddddddd'); const nextRotations = methods._collectChannelCodeRotations.call( { ...app, channels: [] }, [{ id: 2, code: 'eeeeeeee' }] ); assert.deepEqual(JSON.parse(JSON.stringify(nextRotations)), [ { channelId: 2, oldCode: 'dddddddd', newCode: 'eeeeeeee' } ]); const otherUser = Object.assign({ user: { id: 8 }, channels: [] }, methods); assert.deepEqual(JSON.parse(JSON.stringify( methods._collectChannelCodeRotations.call(otherUser, channels) )), []); methods._clearChannelCodeMap.call(app); assert.equal(storage.getItem('haven_channel_codes_by_id'), null); }); test('voice-channel-gone can be repaired by channels-list without delaying rejoin', () => { const handlers = {}; const context = vm.createContext({ module: { exports: {} }, navigator: { userAgent: '', platform: '', maxTouchPoints: 0 }, localStorage: createStorage(), console: { log() {}, warn() {}, error: console.error }, setTimeout, clearTimeout, Date }); vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' }); const VoiceManager = context.module.exports; const voice = Object.create(VoiceManager.prototype); voice.socket = { on: (event, handler) => { handlers[event] = handler; } }; voice.inVoice = true; voice.currentChannel = '11111111'; voice.leaveCalls = 0; voice.leave = () => { voice.leaveCalls++; voice.inVoice = false; }; voice._setupSocketListeners(); voice.deferChannelGone(6000); handlers['voice-channel-gone']({ code: '11111111' }); assert.equal(voice.leaveCalls, 0); voice.currentChannel = '22222222'; voice.resolveDeferredChannelGone('22222222'); assert.equal(voice.leaveCalls, 0); voice.inVoice = true; voice._voiceSessionGeneration = 1; voice.deferChannelGone(6000); handlers['voice-channel-gone']({ code: '22222222' }); voice.currentChannel = '33333333'; voice._voiceSessionGeneration = 2; voice.resolveDeferredChannelGone(null); assert.equal(voice.leaveCalls, 0); voice.inVoice = true; voice.deferChannelGone(6000); handlers['voice-channel-gone']({ code: '33333333' }); const originalDeadline = voice._deferChannelGoneUntil; const originalTimer = voice._deferredChannelGoneTimer; voice.deferChannelGone(6000); assert.ok(voice._deferredChannelGone); assert.equal(voice._deferChannelGoneUntil, originalDeadline); assert.equal(voice._deferredChannelGoneTimer, originalTimer); voice.resolveDeferredChannelGone(null); assert.equal(voice.leaveCalls, 1); const connectHandler = SOCKET_SOURCE.slice( SOCKET_SOURCE.indexOf("this.socket.on('connect'"), SOCKET_SOURCE.indexOf("this.socket.on('disconnect'") ); assert.match(connectHandler, /emit\('enter-channel'/); assert.match(connectHandler, /emit\('voice-rejoin'/); assert.doesNotMatch(connectHandler, /await[^;]+channels-list|_resumeChannelsAfterList/); const channelListHandler = SOCKET_SOURCE.slice( SOCKET_SOURCE.indexOf("this.socket.on('channels-list'"), SOCKET_SOURCE.indexOf("this.socket.on('message-history'") ); assert.match(channelListHandler, /voiceRotation[\s\S]+_healPeerConnectionsAfterChannelRotation/); }); test('rotation recovery rolls back an unanswered offer sent with the old code', async () => { const context = vm.createContext({ module: { exports: {} }, navigator: { userAgent: '', platform: '', maxTouchPoints: 0 }, localStorage: createStorage(), console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout, Date }); vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' }); const VoiceManager = context.module.exports; const voice = Object.create(VoiceManager.prototype); let rollback; let healCalls = 0; const connection = { signalingState: 'have-local-offer', async setLocalDescription(description) { rollback = description; this.signalingState = 'stable'; } }; const peer = { connection, _makingOffer: false, _awaitingAnswer: true, _offerIsIceRestart: true, _offerChannelCode: '11111111' }; voice.peers = new Map([[7, peer]]); voice._healPeerConnections = () => { healCalls++; }; await voice._healPeerConnectionsAfterChannelRotation('11111111'); assert.deepEqual(JSON.parse(JSON.stringify(rollback)), { type: 'rollback' }); assert.equal(peer._awaitingAnswer, false); assert.equal(peer._offerChannelCode, null); assert.equal(healCalls, 1); }); test('live channel rotation invokes voice recovery with the old code', () => { const handlers = new Map(); const storage = createStorage(); const methods = loadSocketMethods({ localStorage: storage, document: { getElementById: () => null, addEventListener() {} }, window: { addEventListener() {} }, console: { log() {}, warn() {}, error() {} }, setInterval: () => 1, clearInterval() {}, }); const calls = []; const app = Object.assign({ socket: { on: (event, handler) => handlers.set(event, handler), emit() {} }, _setupFerrySocket() {}, user: { id: 7 }, channels: [{ id: 1, code: '11111111', name: 'Voice' }], currentChannel: null, unreadCounts: {}, voiceCounts: {}, voiceChannelUsers: {}, voice: { inVoice: true, currentChannel: '11111111', resolveDeferredChannelGone: code => calls.push(['resolve', code]), _healPeerConnectionsAfterChannelRotation: code => calls.push(['heal', code]), }, _renderChannels() {}, _refreshVoiceSidebar() {}, }, methods); app._setupSocketListeners(); handlers.get('channel-code-rotated')({ channelId: 1, oldCode: '11111111', newCode: '22222222', }); assert.equal(app.voice.currentChannel, '22222222'); assert.deepEqual(calls, [['heal', '11111111']]); }); test('voice joins are single-flight while async setup is pending', async () => { const context = vm.createContext({ module: { exports: {} }, navigator: { userAgent: '', platform: '', maxTouchPoints: 0 }, localStorage: createStorage(), console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout, Date }); vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' }); const VoiceManager = context.module.exports; const voice = Object.create(VoiceManager.prototype); voice.socket = { connected: true }; voice.inVoice = false; let rejectSetup; voice._fetchIceServers = () => new Promise((resolve, reject) => { rejectSetup = reject; }); const firstJoin = voice.join('11111111'); await Promise.resolve(); assert.equal(voice._joinInFlight, true); assert.equal(await voice.join('11111111'), false); rejectSetup(new Error('stop setup')); assert.equal(await firstJoin, false); assert.equal(voice._joinInFlight, false); assert.equal(voice._joiningChannelCode, null); }); test('an async voice join uses a rotated code and does not emit while disconnected', async () => { const emissions = []; const context = vm.createContext({ module: { exports: {} }, navigator: { userAgent: '', platform: '', maxTouchPoints: 0 }, localStorage: createStorage({ haven_listener_only: '1' }), console: { log() {}, warn() {}, error() {} }, window: {}, setTimeout, clearTimeout, Date }); vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' }); const VoiceManager = context.module.exports; const createVoice = () => { const voice = Object.create(VoiceManager.prototype); voice.socket = { connected: true, emit: (event, data) => emissions.push({ event, data }) }; voice.inVoice = false; voice.isMuted = false; voice.isDeafened = false; voice._ensureAudioCtx = () => {}; voice.audioCtx = { resume: async () => {}, close: async () => {}, createMediaStreamDestination: () => ({ stream: { getTracks: () => [] } }) }; voice._applyMuteStateToLocalTracks = () => {}; voice._disableRNNoise = () => {}; voice._stopNoiseGate = () => {}; voice._stopLocalTalkDetection = () => {}; return voice; }; const rotatedVoice = createVoice(); let finishSetup; rotatedVoice._fetchIceServers = () => new Promise(resolve => { finishSetup = resolve; }); const rotatedJoin = rotatedVoice.join('11111111'); rotatedVoice._joiningChannelCode = '22222222'; finishSetup(); assert.equal(await rotatedJoin, true); assert.deepEqual(JSON.parse(JSON.stringify( emissions.find(item => item.event === 'voice-join') )), { event: 'voice-join', data: { code: '22222222', relay: 0 // no voice relay script loaded in this test } }); emissions.length = 0; const disconnectedVoice = createVoice(); let finishDisconnectedSetup; disconnectedVoice._fetchIceServers = () => new Promise(resolve => { finishDisconnectedSetup = resolve; }); const disconnectedJoin = disconnectedVoice.join('33333333'); disconnectedVoice.socket.connected = false; finishDisconnectedSetup(); assert.equal(await disconnectedJoin, false); assert.equal(emissions.some(item => item.event === 'voice-join'), false); });