Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions src/websocket/websocket-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import type { IncomingMessage } from 'node:http';

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { type WebSocket, WebSocketServer } from 'ws';

import * as websocket from './websocket.js';
import { startWebSocketServerWithRetry } from './websocket-server.js';

interface MockWebSocketServer extends WebSocketServer {
on: ReturnType<typeof vi.fn>;
once: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
}
interface MockWebSocket extends WebSocket {
send: ReturnType<typeof vi.fn>;
}

vi.mock('./websocket.js');
vi.mock('ws');

const WS_SERVER_PORTS = [3000, 3001, 3002];
const WS_SERVER_PATH = '/cap-console/logs';
const WELCOME_MESSAGE = {
path: '/cap-console/logs',
data: {
type: 'welcome',
message: 'Welcome to CAP console plugin',
path: WS_SERVER_PATH,
remoteAddress: '127.0.0.1',
},
};

describe('websocket-server', () => {
let mockWebSocketServer: MockWebSocketServer;
let mockWebSocket: MockWebSocket;
let mockRequest: IncomingMessage;

const setupSuccessListeningEvent = (mockWebSocketServer: MockWebSocketServer) => {
mockWebSocketServer.once.mockImplementation((event: string, callback: () => void) => {
if (event === 'listening') {
callback();
}
});
};
const setupErrorListeningEvent = (mockWebSocketServer: MockWebSocketServer) => {
mockWebSocketServer.once.mockImplementation((event: string) => {
if (event === 'listening') {
throw new Error('EADDRINUSE');
}
});
};

beforeEach(() => {
mockWebSocketServer = {
on: vi.fn(),
once: vi.fn(),
off: vi.fn(),
} as unknown as MockWebSocketServer;

mockWebSocket = {
send: vi.fn(),
} as unknown as MockWebSocket;

mockRequest = {
url: '/cap-console/logs',
socket: {
remoteAddress: '127.0.0.1',
},
} as unknown as IncomingMessage;

vi.mocked(WebSocketServer).mockImplementation(() => mockWebSocketServer);
vi.mocked(websocket.registerWebsocketHandlers).mockImplementation(vi.fn());
});

afterEach(() => {
vi.clearAllMocks();
});

describe('startWebSocketServerWithRetry', () => {
test('should start server on first port if available', async () => {
setupSuccessListeningEvent(mockWebSocketServer);

const wss = await startWebSocketServerWithRetry(WS_SERVER_PORTS);

expect(wss).toBe(mockWebSocketServer);
expect(WebSocketServer).toHaveBeenCalledWith({ port: WS_SERVER_PORTS[0], path: WS_SERVER_PATH });
});

test('should retry on next port if first port fails', async () => {
mockWebSocketServer.once.mockImplementationOnce((event: string) => {
if (event === 'listening') {
throw new Error('EADDRINUSE');
}
});
setupSuccessListeningEvent(mockWebSocketServer);

const wss = await startWebSocketServerWithRetry(WS_SERVER_PORTS);

expect(wss).toBeDefined();
expect(WebSocketServer).toHaveBeenCalledTimes(2);
expect(WebSocketServer).toHaveBeenNthCalledWith(1, { port: WS_SERVER_PORTS[0], path: WS_SERVER_PATH });
expect(WebSocketServer).toHaveBeenNthCalledWith(2, { port: WS_SERVER_PORTS[1], path: WS_SERVER_PATH });
});

test('should throw error if all ports fail', async () => {
setupErrorListeningEvent(mockWebSocketServer);

await expect(startWebSocketServerWithRetry(WS_SERVER_PORTS)).rejects.toThrow(
'Failed to start WebSocket server on ports: 3000,3001'
);

expect(WebSocketServer).toHaveBeenCalledTimes(3);
});

test('should register server handlers on successful start', async () => {
setupSuccessListeningEvent(mockWebSocketServer);

await startWebSocketServerWithRetry([3000]);

expect(mockWebSocketServer.on).toHaveBeenCalledWith('connection', expect.any(Function));
});

test('should handle connection event and sent welcome message', async () => {
let connectionHandler: (ws: WebSocket, req: IncomingMessage) => void;
setupSuccessListeningEvent(mockWebSocketServer);
mockWebSocketServer.on.mockImplementation(
(event: string, callback: (ws: WebSocket, req: IncomingMessage) => void) => {
if (event === 'connection') {
connectionHandler = callback;
}
}
);

await startWebSocketServerWithRetry([3000]);

connectionHandler!(mockWebSocket, mockRequest);
const sentMessage = JSON.parse(mockWebSocket.send.mock.calls[0][0]);

expect(websocket.registerWebsocketHandlers).toHaveBeenCalledWith(mockWebSocket);

expect(sentMessage).toMatchObject(WELCOME_MESSAGE);
expect(sentMessage.data.timestamp).toBeDefined();
});

test.each([
['URL', { url: undefined, socket: { remoteAddress: '127.0.0.1' } }],
['remote address', { url: '/cap-console/logs', socket: { remoteAddress: undefined } }],
])('should not send welcome message if request %s is missing', async (_description, requestData) => {
let connectionHandler: (ws: WebSocket, req: IncomingMessage) => void;
setupSuccessListeningEvent(mockWebSocketServer);
mockWebSocketServer.on.mockImplementation(
(event: string, callback: (ws: WebSocket, req: IncomingMessage) => void) => {
if (event === 'connection') {
connectionHandler = callback;
}
}
);

const mockRequestWithMissingData = requestData as unknown as IncomingMessage;

await startWebSocketServerWithRetry([3000]);

connectionHandler!(mockWebSocket, mockRequestWithMissingData);

expect(websocket.registerWebsocketHandlers).toHaveBeenCalledWith(mockWebSocket);
expect(mockWebSocket.send).not.toHaveBeenCalled();
});
});
});
181 changes: 181 additions & 0 deletions src/websocket/websocket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { WebSocket } from 'ws';

