Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MM-62925] introduce websocket client-side ping #8633

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
244 changes: 227 additions & 17 deletions app/client/websocket/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {WebSocketReadyState, getOrCreateWebSocketClient} from '@mattermost/react
import {WebsocketEvents} from '@constants';
import DatabaseManager from '@database/manager';
import {getConfigValue} from '@queries/servers/system';
import {enableFakeTimers, disableFakeTimers, advanceTimers} from '@test/timer_helpers';
import {hasReliableWebsocket} from '@utils/config';
import {logDebug, logInfo, logError, logWarning} from '@utils/log';

Expand Down Expand Up @@ -44,27 +45,41 @@ const mockedHasReliableWebsocket = jest.mocked(hasReliableWebsocket);
const mockedGetOrCreateWebSocketClient = jest.mocked(getOrCreateWebSocketClient);

describe('WebSocketClient', () => {
let client: WebSocketClient;
const serverUrl = 'https://example.com';
const token = 'test-token';

const mockConn = {
const createMockConn = () => ({
onOpen: jest.fn(),
onClose: jest.fn(),
onError: jest.fn(),
onMessage: jest.fn(),
open: jest.fn(),
close: jest.fn(),
invalidate: jest.fn(),
send: jest.fn(),
readyState: WebSocketReadyState.OPEN,
};
const mockClient = {client: mockConn};
mockedGetOrCreateWebSocketClient.mockResolvedValue(mockClient as any);
mockedHasReliableWebsocket.mockReturnValue(false);
readyState: WebSocketReadyState.CLOSED,
open() {
this.readyState = WebSocketReadyState.OPEN;
this.onOpen.mock.calls[0][0]({});
},
close() {
this.readyState = WebSocketReadyState.CLOSED;
this.onClose.mock.calls[0][0]({});
},
});

let mockConn: ReturnType<typeof createMockConn>;
let client: WebSocketClient;

beforeEach(() => {
mockConn = createMockConn();
const mockClient = {client: mockConn};
mockedGetOrCreateWebSocketClient.mockResolvedValue(mockClient as any);
mockedHasReliableWebsocket.mockReturnValue(false);
client = new WebSocketClient(serverUrl, token);
enableFakeTimers();
});

afterEach(() => {
client.close(true);
disableFakeTimers();
});

it('should initialize the WebSocketClient', async () => {
Expand All @@ -85,7 +100,7 @@ describe('WebSocketClient', () => {

await client.initialize({}, true);

mockConn.onOpen.mock.calls[0][0]();
expect(mockConn.readyState).toBe(WebSocketReadyState.OPEN);

expect(logInfo).toHaveBeenCalledWith('websocket connected to', 'wss://example.com/api/v4/websocket');
expect(firstConnectCallback).toHaveBeenCalled();
Expand All @@ -97,8 +112,6 @@ describe('WebSocketClient', () => {

await client.initialize();

mockConn.onOpen.mock.calls[0][0]();

expect(logInfo).toHaveBeenCalledWith('websocket re-established connection to', 'wss://example.com/api/v4/websocket');
expect(reconnectCallback).toHaveBeenCalled();
});
Expand Down Expand Up @@ -130,12 +143,35 @@ describe('WebSocketClient', () => {

await client.initialize();

mockConn.onClose.mock.calls[0][0]({});
mockConn.close();

expect(logInfo).toHaveBeenCalledWith('websocket closed', 'wss://example.com/api/v4/websocket');
expect(closeCallback).toHaveBeenCalled();
});

it('should handle WebSocket close event - reconnect', async () => {
enableFakeTimers();

const closeCallback = jest.fn();
client.setCloseCallback(closeCallback);

const connectingCallback = jest.fn();
client.setConnectingCallback(connectingCallback);

await client.initialize();

expect(connectingCallback).toHaveBeenCalledTimes(1);
expect(closeCallback).toHaveBeenCalledTimes(0);

mockConn.close();

await advanceTimers(6000);

expect(connectingCallback).toHaveBeenCalledTimes(2);
expect(closeCallback).toHaveBeenCalledTimes(1);
expect(mockConn.readyState).toBe(WebSocketReadyState.OPEN);
});

it('should handle WebSocket close event - tls handshake error', async () => {
await client.initialize();
const message = {code: 1015, reason: 'tls handshake error'};
Expand Down Expand Up @@ -242,9 +278,16 @@ describe('WebSocketClient', () => {

client.sendUserTypingEvent('channel1', 'parent1');

expect(mockConn.send).toHaveBeenCalledWith(JSON.stringify({
action: 'user_typing',
expect(mockConn.send).toHaveBeenNthCalledWith(1, JSON.stringify({
action: 'authentication_challenge',
seq: 1,
data: {
token: 'test-token',
},
}));
expect(mockConn.send).toHaveBeenNthCalledWith(2, JSON.stringify({
action: 'user_typing',
seq: 2,
data: {
channel_id: 'channel1',
parent_id: 'parent1',
Expand All @@ -253,7 +296,7 @@ describe('WebSocketClient', () => {
});

it('should fail to send user typing event', async () => {
client.close();
client.close(true);
client.sendUserTypingEvent('channel1', 'parent1');

expect(mockConn.send).not.toHaveBeenCalled();
Expand All @@ -264,4 +307,171 @@ describe('WebSocketClient', () => {

expect(client.isConnected()).toBe(true);
});

it('should send ping messages on interval and handle pong responses', async () => {
await client.initialize();

// Wait until we get a client PING
await advanceTimers(30100);
expect(mockConn.send).toHaveBeenNthCalledWith(1, JSON.stringify({
action: 'authentication_challenge',
seq: 1,
data: {
token: 'test-token',
},
}));
expect(mockConn.send).toHaveBeenNthCalledWith(2, JSON.stringify({
action: 'ping',
seq: 2,
}));

// Second ping should be sent if we got a pong response
const pongMessage = {data: {text: WebsocketEvents.PONG}, seq_reply: 2, status: 'OK'};
mockConn.onMessage.mock.calls[0][0]({message: pongMessage});

// Second ping should be sent if we got a pong response
await advanceTimers(30100);
expect(mockConn.send).toHaveBeenNthCalledWith(3, JSON.stringify({
action: 'ping',
seq: 3,
}));

// Verify ping sequence increments
const pongMessage2 = {data: {text: WebsocketEvents.PONG}, seq_reply: 3, status: 'OK'};
mockConn.onMessage.mock.calls[0][0]({message: pongMessage2});

await advanceTimers(30100);
expect(mockConn.send).toHaveBeenNthCalledWith(4, JSON.stringify({
action: 'ping',
seq: 4,
}));
});

it('should handle ping timeouts and reconnect', async () => {
await client.initialize();

// Send first ping
await advanceTimers(30100);
expect(mockConn.send).toHaveBeenNthCalledWith(1, JSON.stringify({
action: 'authentication_challenge',
seq: 1,
data: {
token: 'test-token',
},
}));
expect(mockConn.send).toHaveBeenNthCalledWith(2, JSON.stringify({
action: 'ping',
seq: 2,
}));

// No pong received, next interval should trigger close
await advanceTimers(30100);
expect(mockConn.onClose).toHaveBeenCalled();

// Reset mock and verify reconnect behavior
mockConn.onClose.mockClear();
mockConn.send.mockClear();

// Should attempt to reconnect after timeout
await advanceTimers(3000);

// Should start pinging again after reconnect
mockConn.onOpen.mock.calls[0][0]();
await advanceTimers(30000);

expect(mockConn.send).toHaveBeenNthCalledWith(2, JSON.stringify({
action: 'authentication_challenge',
seq: 2,
data: {
token: 'test-token',
},
}));
expect(mockConn.send).toHaveBeenNthCalledWith(3, JSON.stringify({
action: 'ping',
seq: 3,
}));
});

it('should clear ping interval on close', async () => {
enableFakeTimers();

await client.initialize();
mockConn.onOpen.mock.calls[0][0](); // Complete the connection
mockConn.send.mockClear(); // Clear the initial authentication call

// Advance timer - no ping should be sent
await advanceTimers(20000);

client.close(true);

// Advance timer - no ping should be sent
await advanceTimers(20000);

expect(mockConn.send).not.toHaveBeenCalled();
});

it('should reset ping interval state when reconnecting during pending ping', async () => {
enableFakeTimers();

const closeCallback = jest.fn();
client.setCloseCallback(closeCallback);

const connectingCallback = jest.fn();
client.setConnectingCallback(connectingCallback);

await client.initialize();

// Wait until we get a client PING
await advanceTimers(30100);

expect(mockConn.send).toHaveBeenNthCalledWith(1, JSON.stringify({
action: 'authentication_challenge',
seq: 1,
data: {
token: 'test-token',
},
}));
expect(mockConn.send).toHaveBeenNthCalledWith(2, JSON.stringify({
action: 'ping',
seq: 2,
}));

mockConn.close();

// Let connection reconnect
await advanceTimers(5100);

// And client for PINGs to start again
await advanceTimers(30100);

expect(connectingCallback).toHaveBeenCalledTimes(2);
expect(closeCallback).toHaveBeenCalledTimes(1);
expect(mockConn.send).toHaveBeenNthCalledWith(3, JSON.stringify({
action: 'authentication_challenge',
seq: 1,
data: {
token: 'test-token',
},
}));
expect(mockConn.send).toHaveBeenNthCalledWith(4, JSON.stringify({
action: 'ping',
seq: 2,
}));

// Second ping should be sent if we got a pong response
const pongMessage = {data: {text: WebsocketEvents.PONG}, seq_reply: 2, status: 'OK'};
mockConn.onMessage.mock.calls[0][0]({message: pongMessage});

// Ensure we continue to get client PINGs
await advanceTimers(30100);
expect(mockConn.send).toHaveBeenNthCalledWith(5, JSON.stringify({
action: 'ping',
seq: 3,
}));

expect(connectingCallback).toHaveBeenCalledTimes(2);
expect(closeCallback).toHaveBeenCalledTimes(1);

disableFakeTimers();
});
});
Loading
Loading