Skip to content

Commit

Permalink
Fix waiting for the TURN server config from the Widget API (#655)
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Weimann <michael.weimann@nordeck.net>
  • Loading branch information
weeman1337 authored Dec 5, 2024
1 parent c068495 commit 6e6fbe7
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 153 deletions.
6 changes: 6 additions & 0 deletions .changeset/breezy-flies-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@nordeck/matrix-neoboard-react-sdk': patch
'@nordeck/matrix-neoboard-widget': patch
---

NeoBoard now more reliably uses the TURN server provided by the Matrix server configuration.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@

import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing';
import { waitFor } from '@testing-library/react';
import { BehaviorSubject, NEVER, Subject, firstValueFrom, toArray } from 'rxjs';
import {
BehaviorSubject,
NEVER,
Subject,
delay,
firstValueFrom,
of,
toArray,
} from 'rxjs';
import {
Mocked,
afterEach,
Expand All @@ -27,7 +35,6 @@ import {
vi,
} from 'vitest';
import { mockDocumentVisibilityState } from '../../lib/testUtils/domTestUtils';
import * as connection from './connection';
import {
Message,
PeerConnection,
Expand Down Expand Up @@ -131,164 +138,210 @@ describe('WebRtcCommunicationChannel', () => {
vi.mocked(WebRtcPeerConnection).mockReturnValue(
peerConnection as unknown as WebRtcPeerConnection,
);

channel = new WebRtcCommunicationChannel(
widgetApi,
sessionManager,
signalingChannel,
'whiteboard-id',
enableObserveVisibilityStateSubject,
250,
);
});

afterEach(() => {
channel.destroy();
vi.resetAllMocks();
});

it('should add peer connections for joined sessions', () => {
const spy = vi.spyOn(connection, 'WebRtcPeerConnection');
expect(sessionManager.join).toHaveBeenCalledWith('whiteboard-id');
expect(channel.getStatistics()).toMatchObject({
localSessionId: 'session-id',
describe('with non-deferred TURN config', () => {
beforeEach(() => {
createChannel();
});

joinedSubject.next(anotherSession);
it('should close peer connections for left sessions', async () => {
joinedSubject.next(anotherSession);
await waitForSessionExists();

expect(spy).toHaveBeenCalledWith(
signalingChannel,
anotherSession,
'session-id',
{
turnServer: {
credential: 'credential',
urls: ['turn:turn.matrix.org'],
username: 'user',
},
},
);
});
leftSubject.next(anotherSession);

it('should close peer connections for left sessions', () => {
joinedSubject.next(anotherSession);

statisticsSubject.next(peerConnectionStatistics);
leftSubject.next(anotherSession);

expect(peerConnection.close).toHaveBeenCalled();
expect(channel.getStatistics()).toEqual({
localSessionId: 'session-id',
peerConnections: {},
expect(peerConnection.close).toHaveBeenCalled();
expect(channel.getStatistics()).toEqual({
localSessionId: 'session-id',
peerConnections: {},
});
});
});

it('should disconnect while the browser is hidden', async () => {
vi.useFakeTimers();
joinedSubject.next(anotherSession);
it('should disconnect while the browser is hidden', async () => {
joinedSubject.next(anotherSession);
await waitForSessionExists();

// Hide the tab
mockDocumentVisibilityState('hidden');
vi.useFakeTimers();

vi.advanceTimersByTime(250);
expect(sessionManager.leave).toHaveBeenCalled();
expect(peerConnection.close).toHaveBeenCalled();
// Hide the tab
mockDocumentVisibilityState('hidden');

await vi.waitFor(() => {
expect(sessionManager.getSessionId()).toBeUndefined();
});
vi.advanceTimersByTime(250);
expect(sessionManager.leave).toHaveBeenCalled();
expect(peerConnection.close).toHaveBeenCalled();

// Make the tab visible again
mockDocumentVisibilityState('visible');
await vi.waitFor(() => {
expect(sessionManager.getSessionId()).toBeUndefined();
});

expect(sessionManager.join).toHaveBeenCalledTimes(2);
expect(sessionManager.join).toHaveBeenCalledWith('whiteboard-id');
});
// Make the tab visible again
mockDocumentVisibilityState('visible');

expect(sessionManager.join).toHaveBeenCalledTimes(2);
expect(sessionManager.join).toHaveBeenCalledWith('whiteboard-id');
});

it('should skip disconnect while the browser is hidden if disabled', async () => {
vi.useFakeTimers();
joinedSubject.next(anotherSession);
it('should skip disconnect while the browser is hidden if disabled', async () => {
vi.useFakeTimers();
joinedSubject.next(anotherSession);

enableObserveVisibilityStateSubject.next(false);
enableObserveVisibilityStateSubject.next(false);

// Hide the tab
mockDocumentVisibilityState('hidden');
// Hide the tab
mockDocumentVisibilityState('hidden');

vi.advanceTimersByTime(1250);
expect(sessionManager.leave).not.toHaveBeenCalled();
expect(peerConnection.close).not.toHaveBeenCalled();
});
vi.advanceTimersByTime(1250);
expect(sessionManager.leave).not.toHaveBeenCalled();
expect(peerConnection.close).not.toHaveBeenCalled();
});

it('should forward statistics from peer connections', async () => {
joinedSubject.next(anotherSession);
it('should forward statistics from peer connections', async () => {
joinedSubject.next(anotherSession);
await waitForSessionExists();

const statisticsPromise = firstValueFrom(channel.observeStatistics());
const statisticsPromise = firstValueFrom(channel.observeStatistics());

statisticsSubject.next(peerConnectionStatistics);
statisticsSubject.next(peerConnectionStatistics);

expect(channel.getStatistics()).toEqual({
localSessionId: 'session-id',
peerConnections: {
'connection-id': peerConnectionStatistics,
},
expect(channel.getStatistics()).toEqual({
localSessionId: 'session-id',
peerConnections: {
'connection-id': peerConnectionStatistics,
},
});
await expect(statisticsPromise).resolves.toEqual({
localSessionId: 'session-id',
peerConnections: {
'connection-id': peerConnectionStatistics,
},
});
});
await expect(statisticsPromise).resolves.toEqual({
localSessionId: 'session-id',
peerConnections: {
'connection-id': peerConnectionStatistics,
},

it('should messages from peer connections', async () => {
joinedSubject.next(anotherSession);
await waitForSessionExists();

const messagesPromise = firstValueFrom(channel.observeMessages());

messageSubject.next({
type: 'example_type',
content: { key: 'value' },
senderSessionId: 'another-session-id',
senderUserId: '@another-user-id',
});

await expect(messagesPromise).resolves.toEqual({
type: 'example_type',
content: { key: 'value' },
senderSessionId: 'another-session-id',
senderUserId: '@another-user-id',
});
});
});

it('should messages from peer connections', async () => {
joinedSubject.next(anotherSession);
it('should send messages to all peer connections', async () => {
joinedSubject.next(anotherSession);
await waitForSessionExists();

const messagesPromise = firstValueFrom(channel.observeMessages());
channel.broadcastMessage('example_type', { key: 'value' });

messageSubject.next({
type: 'example_type',
content: { key: 'value' },
senderSessionId: 'another-session-id',
senderUserId: '@another-user-id',
expect(peerConnection.sendMessage).toHaveBeenCalledWith('example_type', {
key: 'value',
});
});

await expect(messagesPromise).resolves.toEqual({
type: 'example_type',
content: { key: 'value' },
senderSessionId: 'another-session-id',
senderUserId: '@another-user-id',
it('should leave after destroying', async () => {
joinedSubject.next(anotherSession);
await waitForSessionExists();

const messagesPromise = firstValueFrom(
channel.observeMessages().pipe(toArray()),
);
const statisticsPromise = firstValueFrom(
channel.observeMessages().pipe(toArray()),
);

channel.destroy();

await expect(messagesPromise).resolves.toEqual([]);
await expect(statisticsPromise).resolves.toEqual([]);
await waitFor(() => {
expect(sessionManager.leave).toHaveBeenCalled();
});
await waitFor(() => {
expect(peerConnection.close).toHaveBeenCalled();
});
});
});

it('should send messages to all peer connections', () => {
joinedSubject.next(anotherSession);
describe('with deferred TURN config', () => {
beforeEach(() => {
vi.useFakeTimers();
createChannel(true);
});

channel.broadcastMessage('example_type', { key: 'value' });
afterEach(() => {
vi.useRealTimers();
});

expect(peerConnection.sendMessage).toHaveBeenCalledWith('example_type', {
key: 'value',
it('should add peer connections for joined sessions', async () => {
expect(sessionManager.join).toHaveBeenCalledWith('whiteboard-id');
expect(channel.getStatistics()).toMatchObject({
localSessionId: 'session-id',
});

joinedSubject.next(anotherSession);
await vi.runAllTimersAsync();

expect(WebRtcPeerConnection).toHaveBeenCalledWith(
signalingChannel,
anotherSession,
'session-id',
{
turnServer: {
credential: 'example-turn-credentials',
urls: ['turn:turn.example.com'],
username: 'example-turn-username',
},
},
);
});
});

it('should leave after destroying', async () => {
joinedSubject.next(anotherSession);

const messagesPromise = firstValueFrom(
channel.observeMessages().pipe(toArray()),
);
const statisticsPromise = firstValueFrom(
channel.observeMessages().pipe(toArray()),
);

channel.destroy();

await expect(messagesPromise).resolves.toEqual([]);
await expect(statisticsPromise).resolves.toEqual([]);
async function waitForSessionExists() {
await waitFor(() => {
expect(sessionManager.leave).toHaveBeenCalled();
statisticsSubject.next(peerConnectionStatistics);
expect(
Object.values(channel.getStatistics().peerConnections).length,
).toBe(1);
});
await waitFor(() => {
expect(peerConnection.close).toHaveBeenCalled();
});
});
}

function createChannel(deferredTurnConfig = false) {
if (deferredTurnConfig) {
// Simulate deferred TURN server configuration
widgetApi.observeTurnServers.mockImplementation(() => {
const exampleTurn = of({
urls: ['turn:turn.example.com'],
username: 'example-turn-username',
credential: 'example-turn-credentials',
});
return exampleTurn.pipe(delay(500));
});
}

channel = new WebRtcCommunicationChannel(
widgetApi,
sessionManager,
signalingChannel,
'whiteboard-id',
enableObserveVisibilityStateSubject,
250,
);
}
});
Loading

0 comments on commit 6e6fbe7

Please sign in to comment.