const mockSetRootLogLevel = vi.fn();
const mockExtendCdsLog = vi.fn();
const mockRestoreCdsLog = vi.fn();

vi.mock('../cds-logger/extended-cds-log-factory.js', () => ({
default: {
getInstance: vi.fn(() => ({
setRootLogLevel: mockSetRootLogLevel,
extendCdsLog: mockExtendCdsLog,
restoreCdsLog: mockRestoreCdsLog,
})),
},
}));

const { broadcastLogEvent, registerWebsocketHandlers } = await import('./websocket.js');

const LOG_UPDATE_MESSAGE = (logger: string) => ({
command: 'logging/update',
data: {
loggers: [
{
logger,
level: 'DEBUG',
group: false,
},
],
},
});
const LOG_MESSAGE = {
path: '/test',
data: {
level: 'INFO',
logger: 'test-logger',
thread: 'main',
type: 'log',
message: ['test message'],
ts: Date.now(),
},
};

describe('websocket', () => {
const openConnections: Array<{ ws: WebSocket; closeHandler: () => void }> = [];
let mockWebSocket: WebSocket;

const getHandlerFromWebsocket = (ws: WebSocket, event: string) => {
return (ws.on as ReturnType<typeof vi.fn>).mock.calls.find((call) => call[0] === event)?.[1];
};

const registerAndTrackConnection = (ws: WebSocket) => {
registerWebsocketHandlers(ws);

const closeHandler = getHandlerFromWebsocket(ws, 'close');
if (closeHandler) {
openConnections.push({ ws, closeHandler });
}
};

beforeEach(() => {
vi.clearAllMocks();

mockWebSocket = {
send: vi.fn(),
on: vi.fn(),
} as unknown as WebSocket;
});

afterEach(() => {
openConnections.forEach(({ closeHandler }) => closeHandler());
openConnections.length = 0;
});

describe('registerWebsocketHandlers', () => {
test('should register message and close handlers', () => {
registerAndTrackConnection(mockWebSocket);

expect(mockWebSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
expect(mockWebSocket.on).toHaveBeenCalledWith('close', expect.any(Function));
expect(mockWebSocket.on).toHaveBeenCalledTimes(2);
});

test('should only extend CdsLogFactory on first connection', () => {
const mockWebSocket2 = {
send: vi.fn(),
on: vi.fn(),
} as unknown as WebSocket;
registerAndTrackConnection(mockWebSocket2);
registerAndTrackConnection(mockWebSocket);
registerAndTrackConnection(mockWebSocket2);

expect(mockExtendCdsLog).toHaveBeenCalledTimes(1);
});

test('should restore original log factory on last disconnection', async () => {
const mockWebSocket2 = {
send: vi.fn(),
on: vi.fn(),
} as unknown as WebSocket;
registerAndTrackConnection(mockWebSocket);
registerAndTrackConnection(mockWebSocket2);

openConnections[0].closeHandler();

expect(mockRestoreCdsLog).not.toHaveBeenCalled();

openConnections[1].closeHandler();

expect(mockRestoreCdsLog).toHaveBeenCalledTimes(1);
});

test('should handle valid logging update message', () => {
registerAndTrackConnection(mockWebSocket);

const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message');
const validMessage = Buffer.from(JSON.stringify(LOG_UPDATE_MESSAGE('root')));

messageHandler(validMessage);

expect(mockSetRootLogLevel).toHaveBeenCalledWith('DEBUG');
});

test('should ignore non-root logger updates', () => {
registerAndTrackConnection(mockWebSocket);

const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message');
const validMessage = Buffer.from(JSON.stringify(LOG_UPDATE_MESSAGE('some.other.logger')));

messageHandler(validMessage);

expect(mockSetRootLogLevel).not.toHaveBeenCalled();
});

test('should ignore invalid message format', () => {
registerAndTrackConnection(mockWebSocket);

const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message');
const invalidMessage = Buffer.from(JSON.stringify({ invalid: 'message' }));

messageHandler(invalidMessage);

expect(mockSetRootLogLevel).not.toHaveBeenCalled();
});

test('should handle malformed JSON gracefully', () => {
registerAndTrackConnection(mockWebSocket);

const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message');
const invalidMessage = Buffer.from('not valid json');

expect(() => messageHandler(invalidMessage)).toThrow();
});
});

describe('broadcastLogEvent', () => {
test('should broadcast log event to all open connections', () => {
const mockWebSocket2 = {
send: vi.fn(),
on: vi.fn(),
} as unknown as WebSocket;
registerAndTrackConnection(mockWebSocket);
registerAndTrackConnection(mockWebSocket2);

broadcastLogEvent(LOG_MESSAGE);

expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(LOG_MESSAGE));
expect(mockWebSocket2.send).toHaveBeenCalledWith(JSON.stringify(LOG_MESSAGE));
});

test('should handle send errors gracefully', () => {
(mockWebSocket.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('Send failed');
});

registerAndTrackConnection(mockWebSocket);

expect(() => broadcastLogEvent(LOG_MESSAGE)).not.toThrow();
});
});
});
Loading