mattermost-mobile/app/products/calls/connection/connection.test.ts
Claudio Costa 5030525c3e
Derive connection state for current call from RTC peer (#8878)
Co-authored-by: Mattermost Build <build@mattermost.com>
2025-06-16 15:08:06 -06:00

607 lines
17 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RTCMonitor, RTCPeer, parseRTCStats} from '@mattermost/calls/lib';
import {zlibSync, strToU8} from 'fflate';
import {Platform} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import NetworkManager from '@managers/network_manager';
import {newConnection} from './connection';
import {WebSocketClient, wsReconnectionTimeoutErr} from './websocket_client';
jest.mock('./websocket_client');
jest.mock('@mattermost/calls/lib');
jest.mock('react-native-webrtc', () => ({
registerGlobals: jest.fn(),
mediaDevices: {
getUserMedia: jest.fn().mockResolvedValue({
getAudioTracks: () => [{
id: 'audioTrackId',
enabled: true,
}],
getTracks: () => [{
id: 'audioTrackId',
enabled: true,
stop: jest.fn(),
release: jest.fn(),
}],
}),
},
}));
jest.mock('@calls/connection/foreground_service', () => ({
foregroundServiceStart: jest.fn(),
foregroundServiceStop: jest.fn(),
foregroundServiceSetup: jest.fn(),
}));
describe('newConnection', () => {
const mockClient = {
getWebSocketUrl: jest.fn(() => 'ws://localhost:8065'),
getCallsConfig: jest.fn(() => ({
ICEServers: ['stun:stun.example.com'],
ICEServersConfigs: [{urls: ['stun:stun.example.com']}],
AllowEnableCalls: true,
EnableAV1: true,
})),
getVersion: jest.fn(() => ({
version: '1.7.0',
})),
genTURNCredentials: jest.fn(() => Promise.resolve([{
urls: ['turn:turn.example.com'],
username: 'user',
credential: 'pass',
}])),
};
const mockRTCStats = {
iceStats: {
succeeded: [
{
id: 'candidatePairA',
timestamp: 45,
priority: 45,
state: 'succeeded',
local: {
candidateType: 'host',
protocol: 'udp',
port: 45000,
},
remote: {
candidateType: 'host',
protocol: 'udp',
port: 8443,
},
},
],
failed: [],
},
};
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
global.navigator = {};
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = jest.fn(() => mockClient);
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => {
return {
getStats: jest.fn(),
on: jest.fn(),
};
});
// eslint-disable-next-line
// @ts-ignore
RTCMonitor.mockImplementation(() => {
return {
on: jest.fn(),
clearCache: jest.fn(),
stop: jest.fn(),
start: jest.fn(),
};
});
Platform.OS = 'android';
InCallManager.start = jest.fn();
InCallManager.stop = jest.fn();
InCallManager.stopProximitySensor = jest.fn();
});
afterAll(() => {
// eslint-disable-next-line
// @ts-ignore
delete global.navigator;
jest.resetAllMocks();
});
beforeEach(() => {
jest.clearAllMocks();
});
it('join', async () => {
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
on: jest.fn(),
getStats: jest.fn(),
}));
let openHandler;
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
} else if (event === 'open') {
openHandler = handler;
handler();
}
},
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
expect(wsSend).toHaveBeenCalledWith('join', {av1Support: false, channelID: 'channelID'});
// reconnect
expect(openHandler).toBeDefined();
openHandler!('originalConnID', 'prevConnID', true);
expect(wsSend).toHaveBeenCalledWith('reconnect', {channelID: 'channelID', originalConnID: 'originalConnID', prevConnID: 'prevConnID'});
});
it('mute/unmute', async () => {
const mockReplaceTrack = jest.fn();
const mockAddStream = jest.fn();
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
replaceTrack: mockReplaceTrack,
addStream: mockAddStream,
on: jest.fn(),
getStats: jest.fn(),
}));
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
}
},
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
// First unmute should use addStream
connection.unmute();
expect(mockAddStream).toHaveBeenCalledWith(expect.any(Object));
expect(wsSend).toHaveBeenCalledWith('unmute');
connection.mute();
// Subsequent unmute should use replaceTrack
connection.unmute();
expect(mockReplaceTrack).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
expect(wsSend).toHaveBeenCalledWith('unmute');
});
it('raise/unraise hand', async () => {
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: jest.fn(),
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
false,
);
connection.raiseHand();
expect(wsSend).toHaveBeenCalledWith('raise_hand');
connection.unraiseHand();
expect(wsSend).toHaveBeenCalledWith('unraise_hand');
});
it('react', async () => {
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: jest.fn(),
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
false,
);
const emoji = {name: 'thumbsup', unified: '1F44D'};
connection.sendReaction(emoji);
expect(wsSend).toHaveBeenCalledWith('react', {
data: JSON.stringify(emoji),
});
});
it('ws error', async () => {
const mockCloseCb = jest.fn();
let errorHandler: (err: Error) => void;
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'error') {
errorHandler = handler;
}
},
send: jest.fn(),
close: jest.fn(),
}));
await newConnection(
'http://localhost:8065',
'channelID',
mockCloseCb,
() => {},
false,
);
// eslint-disable-next-line
// @ts-ignore
errorHandler(new Error('test error'));
expect(mockCloseCb).not.toHaveBeenCalled();
// eslint-disable-next-line
// @ts-ignore
errorHandler(wsReconnectionTimeoutErr);
expect(mockCloseCb).toHaveBeenCalled();
});
it('ws close', async () => {
let closeHandler: (event: WebSocketCloseEvent) => void;
const mockCloseCb = jest.fn();
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'close') {
closeHandler = handler;
}
},
send: jest.fn(),
}));
await newConnection(
'http://localhost:8065',
'channelID',
mockCloseCb,
() => {},
false,
);
// eslint-disable-next-line
// @ts-ignore
closeHandler({code: 1000, reason: 'normal'});
expect(mockCloseCb).not.toHaveBeenCalled();
});
it('voice track', async () => {
const getUserMedia = require('react-native-webrtc').mediaDevices.getUserMedia;
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(getUserMedia).toHaveBeenCalledWith({
video: false,
audio: true,
});
expect(getUserMedia).toHaveBeenCalledTimes(1);
await connection.initializeVoiceTrack();
expect(getUserMedia).toHaveBeenCalledTimes(1);
});
it('rtc peer', async () => {
const wsSend = jest.fn();
const wsClose = jest.fn();
const peerDestroy = jest.fn();
const peerSignal = jest.fn();
const handlers: Record<string, any> = {};
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
on: (event: string, handler: any) => {
handlers[event] = handler;
},
getStats: jest.fn(),
destroy: peerDestroy,
signal: peerSignal,
}));
// eslint-disable-next-line
// @ts-ignore
parseRTCStats.mockImplementation(() => mockRTCStats);
let wsMsgHandler;
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
} else if (event === 'message') {
wsMsgHandler = handler;
}
},
send: wsSend,
close: wsClose,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
handlers.candidate({candidate: 'candidate'});
expect(wsSend).toHaveBeenCalledWith('ice', {data: '{"candidate":"candidate"}'});
handlers.offer({offer: 'sdp'});
expect(wsSend).toHaveBeenCalledWith('sdp', {data: zlibSync(strToU8('{"offer":"sdp"}'))}, true);
expect(wsMsgHandler).toBeDefined();
wsMsgHandler!({data: '{"type": "answer"}'});
expect(peerSignal).toHaveBeenCalledWith('{"type": "answer"}');
wsMsgHandler!({data: '{"type": "offer"}'});
expect(peerSignal).toHaveBeenCalledWith('{"type": "offer"}');
wsMsgHandler!({data: '{"type": "candidate"}'});
expect(peerSignal).toHaveBeenCalledWith('{"type": "candidate"}');
const mockGetTracks = jest.fn().mockReturnValue([
{
id: 'audioTrackId', enabled: true, stop: jest.fn(), release: jest.fn(),
},
]);
const mockGetVideoTracks = jest.fn().mockReturnValue([]);
handlers.stream({
getTracks: mockGetTracks,
getVideoTracks: mockGetVideoTracks,
});
expect(mockGetTracks).toHaveBeenCalled();
expect(mockGetVideoTracks).toHaveBeenCalled();
handlers.error(new Error('test error'));
expect(wsClose).toHaveBeenCalled();
expect(wsSend).toHaveBeenCalledWith('leave');
expect(peerDestroy).toHaveBeenCalled();
handlers.close();
});
it('rtc peer close', async () => {
const wsSend = jest.fn();
const wsClose = jest.fn();
const peerDestroy = jest.fn();
const peerSignal = jest.fn();
const handlers: Record<string, any> = {};
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
on: (event: string, handler: any) => {
handlers[event] = handler;
},
getStats: jest.fn(),
destroy: peerDestroy,
signal: peerSignal,
}));
// eslint-disable-next-line
// @ts-ignore
parseRTCStats.mockImplementation(() => mockRTCStats);
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
}
},
send: wsSend,
close: wsClose,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
handlers.close();
expect(wsClose).toHaveBeenCalled();
expect(wsSend).toHaveBeenCalledWith('leave');
expect(peerDestroy).toHaveBeenCalled();
});
it('collectICEStats', (done) => {
Platform.OS = 'web';
const wsSend = jest.fn();
const joinHandler = jest.fn(async (event: string, cb: () => void) => {
if (event === 'join') {
await cb();
expect(parseRTCStats).toHaveBeenCalled();
expect(wsSend).toHaveBeenCalledWith('metric', {
metric_name: 'client_ice_candidate_pair',
data: JSON.stringify({
state: 'succeeded',
local: {
type: 'host',
protocol: 'udp',
},
remote: {
type: 'host',
protocol: 'udp',
},
}),
},
);
done();
}
});
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => {
return {
initialize: jest.fn(),
on: joinHandler,
send: wsSend,
};
});
// eslint-disable-next-line
// @ts-ignore
parseRTCStats.mockImplementation(() => mockRTCStats);
newConnection('http://localhost:8065', 'channelID', () => {}, () => {}, false).
then((connection) => {
expect(connection).toBeDefined();
expect(joinHandler).toHaveBeenCalled();
});
});
it('waitForPeerConnection', async () => {
let connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
jest.useFakeTimers();
let res = connection.waitForPeerConnection();
jest.runAllTimers();
await expect(res).rejects.toEqual('timed out waiting for peer connection');
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => {
return {
getStats: jest.fn(),
on: jest.fn(),
connected: true,
};
});
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
}
},
send: jest.fn(),
sessionID: 'sessionID',
}));
connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
res = connection.waitForPeerConnection();
jest.runAllTimers();
await expect(res).resolves.toBe('sessionID');
jest.useRealTimers();
});
});